Skip to content

Commit e41d8f3

Browse files
committed
worker: add wpt tests for Web Workers
1 parent f014929 commit e41d8f3

1,327 files changed

Lines changed: 53689 additions & 25 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

doc/api/globals.md

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,8 +1316,98 @@ with the [`--no-experimental-websocket`][] CLI flag.
13161316
added: REPLACEME
13171317
-->
13181318

1319-
A browser-compatible implementation of Web Workers. Enable this API
1320-
with the [`--experimental-web-worker`][] CLI flag.
1319+
> Stability: 1 - Experimental. Enable this API with the
1320+
> [`--experimental-web-worker`][] CLI flag.
1321+
1322+
A browser-compatible implementation of Web Workers of the [HTML Standard][],
1323+
implemented on top of [`node:worker_threads`][]. Threads created with it
1324+
are given the {DedicatedWorkerGlobalScope} API (`self`,
1325+
`name`, `location`, `navigator`, `postMessage()`, `close()`, and
1326+
`importScripts()`), in addition to the usual Node.js globals, such as `process`.
1327+
1328+
```js
1329+
// worker.js
1330+
addEventListener('message', (event) => {
1331+
postMessage(`${event.data} from ${name}!`);
1332+
});
1333+
```
1334+
1335+
```js
1336+
// main.js
1337+
const worker = new Worker('./worker.js', { name: 'greeter' });
1338+
1339+
worker.addEventListener('message', (event) => {
1340+
console.log(event.data); // Prints: Hello from greeter!
1341+
worker.terminate();
1342+
});
1343+
1344+
worker.postMessage('Hello');
1345+
```
1346+
1347+
Because their lifetime and sharing model depend on origins and
1348+
browsing contexts, Node.js does not currently implement `SharedWorker`.
1349+
1350+
### Loading worker scripts
1351+
1352+
Worker scripts are read synchronously from the local file system or from
1353+
memory rather than fetched over the network, which changes which URLs are
1354+
accepted and how failures are reported:
1355+
1356+
* `new Worker()` and `importScripts()` accept only `file:`, `data:`, and
1357+
`blob:` URLs. Any other scheme makes `new Worker()` throw a
1358+
`NotSupportedError` and `importScripts()` throw a `NetworkError`.
1359+
* A script that cannot be read makes `importScripts()` throw a `NetworkError`;
1360+
for `new Worker()` it fires an `error` event at the `Worker` object.
1361+
* Redirects, the `nosniff` check, and HTTP MIME type validation do not apply.
1362+
MIME types are validated only for `data:` and `blob:` URLs. The
1363+
`credentials` option is validated for API compatibility but has no effect,
1364+
since no network request is made.
1365+
* On the main thread, relative script URLs are resolved against the current
1366+
working directory, because there is no document base URL. Within a worker
1367+
they are resolved against the worker's own URL (as is done in the spec).
1368+
* For `blob:` URLs, the script must be held in memory, so blobs backed by a file,
1369+
such as those returned by [`fs.openAsBlob()`][], cannot be used.
1370+
1371+
### Differences from the HTML Standard
1372+
1373+
Besides script loading, mentioned above:
1374+
1375+
* Node.js has no origin model, so same-origin and cross-origin distinctions do
1376+
not exist and `location.origin` is `'null'` for every supported scheme.
1377+
* `close()` terminates the worker immediately instead of following the
1378+
specification's "closing flag" algorithm, so code remaining in the current
1379+
task after `close()` is not executed.
1380+
* The worker global is the normal Node.js global object with
1381+
`DedicatedWorkerGlobalScope` inserted into its prototype chain, rather than
1382+
a fresh global created from the interface. Node.js globals such as
1383+
`process`, `Buffer`, and `require()` remain available to worker scripts.
1384+
* `ErrorEvent`s dispatched at `Worker` instances include `message` and
1385+
`error`, but `filename`, `lineno`, and `colno` are always `''`, `0`, and
1386+
`0`. An uncaught exception terminates the worker thread, and an unhandled
1387+
`error` event is not propagated further: it neither reaches the parent's
1388+
global scope nor affects the exit code of the process.
1389+
* The following {WorkerGlobalScope} events are never dispatched, although
1390+
their handler properties exist: `languagechange`, `online`, and `offline`,
1391+
since these concepts do not exist in Node.js; `rejectionhandled` and
1392+
`unhandledrejection`, since Node.js exposes the equivalent does not
1393+
implement the `PromiseRejectionEvent` interface or the per-rejection
1394+
`preventDefault()` behavior required by the HTML Standard.
1395+
1396+
### Web Workers and `node:worker_threads`
1397+
1398+
Every Web Worker is backed by a [`node:worker_threads`][] {Worker}, so the
1399+
two APIs share their threading, structured clone, and transfer semantics.
1400+
Inside a worker, \[`worker_threads.parentPort`]\[] is the port behind
1401+
`self.postMessage()` and the worker's `message` events, `isMainThread` is
1402+
`false`, and `workerData` is `undefined`.
1403+
1404+
As a rule of thumb, use [`node:worker_threads`][] directly when a program
1405+
needs `workerData`, a custom `env` or `execArgv`, resource limits, stdio
1406+
redirection, the `'online'` and `'exit'` events, or `worker.threadId`;
1407+
`Worker` accepts only the `name`, `type`, and `credentials` options and,
1408+
per the specification, its `terminate()` returns `undefined`, rather than
1409+
a promise. Threads started through [`node:worker_threads`][] are ordinary
1410+
Node.js threads and do not get the worker global scope APIs.
13211411

