web-test-runner.config.mjs
Rama master · réplica de lectura del repositorio original.
/**
* 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=<n>/<total> 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 "<n>/<total>" 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 => `<!doctype html>
<html lang="en-US">
<body>
<div id="egw_script_id" data-url="test.com"></div>
<!-- Classic script, so it runs before the deferred modules below - same order as a
real page. Legacy et2 code and bundled vendor libraries (eg. cropper, pulled
in by Et2Avatar) still expect the jQuery global to already be there. -->
<script src="/vendor/bower-asset/jquery/dist/jquery.min.js"></script>
<script type="module">
// CI/test environments can expose POSIX locale tags that Intl rejects.
// Make sure the document has a lang for shoelace / library localization to find
document.documentElement.lang = 'en-US';
// "strict": widget conformance warnings (WidgetConformance.ts) become failures
window.JSTEST_CONFORMANCE = ${JSON.stringify(process.env.JSTEST_CONFORMANCE || 'warn')};
Object.defineProperty(window.navigator, 'language', {value: 'en-US', configurable: true});
Object.defineProperty(window.navigator, 'languages', {value: ['en-US'], configurable: true});
// egw.js redirects the page to ?cd=popup at module scope when it can't find a
// framework object, which under web-test-runner reloads the page mid-run and
// aborts the whole test file ("Tests were interrupted because the page was
// reloaded"). Nothing here needs a real framework, we only need the check to
// find *something*, so give it an inert stub. Do NOT instead fake the
// data-include list egw.js also consults - it actually imports those files.
window.framework = window.framework || {};
if(!window.egw)
{
const egwFallback = function() { return window.egw || egwFallback; };
Object.assign(egwFallback, {
// egw_core.ts only builds the real egw object (the one with extend(),
// so every egw module can register itself) out of an existing
// window.egw marked prefsOnly - that is how Api\Framework::header()
// seeds it in a real page. Without it, importing anything that pulls
// in an egw module dies on "egw.extend is not a function".
prefsOnly: true,
webserverUrl: "",
lang: label => label,
debug: () => {},
image: () => "",
link: link => link,
open_link: () => {},
tooltipBind: () => {},
tooltipUnbind: () => {},
preference: () => null,
set_preference: () => {},
app_name: () => "api",
uid: () => "test"
});
window.egw = egwFallback;
}
</script>
<script type="module">
import '${testRunnerImport}';
</script>
</body>
</html>
`,
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'}})
],
};