test: enable multi-global WPTs

Signed-off-by: Filip Skokan <panva.ip@gmail.com>
Co-authored-by: Aviv Keller <me@aviv.sh>
Signed-off-by: Aviv Keller <me@aviv.sh>
PR-URL: https://github.com/nodejs/node/pull/64894
Fixes: https://github.com/nodejs/node/issues/43583
Reviewed-By: Filip Skokan <panva.ip@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Matthew Aitken <maitken033380023@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
This commit is contained in:
Filip SkokanandAviv Keller authored and avivkeller committed 2026-08-15 13:15:49 -07:00
1 parent b9afdd0b87
commit 78cca3671f
11 files changed
+364 -31

No files matched your search

+59 -20
View File
@@ -8,11 +8,11 @@ const path = require('path');
const events = require('events');
const os = require('os');
const { inspect } = require('util');
const { pathToFileURL } = require('url');
const { Worker } = require('worker_threads');
const { fork } = require('child_process');
const workerPath = path.join(__dirname, 'wpt/worker.js');
const kRunWorkerGlobals = false;
const wptNonTestDirs = new Set(['resources', 'support', 'tools']);
function getBrowserProperties() {
@@ -191,6 +191,24 @@ class ResourceLoader {
fixtures.path('wpt', base, url);
}
/**
* Map a URL that a test would have fetched from the WPT server (an
* absolute path, or a path relative to the test file) to a file: URL
* into the fixtures directory. URLs that already have a scheme (data:,
* blob:, http:, ...) are returned unchanged.
* @param {string} from the path of the file loading this resource,
* relative to the WPT folder.
* @param {string|URL} url the url of the resource being loaded.
* @returns {string}
*/
mapServerURL(from, url) {
url = `${url}`;
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:|^\/\//.test(url)) {
return url;
}
return pathToFileURL(this.toRealFilePath(from, url)).href;
}
/**
* Load a resource in test/fixtures/wpt specified with a URL
* @param {string} from the path of the file loading this resource,
@@ -818,7 +836,11 @@ class WPTRunner {
this.resource = new ResourceLoader(path);
this.concurrency = concurrency;
this.flags = [];
// Since we need to prepare the Web Worker APIs
// in the harness that runs on all WPT workers,
// we enable the API globally. This has no practical
// effect on the non-web-worker tests, however.
this.flags = ['--experimental-web-worker'];
this.globalThisInitScripts = [];
this.initScript = null;
@@ -845,7 +867,7 @@ class WPTRunner {
* @param {string[]} flags
*/
setFlags(flags) {
this.flags = flags;
this.flags = this.flags.concat(flags);
}
/**
@@ -933,8 +955,17 @@ class WPTRunner {
const absolutePath = spec.getAbsolutePath();
const relativePath = spec.getRelativePath();
const harnessPath = fixtures.path('wpt', 'resources', 'testharness.js');
// Scripts specified with the `// META: script=` header
const scriptsToRun = meta.script?.map((script) => {
// *.worker.js tests are dedicated worker tests by definition. Each
// dedicated worker variant generated from a multi-global (*.any.js)
// test also runs inside an actual Web Worker. Refs:
// https://web-platform-tests.org/writing-tests/testharness.html#multi-global-tests
const isAnyTest = spec.isAnyTest();
const isWebWorkerTest = spec.isWebWorkerTest();
// Scripts specified with the `// META: script=` header. For tests
// that run inside a Web Worker they are imported by the worker
// instead.
const scriptsToRun = isWebWorkerTest ? [] : meta.script?.map((script) => {
const obj = {
filename: this.resource.toRealFilePath(relativePath, script),
code: this.resource.read(relativePath, script),
@@ -942,13 +973,15 @@ class WPTRunner {
this.scriptsModifier?.(obj);
return obj;
}) ?? [];
// The actual test
const obj = {
code: content,
filename: absolutePath,
};
this.scriptsModifier?.(obj);
scriptsToRun.push(obj);
if (!isWebWorkerTest) {
// The actual test
const obj = {
code: content,
filename: absolutePath,
};
this.scriptsModifier?.(obj);
scriptsToRun.push(obj);
}
jobs.push(run(async () => {
this.inProgress.add(spec);
@@ -964,6 +997,18 @@ class WPTRunner {
filename: harnessPath,
},
scriptsToRun,
// Set when the test runs inside an actual Web Worker.
webWorker: isWebWorkerTest ? {
path: absolutePath,
isAnyTest,
initScript: this.initScript,
title: meta.title,
variant: spec.variant,
scripts: meta.script?.map(
(script) => this.resource.toRealFilePath(relativePath, script),
) ?? [],
skippedTests: spec.skippedTests,
} : undefined,
needsGc: !!meta.script?.find((script) => script === '/common/gc.js'),
skippedTests: spec.skippedTests,
}, {
@@ -1255,10 +1300,6 @@ class WPTRunner {
this.skippedSpecCount = 0;
const arg = process.argv[2];
for (const spec of this.specs) {
if (!kRunWorkerGlobals && spec.isWebWorkerTest()) {
continue;
}
if (arg) {
if (spec.isSelectedBy(arg)) {
queue.push(spec);
@@ -1281,13 +1322,11 @@ class WPTRunner {
}
// If the tests are run as `node test/wpt/test-something.js subset.any.js`,
// only `subset.any.js` (all enabled variants and globals) will be run by
// the runner.
// only `subset.any.js` (all variants and globals) will be run by the runner.
// If the tests are run as `node test/wpt/test-something.js 'subset.any.js?1-10'`,
// only the `?1-10` variant of `subset.any.js` will be run by the runner.
// A test path as printed with the results, e.g.
// `'dir/subset.any.worker.html?1-10'`, runs exactly that one when its
// global is enabled.
// `'dir/subset.any.worker.html?1-10'`, runs exactly that one.
if (arg && queue.length === 0) {
throw new Error(`${arg} not found!`);
}
+133
View File
@@ -0,0 +1,133 @@
'use strict';
// Runs a WPT test file inside a Web Worker
// Refs: https://web-platform-tests.org/writing-tests/testharness.html
const { pathToFileURL } = require('url');
const {
runInThisContext,
constants: { USE_MAIN_CONTEXT_DEFAULT_LOADER },
} = require('vm');
globalThis.onmessage = ({ data }) => {
// Let the test install its own handler.
globalThis.onmessage = null;
const { ResourceLoader } = require(data.wptRunner);
const resource = new ResourceLoader(data.wptPath);
globalThis.fetch = function fetch(file) {
return resource.readAsFetch(data.testRelativePath, file);
};
// Pretend the worker was served from the URL the WPT server would have
// used
const fakePath = (data.isAnyTest ?
data.testRelativePath.replace(/\.any\.js$/, '.any.worker.js') :
data.testRelativePath).replace(/\\/g, '/');
const fakeURL = new URL(`/${fakePath}${data.variant}`, 'http://wpt');
// eslint-disable-next-line no-undef
const fakeLocation = { __proto__: WorkerLocation.prototype };
for (const key of ['href', 'origin', 'protocol', 'host', 'hostname',
'port', 'pathname', 'search', 'hash']) {
Object.defineProperty(fakeLocation, key, {
value: fakeURL[key],
enumerable: true,
});
}
Object.defineProperty(fakeLocation, 'toString', {
value: function toString() { return fakeURL.href; },
enumerable: true,
});
Object.defineProperty(globalThis, 'location', {
value: fakeLocation,
enumerable: true,
configurable: true,
});
const testharnessPath =
pathToFileURL(resource.toRealFilePath(data.testRelativePath,
'/resources/testharness.js')).href;
// If there are skip patterns, wrap the test functions to prevent
// execution of matching tests. This must happen after testharness.js is
// loaded but before the test scripts run.
function applySkips() {
if (!data.skippedTests?.length) {
return;
}
function isSkipped(name) {
for (const matcher of data.skippedTests) {
if (typeof matcher === 'string') {
if (name === matcher) return true;
} else if (matcher.test(name)) {
return true;
}
}
return false;
}
for (const fn of ['test', 'async_test', 'promise_test']) {
const original = globalThis[fn];
globalThis[fn] = function(func, name, ...rest) {
if (typeof name === 'string' && isSkipped(name)) {
// eslint-disable-next-line no-undef
postMessage({ type: 'skip', name });
return;
}
return original.call(this, func, name, ...rest);
};
}
}
// Tests fetch scripts and nested worker scripts from the WPT server; map
// those URLs into the fixtures directory.
const realImportScripts = globalThis.importScripts;
globalThis.importScripts = function importScripts(...urls) {
const mapped = urls.map(
(url) => resource.mapServerURL(data.testRelativePath, url));
const result = realImportScripts.apply(this, mapped);
if (mapped.includes(testharnessPath)) {
applySkips();
}
return result;
};
const RealWorker = globalThis.Worker;
globalThis.Worker = class Worker extends RealWorker {
constructor(url, options) {
super(resource.mapServerURL(data.testRelativePath, url), options);
}
};
if (data.isAnyTest) {
// Emulate the generated .any.worker.js wrapper script.
// Refs: https://github.com/web-platform-tests/wpt/blob/master/tools/serve/serve.py
globalThis.GLOBAL = {
isWindow() { return false; },
isWorker() { return true; },
isShadowRealm() { return false; },
};
}
if (data.initScript) {
runInThisContext(data.initScript, {
importModuleDynamically: USE_MAIN_CONTEXT_DEFAULT_LOADER,
});
}
if (data.title) {
globalThis.META_TITLE = data.title;
}
if (data.isAnyTest) {
globalThis.importScripts('/resources/testharness.js');
for (const script of data.scripts) {
globalThis.importScripts(pathToFileURL(script).href);
}
globalThis.importScripts(pathToFileURL(data.path).href);
// eslint-disable-next-line no-undef
done();
} else {
// *.worker.js tests import testharness.js and call done() themselves.
globalThis.importScripts(pathToFileURL(data.path).href);
}
};
+51
View File
@@ -1,5 +1,7 @@
'use strict';
const path = require('path');
const { pathToFileURL } = require('url');
const {
runInNewContext,
runInThisContext,
@@ -38,6 +40,15 @@ function run(workerData) {
const { ResourceLoader } = require(workerData.wptRunner);
const resource = new ResourceLoader(workerData.wptPath);
// Tests create workers with URLs the WPT server would have served them
// from; map them into the fixtures directory.
const RealWorker = globalThis.Worker;
globalThis.Worker = class Worker extends RealWorker {
constructor(url, options) {
super(resource.mapServerURL(workerData.testRelativePath, url), options);
}
};
if (workerData.needsGc) {
// See https://github.com/nodejs/node/issues/16595#issuecomment-340288680
setFlagsFromString('--expose-gc');
@@ -131,4 +142,44 @@ function run(workerData) {
importModuleDynamically: USE_MAIN_CONTEXT_DEFAULT_LOADER,
});
}
if (workerData.webWorker) {
const worker = new RealWorker(
pathToFileURL(path.join(__dirname, 'webworker.js')));
worker.postMessage({
wptRunner: workerData.wptRunner,
wptPath: workerData.wptPath,
testRelativePath: workerData.testRelativePath,
...workerData.webWorker,
});
let completed = false;
worker.addEventListener('message', (event) => {
if (event.data?.type === 'complete') {
completed = true;
}
// Skipped subtests never register with the testharness inside the
// worker; the runner is notified about them directly.
if (event.data?.type === 'skip') {
send({ type: 'skip', name: event.data.name });
}
});
worker.addEventListener('error', (event) => {
if (completed) {
return;
}
clearTimeout(timeout);
send({
type: 'completion',
status: {
status: 1,
message: event.message,
stack: event.error?.stack,
},
});
});
// eslint-disable-next-line no-undef
fetch_tests_from_worker(worker);
}
}
+12
View File
@@ -0,0 +1,12 @@
'use strict';
// Throw only once a result has been reported, so the harness has to surface
// the error rather than treat the test file as already done.
add_result_callback(() => {
setTimeout(() => {
throw new Error('probe error after first result');
}, 0);
});
test(() => {}, 'reported before error');
async_test(() => {}, 'waiting for error');
+13 -1
View File
@@ -16,7 +16,7 @@ const queueProbe = process.env.NODE_TEST_WPT_QUEUE_PROBE === '1';
const harnessPath = fixtures.path('wpt', 'resources', 'testharness.js');
const specPath = fixtures.path('wpt-backends-spec.js');
const execArgv = [];
const execArgv = ['--experimental-web-worker'];
function payload(throws) {
return {
@@ -175,6 +175,18 @@ async function main() {
runDriver(driver, spec, 'process'),
);
}
const windowResults = runDriver(
'test-events.js',
'dom/events/Event-constructors.any.html',
'thread',
);
const workerResults = runDriver(
'test-events.js',
'dom/events/Event-constructors.any.worker.html',
'thread',
).map((line) => line.replace('.any.worker.html', '.any.html'));
assert.deepStrictEqual(workerResults, windowResults);
}
if (queueProbe) {
@@ -0,0 +1,79 @@
'use strict';
const common = require('../common');
const fixtures = require('../common/fixtures');
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { fork } = require('child_process');
const { Worker } = require('worker_threads');
const workerPath = path.join(__dirname, '../common/wpt/worker.js');
const harnessPath = fixtures.path('wpt', 'resources', 'testharness.js');
const execArgv = ['--experimental-web-worker'];
const workerData = {
testRelativePath: 'workers/error-after-result.any.js',
wptRunner: path.join(__dirname, '../common/wpt.js'),
wptPath: 'workers',
initScript: null,
harness: {
code: fs.readFileSync(harnessPath, 'utf8'),
filename: harnessPath,
},
scriptsToRun: [],
webWorker: {
path: fixtures.path('web-worker', 'wpt-error-after-result.js'),
isAnyTest: true,
initScript: null,
variant: '',
scripts: [],
skippedTests: [],
},
needsGc: false,
skippedTests: [],
};
// Both backends have to report an error thrown by a Web Worker after a result
// was already reported, and report it the same way.
check('thread', () => {
const worker = new Worker(workerPath, { execArgv, workerData });
return { runner: worker, stop: () => worker.terminate() };
});
check('process', () => {
const child = fork(workerPath, { execArgv, serialization: 'advanced' });
child.send(workerData);
return { runner: child, stop: () => child.kill() };
});
function check(backend, start) {
const { runner, stop } = start();
const timeout = setTimeout(() => {
stop();
assert.fail(`WPT worker error was not reported on the ${backend} backend`);
}, common.platformTimeout(10_000));
const onCompletion = common.mustCall((status) => {
clearTimeout(timeout);
assert.strictEqual(status.status, 1);
assert.strictEqual(status.message, 'probe error after first result');
stop();
});
runner.on('error', common.mustNotCall());
// The error reaches the runner straight from the Web Worker while results
// travel through the harness, so it can arrive before them.
runner.on('message', common.mustCallAtLeast((message) => {
switch (message.type) {
case 'result':
assert.strictEqual(message.result.name, 'reported before error');
assert.strictEqual(message.result.status, 0);
break;
case 'completion':
onCompletion(message.status);
break;
default:
assert.fail(`Unexpected message type: ${message.type}`);
}
}, 1));
}
-3
View File
@@ -33,7 +33,4 @@ module.exports = {
],
},
},
'Blob-in-worker.worker.js': {
skip: 'Depends on Web Workers API',
},
};
-3
View File
@@ -21,9 +21,6 @@
"event-global-set-before-handleEvent-lookup.window.js": {
"skip": "window is not defined"
},
"event-global.worker.js": {
"skip": "importScripts is not defined"
},
"legacy-pre-activation-behavior.window.js": {
"skip": "document is not defined"
},
+1 -1
View File
@@ -19,7 +19,7 @@ module.exports = {
},
} : {}),
'idlharness.any.js': {
'idlharness.any.html': {
fail: {
expected: [
'Window interface: attribute performance',
+1 -1
View File
@@ -1,7 +1,7 @@
'use strict';
module.exports = {
'historical.any.js': {
'historical.any.html': {
'fail': {
'expected': [
'searchParams on location object',
+15 -2
View File
@@ -27,11 +27,24 @@
"Global object's setter throws when called on incompatible object",
"Global object's operation throws when called on incompatible object",
"Global object's getter works when called on null / undefined",
"Global object's setter works when called on null / undefined",
"Global object's setter works when called on null / undefined"
]
}
},
"ecmascript-binding/global-object-implicit-this-value.any.html": {
"fail": {
"expected": [
"ReferenceError: addEventListener is not defined"
]
}
},
"ecmascript-binding/global-object-implicit-this-value.any.worker.html": {
"fail": {
"expected": [
"Global object's operation works when called on null / undefined"
]
}
},
"ecmascript-binding/legacy-factor-function-subclass.window.js": {
"fail": {
"expected": [
@@ -46,7 +59,7 @@
]
}
},
"ecmascript-binding/no-regexp-special-casing.any.js": {
"ecmascript-binding/no-regexp-special-casing.any.html": {
"fail": {
"expected": [
"Can be used as an object implementing a callback interface"