13221412
## Class: `WritableStream`
13231413

@@ -1364,6 +1454,7 @@ A browser-compatible implementation of [`WritableStreamDefaultWriter`][].
13641454
[CommonJS module]: modules.md
13651455
[CommonJS modules]: modules.md
13661456
[ECMAScript module]: esm.md
1457+
[HTML Standard]: https://html.spec.whatwg.org/multipage/workers.html
13671458
[Navigator API]: https://html.spec.whatwg.org/multipage/system-state.html#the-navigator-object
13681459
[RFC 5646]: https://www.rfc-editor.org/rfc/rfc5646.txt
13691460
[Web Crypto API]: webcrypto.md
@@ -1420,9 +1511,11 @@ A browser-compatible implementation of [`WritableStreamDefaultWriter`][].
14201511
[`console`]: console.md
14211512
[`exports`]: modules.md#exports
14221513
[`fetch()`]: https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch
1514+
[`fs.openAsBlob()`]: fs.md#fsopenasblobpath-options
14231515
[`globalThis`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/globalThis
14241516
[`localStorage`]: https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage
14251517
[`module`]: modules.md#module
1518+
[`node:worker_threads`]: worker_threads.md
14261519
[`perf_hooks.performance`]: perf_hooks.md#perf_hooksperformance
14271520
[`process.nextTick()`]: process.md#processnexttickcallback-args
14281521
[`process` object]: process.md#process

test/common/wpt.js

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ 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

1314
const workerPath = path.join(__dirname, 'wpt/worker.js');
@@ -194,6 +195,24 @@ class ResourceLoader {
194195
fixtures.path('wpt', base, url);
195196
}
196197

198+
/**
199+
* Map a URL that a test would have fetched from the WPT server (an
200+
* absolute path, or a path relative to the test file) to a file: URL
201+
* into the fixtures directory. URLs that already have a scheme (data:,
202+
* blob:, http:, ...) are returned unchanged.
203+
* @param {string} from the path of the file loading this resource,
204+
* relative to the WPT folder.
205+
* @param {string|URL} url the url of the resource being loaded.
206+
* @returns {string}
207+
*/
208+
mapServerURL(from, url) {
209+
url = `${url}`;
210+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:|^\/\//.test(url)) {
211+
return url;
212+
}
213+
return pathToFileURL(this.toRealFilePath(from, url)).href;
214+
}
215+
197216
/**
198217
* Load a resource in test/fixtures/wpt specified with a URL
199218
* @param {string} from the path of the file loading this resource,
@@ -389,13 +408,15 @@ class WPTTestSpec {
389408
* @returns {{ script?: string[]; variant?: string[]; [key: string]: string }} parsed META tags of a spec file
390409
*/
391410
getMeta() {
392-
const matches = this.getContent().match(/\/\/ META: .+/g);
411+
// Like upstream, tolerate missing whitespace around "META:".
412+
// Refs: https://github.com/web-platform-tests/wpt/blob/master/tools/manifest/sourcefile.py
413+
const matches = this.getContent().match(/\/\/\s*META:\s*.+/g);
393414
if (!matches) {
394415
return {};
395416
}
396417
const result = {};
397418
for (const match of matches) {
398-
const parts = match.match(/\/\/ META: ([^=]+?)=(.+)/);
419+
const parts = match.match(/\/\/\s*META:\s*([^=]+?)=(.+)/);
399420
const key = parts[1];
400421
const value = parts[2];
401422
if (key === 'script' || key === 'variant') {
@@ -570,7 +591,11 @@ class WPTRunner {
570591
this.resource = new ResourceLoader(path);
571592
this.concurrency = concurrency;
572593

573-
this.flags = [];
594+
// Since we need to prepare the Web Worker APIs
595+
// in the harness that runs on all WPT workers,
596+
// we enable the API globally. This has no practical
597+
// effect on the non-web-worker tests, however.
598+
this.flags = ['--experimental-web-worker'];
574599
this.globalThisInitScripts = [];
575600
this.initScript = null;
576601

@@ -596,7 +621,7 @@ class WPTRunner {
596621
* @param {string[]} flags
597622
*/
598623
setFlags(flags) {
599-
this.flags = flags;
624+
this.flags = this.flags.concat(flags);
600625
}
601626

602627
/**
@@ -679,23 +704,41 @@ class WPTRunner {
679704
const absolutePath = spec.getAbsolutePath();
680705
const relativePath = spec.getRelativePath();
681706
const harnessPath = fixtures.path('wpt', 'resources', 'testharness.js');
682-
683-
// Scripts specified with the `// META: script=` header
684-
const scriptsToRun = meta.script?.map((script) => {
707+
// *.worker.js tests are dedicated worker tests by definition.
708+
// Multi-global (*.any.js) tests whose global scopes include a
709+
// dedicated worker but not a window also run inside an actual Web
710+
// Worker, like the .any.worker.html variant generated by the WPT
711+
// server does. Refs:
712+
// https://web-platform-tests.org/writing-tests/testharness.html#multi-global-tests
713+
const isAnyTest = /\.any\.js$/.test(spec.filename);
714+
const globalScopes = isAnyTest ?
715+
(meta.global?.split(',').map((s) => s.trim()) ??
716+
['window', 'dedicatedworker']) : [];
717+
const isWebWorkerTest = /\.worker\.js$/.test(spec.filename) ||
718+
(isAnyTest && !globalScopes.includes('window') &&
719+
(globalScopes.includes('worker') ||
720+
globalScopes.includes('dedicatedworker')));
721+
722+
// Scripts specified with the `// META: script=` header. For tests
723+
// that run inside a Web Worker they are imported by the worker
724+
// instead.
725+
const scriptsToRun = isWebWorkerTest ? [] : meta.script?.map((script) => {
685726
const obj = {
686727
filename: this.resource.toRealFilePath(relativePath, script),
687728
code: this.resource.read(relativePath, script),
688729
};
689730
this.scriptsModifier?.(obj);
690731
return obj;
691732
}) ?? [];
692-
// The actual test
693-
const obj = {
694-
code: content,
695-
filename: absolutePath,
696-
};
697-
this.scriptsModifier?.(obj);
698-
scriptsToRun.push(obj);
733+
if (!isWebWorkerTest) {
734+
// The actual test
735+
const obj = {
736+
code: content,
737+
filename: absolutePath,
738+
};
739+
this.scriptsModifier?.(obj);
740+
scriptsToRun.push(obj);
741+
}
699742

700743
run(async () => {
701744
const worker = new Worker(workerPath, {
@@ -710,6 +753,15 @@ class WPTRunner {
710753
filename: harnessPath,
711754
},
712755
scriptsToRun,
756+
// Set when the test runs inside an actual Web Worker.
757+
webWorker: isWebWorkerTest ? {
758+
path: absolutePath,
759+
isAnyTest,
760+
scripts: meta.script?.map(
761+
(script) => this.resource.toRealFilePath(relativePath, script),
762+
) ?? [],
763+
skippedTests: spec.skippedTests,
764+
} : undefined,
713765
needsGc: !!meta.script?.find((script) => script === '/common/gc.js'),
714766
skippedTests: spec.skippedTests,
715767
},

test/common/wpt/webworker.js

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

0 commit comments

Comments
 (0)