Skip to content

Commit cf54167

Browse files
panvaavivkeller
andcommitted
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>
1 parent 2d8aa74 commit cf54167

11 files changed

Lines changed: 347 additions & 31 deletions

File tree

test/common/wpt.js

Lines changed: 58 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ const path = require('path');
88
const events = require('events');
99
const os = require('os');
1010
const { inspect } = require('util');
11+
const { pathToFileURL } = require('url');
1112
const { Worker } = require('worker_threads');
1213
const { fork } = require('child_process');
1314

1415
const workerPath = path.join(__dirname, 'wpt/worker.js');
15-
const kRunWorkerGlobals = false;
1616

1717
function getBrowserProperties() {
1818
const { node: version } = process.versions; // e.g. 18.13.0, 20.0.0-nightly202302078e6e215481
@@ -190,6 +190,24 @@ class ResourceLoader {
190190
fixtures.path('wpt', base, url);
191191
}
192192

193+
/**
194+
* Map a URL that a test would have fetched from the WPT server (an
195+
* absolute path, or a path relative to the test file) to a file: URL
196+
* into the fixtures directory. URLs that already have a scheme (data:,
197+
* blob:, http:, ...) are returned unchanged.
198+
* @param {string} from the path of the file loading this resource,
199+
* relative to the WPT folder.
200+
* @param {string|URL} url the url of the resource being loaded.
201+
* @returns {string}
202+
*/
203+
mapServerURL(from, url) {
204+
url = `${url}`;
205+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:|^\/\//.test(url)) {
206+
return url;
207+
}
208+
return pathToFileURL(this.toRealFilePath(from, url)).href;
209+
}
210+
193211
/**
194212
* Load a resource in test/fixtures/wpt specified with a URL
195213
* @param {string} from the path of the file loading this resource,
@@ -805,7 +823,11 @@ class WPTRunner {
805823
this.resource = new ResourceLoader(path);
806824
this.concurrency = concurrency;
807825

808-
this.flags = [];
826+
// Since we need to prepare the Web Worker APIs
827+
// in the harness that runs on all WPT workers,
828+
// we enable the API globally. This has no practical
829+
// effect on the non-web-worker tests, however.
830+
this.flags = ['--experimental-web-worker'];
809831
this.globalThisInitScripts = [];
810832
this.initScript = null;
811833

@@ -832,7 +854,7 @@ class WPTRunner {
832854
* @param {string[]} flags
833855
*/
834856
setFlags(flags) {
835-
this.flags = flags;
857+
this.flags = this.flags.concat(flags);
836858
}
837859

