diff --git a/test/common/wpt.js b/test/common/wpt.js index 0bf56e358d9..82b0777de5d 100644 --- a/test/common/wpt.js +++ b/test/common/wpt.js @@ -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!`); } diff --git a/test/common/wpt/webworker.js b/test/common/wpt/webworker.js new file mode 100644 index 00000000000..1f94c82c02a --- /dev/null +++ b/test/common/wpt/webworker.js @@ -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); + } +}; diff --git a/test/common/wpt/worker.js b/test/common/wpt/worker.js index 1f5c3ebc30c..ca4f4706f89 100644 --- a/test/common/wpt/worker.js +++ b/test/common/wpt/worker.js @@ -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); + } } diff --git a/test/fixtures/web-worker/wpt-error-after-result.js b/test/fixtures/web-worker/wpt-error-after-result.js new file mode 100644 index 00000000000..4dfb835a92f --- /dev/null +++ b/test/fixtures/web-worker/wpt-error-after-result.js @@ -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'); diff --git a/test/parallel/test-common-wpt-backends.js b/test/parallel/test-common-wpt-backends.js index c8c15d84a42..f201adde64e 100644 --- a/test/parallel/test-common-wpt-backends.js +++ b/test/parallel/test-common-wpt-backends.js @@ -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) { diff --git a/test/parallel/test-common-wpt-webworker-error.js b/test/parallel/test-common-wpt-webworker-error.js new file mode 100644 index 00000000000..76738303cdf --- /dev/null +++ b/test/parallel/test-common-wpt-webworker-error.js @@ -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)); +} diff --git a/test/wpt/status/FileAPI/blob.cjs b/test/wpt/status/FileAPI/blob.cjs index bf40df1b842..6f8599dc8b4 100644 --- a/test/wpt/status/FileAPI/blob.cjs +++ b/test/wpt/status/FileAPI/blob.cjs @@ -33,7 +33,4 @@ module.exports = { ], }, }, - 'Blob-in-worker.worker.js': { - skip: 'Depends on Web Workers API', - }, }; diff --git a/test/wpt/status/dom/events.json b/test/wpt/status/dom/events.json index 8109e2372ad..f621431c9fc 100644 --- a/test/wpt/status/dom/events.json +++ b/test/wpt/status/dom/events.json @@ -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" }, diff --git a/test/wpt/status/hr-time.cjs b/test/wpt/status/hr-time.cjs index c426eeeb8e9..35b82c87a1e 100644 --- a/test/wpt/status/hr-time.cjs +++ b/test/wpt/status/hr-time.cjs @@ -19,7 +19,7 @@ module.exports = { }, } : {}), - 'idlharness.any.js': { + 'idlharness.any.html': { fail: { expected: [ 'Window interface: attribute performance', diff --git a/test/wpt/status/url.cjs b/test/wpt/status/url.cjs index d7cd8a8666b..1ee0ce90071 100644 --- a/test/wpt/status/url.cjs +++ b/test/wpt/status/url.cjs @@ -1,7 +1,7 @@ 'use strict'; module.exports = { - 'historical.any.js': { + 'historical.any.html': { 'fail': { 'expected': [ 'searchParams on location object', diff --git a/test/wpt/status/webidl.json b/test/wpt/status/webidl.json index 26a6e44f484..73a89a37381 100644 --- a/test/wpt/status/webidl.json +++ b/test/wpt/status/webidl.json @@ -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"