/** * This is the configuration file for automatic TypeScript testing * * It uses "web-test-runner" to run the tests, which are written using * Mocha (https://mochajs.org/) & Chai Assertion Library (https://www.chaijs.com/api/assert/) * Playwright (https://playwright.dev/docs/intro) runs the tests in actual browsers. * * Test groups are discovered from each app with any *.test.ts file somewhere under js/ * (not necessarily directly in js/test/ - eg. api's tests live in per-widget * js/etemplate/<widget>/test/ subdirectories) and are named after that app. * * - every group: `npm run jstest` * - one app: `npm run jstest -- --group api` * - one file/glob: `npm run jstest -- api/js/etemplate/MyWidget/test/MyWidget.test.ts` * - one browser: `JSTEST_BROWSERS=chromium npm run jstest` * - one shard: `JSTEST_SHARD=1/2 npm run jstest` (every 2nd test file; CI uses this) * - conformance: `JSTEST_CONFORMANCE=strict npm run jstest` (widget conformance warnings, see * api/js/etemplate/Et2Widget/test/WidgetConformance.ts, become failures) * * Note the `--group`: a bare app name (`npm run jstest -- api`) is NOT a group selector, it is a * path, and the runner would glob the whole api/ directory. See the comment on cliFiles below. * * Trouble getting tests to run? Try manually compiling TypeScript (source & tests), that seems to help. */ import fs from 'fs'; import path from 'path'; import {playwrightLauncher} from '@web/test-runner-playwright'; import {esbuildPlugin} from '@web/dev-server-esbuild'; import {legacyWidgetShimDevServerPlugin} from './api/js/etemplate/webtest-legacy-widget-shim.mjs'; // Every *.test.ts under dir, recursing into subdirectories, so an app is discovered - and a // shard filled - regardless of how deep its test files are nested. function collectTestFiles(dir) { let entries; try { entries = fs.readdirSync(dir, {withFileTypes: true}); } catch(e) { return []; } return entries.flatMap(entry => entry.isDirectory() ? collectTestFiles(`${dir}/${entry.name}`) : entry.name.endsWith('.test.ts') ? [`${dir}/${entry.name}`] : []); } // Add any app with a *.test.ts file somewhere under js/ const appJS = fs.readdirSync('.') .filter( dir => fs.existsSync(`${dir}/js`) && fs.statSync(`${dir}/js`).isDirectory() && collectTestFiles(`${dir}/js`).length > 0, ) const testGroups = appJS.map(app => ({ name: app, files: `${app}/js/**/*.test.ts`, })); const groupFiles = Object.fromEntries(testGroups.map(({name, files}) => [name, files])); groupFiles.default = groupFiles.api; // Which browsers to run, as a comma separated list. CI gives each one its own job so they run on // separate runners instead of sharing one (.github/workflows/testing.yml), and a quick local pass // in a single browser is often enough: JSTEST_BROWSERS=chromium npm run jstest // // @web/test-runner's own --browsers flag is no use here: it only works together with --playwright, // which throws as soon as the config defines browsers itself - which this one has to, for the // per-launcher options further down. const PLAYWRIGHT_PRODUCTS = ['chromium', 'firefox', 'webkit']; const browserNames = (process.env.JSTEST_BROWSERS || 'firefox,chromium') .split(',') .map(name => name.trim().toLowerCase()) .filter(Boolean); for(const name of browserNames) { if(!PLAYWRIGHT_PRODUCTS.includes(name)) { throw new Error(`JSTEST_BROWSERS: "${name}" is not a Playwright product.\n` + `Available: ${PLAYWRIGHT_PRODUCTS.join(', ')}`); } } // JSTEST_SHARD=/ runs only this shard's share of the test files, so CI can spread the // suite over several runners. The list comes from the same walk the groups are built from, so // there is no second copy to keep in sync with this config. The split is by FILE because a file // is one browser page, and the page - not the test in it - is what the suite's time goes on: only // ~105s of a ~334s run is test code (see the launcher comment below). // // Ignored for a run with explicit files/globs: those are already a narrower selection, and a glob // cannot be split this way without expanding it first. function shardFiles(spec) { const [index, total] = String(spec).split('/').map(part => Number(part.trim())); if(!Number.isInteger(index) || !Number.isInteger(total) || total < 1 || index < 1 || index > total) { throw new Error(`JSTEST_SHARD must be "/" with 1 <= n <= total - got "${spec}"`); } // Sorted, so every shard of the same run numbers the files identically and each file is picked // up by exactly one of them. const all = appJS.flatMap(app => collectTestFiles(`${app}/js`)).sort(); const files = all.filter((file, i) => i % total === index - 1); if(!files.length) { throw new Error(`JSTEST_SHARD=${spec} selects none of the ${all.length} test files`); } return files; } // Flags that consume the following argument as their value, so we do not mistake that value // for a file to test. Taken from @web/test-runner's own option list (dist/config/readCliArgs.js). const VALUE_FLAGS = new Set([ '--files', '--root-dir', '--concurrent-browsers', '--concurrency', '--config', '--port', '--groups', '--group', '--browsers', '--esbuild-target' ]); // Positional arguments only - file paths or globs for a targeted run. // // Group names deliberately do NOT belong here: @web/test-runner declares its own `files` option // as the CLI's defaultOption, and parseConfig() merges cliArgs *after* this config, so a bare // positional always overwrites whatever `files` we set. Passing a group name that way used to // expand to the group's glob here and then get clobbered back to the bare name, which the runner // globbed as a *directory* - pulling in every .php/.svg/.xet/.map under it as a "test file". // Group selection therefore goes through the runner's own --group flag; its value must not be // collected here, or we would fall into the `files` branch below and export no groups at all. // (Broke in @web/test-runner 1.0, see bde6bcf403.) const cliFiles = []; for(let i = 2; i < process.argv.length; i++) { const arg = process.argv[i]; if(!arg) { continue; } if(arg.startsWith('-')) { // A shard is a flat file list and is exported as `files`, so there are no groups left for // --group to find - it would fail with "Could not find any group named x" instead of // saying what actually went wrong. if((arg === '--group' || arg.startsWith('--group=')) && process.env.JSTEST_SHARD) { throw new Error( `JSTEST_SHARD=${process.env.JSTEST_SHARD} cannot be combined with --group: a shard ` + `is a list of files, --group selects whole apps. Use one or the other.` ); } // "--group api" consumes the next argument; "--group=api" does not if(!arg.includes('=') && VALUE_FLAGS.has(arg)) { i++; } continue; } if(groupFiles[arg]) { throw new Error( `"${arg}" is a test group, not a file. Use: npm run jstest -- --group ${arg}\n` + `Available groups: ${Object.keys(groupFiles).join(', ')}` ); } cliFiles.push(arg); } export default { nodeResolve: true, exclude: ['**/node_modules/**'], testRunnerHtml: testRunnerImport => `
`, filterBrowserLogs(log) { // Silence some warnings we don't care about const text = log && typeof log.args[0] === 'string' ? log.args[0] : ''; if (text.includes('Lit is in dev mode.') || text.includes('Multiple versions of Lit loaded.')) { return false; } return true; }, coverageConfig: { report: true, reportDir: 'coverage', threshold: { statements: 90, branches: 65, functions: 80, lines: 90, }, }, testFramework: { config: { timeout: '3000', }, }, // concurrency: 1 is a deliberate, measured choice - but not for the reason given here before. // // Running several pages at once does NOT background them. Playwright headless pages are // separate offscreen targets, not tabs competing for one foreground slot: measured with 8 // running at once, in both products, every page reports visibilityState "visible" with // document.hasFocus() true, requestAnimationFrame at a full 60fps, ResizeObserver delivering // and no setTimeout clamping. A backgrounded tab WOULD be a real problem here - tests that // assert an ABSENCE of work (Et2Datagrid.idleSettle.test.ts: "an idle grid performs 0 update // cycles") would pass while the defect is fully present, and checking that rows rendered does // not catch it (with both APIs stubbed out, 24 rows still render) - it just is not what this // setting is protecting against. // // What raising it actually buys is CPU contention, for very little time. Full runs of this // suite (244 files, 3578 tests, both products), 2026-09-25: // // cores concurrency wall result // 12 1 334s green // 12 2 326s green // 12 4 286s 10 failed // 4 1 338s green <- what a GitHub runner has // 4 2 319s 13 failed // // 12 cores and 4 cores take the same wall clock at concurrency 1, with 60-80% of the CPU idle // throughout: the suite is not CPU-bound. Only ~105s of that ~334s is test code - the rest is // per-session page startup and module import (~1.2s per file per browser), which overlaps // poorly. So there is no 2x sitting here, while the tests contention does break are the // real-timer ones: the addressbook nextmatch files blow their 15s timeout, // Et2LazyLoadController misses its IntersectionObserver callback, and idleSettle's own // "locked" control arm stops being quiet. Push it further and the rAF-polling helpers in // Et2Datagrid.test.ts hang to the mocha timeout, which looks exactly like a product regression // (see the note there - it cost two debugging sessions). // // To make the suite faster, give it more machines rather than more pages per machine: // JSTEST_BROWSERS and JSTEST_SHARD above split it across runners that do not share a CPU, and // each of those pieces is still a concurrency-1 run. Measured on 4 cores: firefox alone 285s // and chromium alone 157s (vs 338s for the two together), and half the files in firefox 145s / // 152s - near enough linear. // // Also measured and rejected: http2: true + protocol: 'https:', to multiplex the module // requests - 478s, 41% slower. TLS on localhost costs more than multiplexing saves. // // webkit is left out: it depends on specific versions of shared libraries (libicuuc.so.66, // latest is .67). JSTEST_BROWSERS=webkit will still try, if that ever gets sorted out. browsers: browserNames.map(product => playwrightLauncher({product, concurrency: 1})), // Either an explicit set of files, or the groups - never both: parseConfig() hands a top-level // `files` to any group that lacks one and then drops it, so exporting `groups` alongside an // explicit glob silently runs every group instead of the glob. --group works because its // value is not collected as a positional above, leaving cliFiles empty. A shard is a plain // list of files, so it goes in the same slot. ...(cliFiles.length ? {files: cliFiles} : process.env.JSTEST_SHARD ? {files: shardFiles(process.env.JSTEST_SHARD)} : {groups: testGroups}), plugins: [ // must run before esbuildPlugin, so it can synthesize the legacy et2_widget_*.ts // shims (eg. et2_widget_selectbox, pulled in by et2_extension_nextmatch.ts) that // no longer exist on disk - see rollup-legacy-widget-shim.mjs for the rollup side legacyWidgetShimDevServerPlugin(), { // A local `npm run build` leaves gitignored bundles next to their sources - eg. // api/js/etemplate/etemplate2.js beside etemplate2.ts - and web-test-runner's // node-resolve tries .js before .ts, so an extensionless import silently pulls in the // last build instead of the source. That is not just stale code: the bundle defines // every widget's custom element itself, so the class under test loses the // customElements.define() race and the test ends up exercising the build. CI has no // such files, so preferring the .ts sibling is also what makes local runs match CI. name: "prefer-ts-source", resolveImport({source, context}) { if(!source.startsWith(".") || path.extname(source)) { return; } const importer = path.join(process.cwd(), path.dirname(context.path)); return fs.existsSync(path.join(importer, `${source}.ts`)) ? `${source}.ts` : undefined; } }, { name: "mock-modules", resolveImport({source}) { // map dompurify requests to package ESM build so browser ESM gets default export if (source === 'dompurify' || source.startsWith('dompurify/')) { return '/node_modules/dompurify/dist/purify.es.mjs'; } // openpgp's own types/exports map ("./lightweight") isn't resolved by this // dev-server's resolver either (same class of gap as dompurify above, and as // tsconfig.json's own classic "node" moduleResolution - see mail/js/openpgp.d.ts) - // point straight at the resolved file, matching Rollup's own production resolution if (source === 'openpgp/lightweight') { return '/node_modules/openpgp/dist/lightweight/openpgp.min.mjs'; } if (source === 'tinymce') { return '/api/js/etemplate/Et2HtmlArea/test/TinyMceStub.ts'; } if (source.startsWith('tinymce/')) { return '/api/js/etemplate/Et2HtmlArea/test/TinyMceSideEffectStub.ts'; } if (source.includes('Resumable/resumable')) { return '/api/js/etemplate/Et2File/test/ResumableStub.ts'; } if (source.includes('shortcut-buttons-flatpickr')) { return './test/FlatpickrShortcutPluginStub.js'; } else if (source.includes('scrollPlugin')) { return './test/FlatpickrScrollPluginStub.js'; } const mockModule = { "diff2html/lib/types": "/api/js/etemplate/test/Diff2HtmlTypesStub.ts", // diff2html's own ESM build imports the Node-only @profoundlogic/hogan package // (no browser field, no dist/ despite its own comment) - see Diff2HtmlStub.ts's // own docblock for why a stub is needed here rather than the real package. "diff2html": "/api/js/etemplate/test/Diff2HtmlStub.ts", }; return mockModule[source]; } }, // Handles typescript // .css as text mirrors the rollup load hook, so unsafeCSS() imports work in tests too esbuildPlugin({ts: true, tsconfig: 'tsconfig.json', loaders: {'.css': 'text'}}) ], };