838860
/**
@@ -919,22 +941,33 @@ class WPTRunner {
919941
const absolutePath = spec.getAbsolutePath();
920942
const relativePath = spec.getRelativePath();
921943
const harnessPath = fixtures.path('wpt', 'resources', 'testharness.js');
922-
// Scripts specified with the `// META: script=` header
923-
const scriptsToRun = meta.script?.map((script) => {
944+
// *.worker.js tests are dedicated worker tests by definition. Each
945+
// dedicated worker variant generated from a multi-global (*.any.js)
946+
// test also runs inside an actual Web Worker. Refs:
947+
// https://web-platform-tests.org/writing-tests/testharness.html#multi-global-tests
948+
const isAnyTest = spec.isAnyTest();
949+
const isWebWorkerTest = spec.isWebWorkerTest();
950+
951+
// Scripts specified with the `// META: script=` header. For tests
952+
// that run inside a Web Worker they are imported by the worker
953+
// instead.
954+
const scriptsToRun = isWebWorkerTest ? [] : meta.script?.map((script) => {
924955
const obj = {
925956
filename: this.resource.toRealFilePath(relativePath, script),
926957
code: this.resource.read(relativePath, script),
927958
};
928959
this.scriptsModifier?.(obj);
929960
return obj;
930961
}) ?? [];
931-
// The actual test
932-
const obj = {
933-
code: content,
934-
filename: absolutePath,
935-
};
936-
this.scriptsModifier?.(obj);
937-
scriptsToRun.push(obj);
962+
if (!isWebWorkerTest) {
963+
// The actual test
964+
const obj = {
965+
code: content,
966+
filename: absolutePath,
967+
};
968+
this.scriptsModifier?.(obj);
969+
scriptsToRun.push(obj);
970+
}
938971

939972
run(async () => {
940973
this.inProgress.add(spec);
@@ -950,6 +983,17 @@ class WPTRunner {
950983
filename: harnessPath,
951984
},
952985
scriptsToRun,
986+
// Set when the test runs inside an actual Web Worker.
987+
webWorker: isWebWorkerTest ? {
988+
path: absolutePath,
989+
isAnyTest,
990+
initScript: this.initScript,
991+
variant: spec.variant,
992+
scripts: meta.script?.map(
993+
(script) => this.resource.toRealFilePath(relativePath, script),
994+
) ?? [],
995+
skippedTests: spec.skippedTests,
996+
} : undefined,
953997
needsGc: !!meta.script?.find((script) => script === '/common/gc.js'),
954998
skippedTests: spec.skippedTests,
955999
}, {
@@ -1222,10 +1266,6 @@ class WPTRunner {
12221266
this.skippedSpecCount = 0;
12231267
const arg = process.argv[2];
12241268
for (const spec of this.specs) {
1225-
if (!kRunWorkerGlobals && spec.isWebWorkerTest()) {
1226-
continue;
1227-
}
1228-
12291269
if (arg) {
12301270
if (spec.isSelectedBy(arg)) {
12311271
queue.push(spec);
@@ -1248,13 +1288,11 @@ class WPTRunner {
12481288
}
12491289

12501290
// If the tests are run as `node test/wpt/test-something.js subset.any.js`,
1251-
// only `subset.any.js` (all enabled variants and globals) will be run by
1252-
// the runner.
1291+
// only `subset.any.js` (all variants and globals) will be run by the runner.
12531292
// If the tests are run as `node test/wpt/test-something.js 'subset.any.js?1-10'`,
12541293
// only the `?1-10` variant of `subset.any.js` will be run by the runner.
12551294
// A test path as printed with the results, e.g.
1256-
// `'dir/subset.any.worker.html?1-10'`, runs exactly that one when its
1257-
// global is enabled.
1295+
// `'dir/subset.any.worker.html?1-10'`, runs exactly that one.
12581296
if (arg && queue.length === 0) {
12591297
throw new Error(`${arg} not found!`);
12601298
}

test/common/wpt/webworker.js

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
'use strict';
2+
3+
// Runs a WPT test file inside a Web Worker
4+
// Refs: https://web-platform-tests.org/writing-tests/testharness.html
5+
6+
const { pathToFileURL } = require('url');
7+
const {
8+
runInThisContext,
9+
constants: { USE_MAIN_CONTEXT_DEFAULT_LOADER },
10+
} = require('vm');
11+
12+
globalThis.onmessage = ({ data }) => {
13+
// Let the test install its own handler.
14+
globalThis.onmessage = null;
15+
16+
const { ResourceLoader } = require(data.wptRunner);
17+
const resource = new ResourceLoader(data.wptPath);
18+
19+
globalThis.fetch = function fetch(file) {
20+
return resource.readAsFetch(data.testRelativePath, file);
21+
};
22+
23+
// Pretend the worker was served from the URL the WPT server would have
24+
// used
25+
const fakePath = (data.isAnyTest ?
26+
data.testRelativePath.replace(/\.any\.js$/, '.any.worker.js') :
27+
data.testRelativePath).replace(/\\/g, '/');
28+
const fakeURL = new URL(`/${fakePath}${data.variant}`, 'http://wpt');
29+
// eslint-disable-next-line no-undef
30+
const fakeLocation = { __proto__: WorkerLocation.prototype };
31+
for (const key of ['href', 'origin', 'protocol', 'host', 'hostname',
32+
'port', 'pathname', 'search', 'hash']) {
33+
Object.defineProperty(fakeLocation, key, {
34+
value: fakeURL[key],
35+
enumerable: true,
36+
});
37+
}
38+
Object.defineProperty(fakeLocation, 'toString', {
39+
value: function toString() { return fakeURL.href; },
40+
enumerable: true,
41+
});
42+
Object.defineProperty(globalThis, 'location', {
43+
value: fakeLocation,
44+
enumerable: true,
45+
configurable: true,
46+
});
47+
48+
const testharnessPath =
49+
pathToFileURL(resource.toRealFilePath(data.testRelativePath,
50+
'/resources/testharness.js')).href;
51+
52+
// If there are skip patterns, wrap the test functions to prevent
53+
// execution of matching tests. This must happen after testharness.js is
54+
// loaded but before the test scripts run.
55+
function applySkips() {
56+
if (!data.skippedTests?.length) {
57+
return;
58+
}
59+
function isSkipped(name) {
60+
for (const matcher of data.skippedTests) {
61+
if (typeof matcher === 'string') {
62+
if (name === matcher) return true;
63+
} else if (matcher.test(name)) {
64+
return true;
65+
}
66+
}
67+
return false;
68+
}
69+
for (const fn of ['test', 'async_test', 'promise_test']) {
70+
const original = globalThis[fn];
71+
globalThis[fn] = function(func, name, ...rest) {
72+
if (typeof name === 'string' && isSkipped(name)) {
73+
// eslint-disable-next-line no-undef
74+
postMessage({ type: 'skip', name });
75+
return;
76+
}
77+
return original.call(this, func, name, ...rest);
78+
};
79+
}
80+
}
81+
82+
// Tests fetch scripts and nested worker scripts from the WPT server; map
83+
// those URLs into the fixtures directory.
84+
const realImportScripts = globalThis.importScripts;
85+
globalThis.importScripts = function importScripts(...urls) {
86+
const mapped = urls.map(
87+
(url) => resource.mapServerURL(data.testRelativePath, url));
88+
const result = realImportScripts.apply(this, mapped);
89+
if (mapped.includes(testharnessPath)) {
90+
applySkips();
91+
}
92+
return result;
93+
};
94+
const RealWorker = globalThis.Worker;
95+
globalThis.Worker = class Worker extends RealWorker {
96+
constructor(url, options) {
97+
super(resource.mapServerURL(data.testRelativePath, url), options);
98+
}
99+
};
100+
101+
if (data.isAnyTest) {
102+
// Emulate the generated .any.worker.js wrapper script.
103+
// Refs: https://github.com/web-platform-tests/wpt/blob/master/tools/serve/serve.py
104+
globalThis.GLOBAL = {
105+
isWindow() { return false; },
106+
isWorker() { return true; },
107+
isShadowRealm() { return false; },
108+
};
109+
}
110+
111+
if (data.initScript) {
112+
runInThisContext(data.initScript, {
113+
importModuleDynamically: USE_MAIN_CONTEXT_DEFAULT_LOADER,
114+
});
115+
}
116+
117+
if (data.isAnyTest) {
118+
globalThis.importScripts('/resources/testharness.js');
119+
for (const script of data.scripts) {
120+
globalThis.importScripts(pathToFileURL(script).href);
121+
}
122+
globalThis.importScripts(pathToFileURL(data.path).href);
123+
// eslint-disable-next-line no-undef
124+
done();
125+
} else {
126+
// *.worker.js tests import testharness.js and call done() themselves.
127+
globalThis.importScripts(pathToFileURL(data.path).href);
128+
}
129+
};

test/common/wpt/worker.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use strict';
22

3+
const path = require('path');
4+
const { pathToFileURL } = require('url');
35
const {
46
runInNewContext,
57
runInThisContext,
@@ -38,6 +40,15 @@ function run(workerData) {
3840
const { ResourceLoader } = require(workerData.wptRunner);
3941
const resource = new ResourceLoader(workerData.wptPath);
4042

43+
// Tests create workers with URLs the WPT server would have served them
44+
// from; map them into the fixtures directory.
45+
const RealWorker = globalThis.Worker;
46+
globalThis.Worker = class Worker extends RealWorker {
47+
constructor(url, options) {
48+
super(resource.mapServerURL(workerData.testRelativePath, url), options);
49+
}
50+
};
51+
4152
if (workerData.needsGc) {
4253
// See https://github.com/nodejs/node/issues/16595#issuecomment-340288680
4354
setFlagsFromString('--expose-gc');
@@ -131,4 +142,44 @@ function run(workerData) {
131142
importModuleDynamically: USE_MAIN_CONTEXT_DEFAULT_LOADER,
132143
});
133144
}
145+
146+
if (workerData.webWorker) {
147+
const worker = new RealWorker(
148+
pathToFileURL(path.join(__dirname, 'webworker.js')));
149+
worker.postMessage({
150+
wptRunner: workerData.wptRunner,
151+
wptPath: workerData.wptPath,
152+
testRelativePath: workerData.testRelativePath,
153+
...workerData.webWorker,
154+
});
155+
156+
let completed = false;
157+
worker.addEventListener('message', (event) => {
158+
if (event.data?.type === 'complete') {
159+
completed = true;
160+
}
161+
// Skipped subtests never register with the testharness inside the
162+
// worker; the runner is notified about them directly.
163+
if (event.data?.type === 'skip') {
164+
send({ type: 'skip', name: event.data.name });
165+
}
166+
});
167+
worker.addEventListener('error', (event) => {
168+
if (completed) {
169+
return;
170+
}
171+
clearTimeout(timeout);
172+
send({
173+
type: 'completion',
174+
status: {
175+
status: 1,
176+
message: event.message,
177+
stack: event.error?.stack,
178+
},
179+
});
180+
});
181+
182+
// eslint-disable-next-line no-undef
183+
fetch_tests_from_worker(worker);
184+
}
134185
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
'use strict';
2+
3+
// Throw only once a result has been reported, so the harness has to surface
4+
// the error rather than treat the test file as already done.
5+
add_result_callback(() => {
6+
setTimeout(() => {
7+
throw new Error('probe error after first result');
8+
}, 0);
9+
});
10+
11+
test(() => {}, 'reported before error');
12+
async_test(() => {}, 'waiting for error');

test/parallel/test-common-wpt-backends.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const { backends } = require('../common/wpt');
1414

1515
const harnessPath = fixtures.path('wpt', 'resources', 'testharness.js');
1616
const specPath = fixtures.path('wpt-backends-spec.js');
17-
const execArgv = [];
17+
const execArgv = ['--experimental-web-worker'];
1818

1919
function payload(throws) {
2020
return {

0 commit comments

Comments
 (0)