From 0ded159ee158f259c007c7afd340db4b593d6d17 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sat, 26 Sep 2026 13:22:26 +0200 Subject: [PATCH] test: honor allowed WPT promise rejections Honor allow_uncaught_exception for unhandled promise rejections in both WPT worker paths. Apply setup settings before nested callbacks, preserve settings after results start, and apply promise_setup settings when its callback runs. Keep rejection failures in single_test mode. Cover both runner backends and Web Workers, and remove the flaky expectation for the Web Locks test that deliberately rejects. Refs: https://github.com/nodejs/node/issues/59142 Signed-off-by: Filip Skokan Assisted-by: Claude, Codex PR-URL: https://github.com/nodejs/node/pull/66322 Reviewed-By: Daeyeon Jeong --- test/common/wpt/rejections.js | 65 ++++++ test/common/wpt/webworker.js | 2 + test/common/wpt/worker.js | 2 + .../test-common-wpt-allowed-rejections.js | 189 ++++++++++++++++++ test/wpt/status/web-locks.json | 8 - 5 files changed, 258 insertions(+), 8 deletions(-) create mode 100644 test/common/wpt/rejections.js create mode 100644 test/parallel/test-common-wpt-allowed-rejections.js diff --git a/test/common/wpt/rejections.js b/test/common/wpt/rejections.js new file mode 100644 index 00000000000..f0cda7a4c60 --- /dev/null +++ b/test/common/wpt/rejections.js @@ -0,0 +1,65 @@ +'use strict'; + +// Node.js does not dispatch the global unhandledrejection event used by +// testharness.js. Ignore rejections only when the harness settings allow them. +module.exports = function honorAllowedRejections() { + const { setup, promise_setup, add_result_callback, add_completion_callback } = globalThis; + let configurable = true; + let allowed = false; + let singleTest = false; + const ignoreRejection = () => {}; + + // The harness ignores configuration changes once results have started. + add_result_callback(() => { configurable = false; }); + add_completion_callback(() => { configurable = false; }); + + function apply(properties) { + if (properties) { + if (Object.prototype.propertyIsEnumerable.call(properties, 'allow_uncaught_exception')) { + allowed = properties.allow_uncaught_exception; + } + if (Object.prototype.propertyIsEnumerable.call(properties, 'single_test') && properties.single_test) { + singleTest = true; + } + } + process.removeListener('unhandledRejection', ignoreRejection); + // In single_test mode the harness still fails the implicit test when + // uncaught errors are allowed. Retain Node's failure path for that mode. + if (allowed && !singleTest) { + process.on('unhandledRejection', ignoreRejection); + } + } + + globalThis.setup = function(funcOrProperties, maybeProperties) { + let func; + let properties; + if (arguments.length === 2) { + func = funcOrProperties; + properties = maybeProperties; + } else if (funcOrProperties instanceof Function) { + func = funcOrProperties; + properties = {}; + } else { + properties = funcOrProperties; + } + + // This callback runs only when setup was accepted, before any nested + // setup calls made by the test's callback can override its settings. + return setup.call(this, () => { + apply(properties); + if (func) func(); + }, properties); + }; + + globalThis.promise_setup = function(func, properties) { + if (typeof func !== 'function') { + return promise_setup.call(this, func, properties); + } + return promise_setup.call(this, () => { + // promise_setup applies settings when its queued callback executes. + // Unlike setup, it still invokes that callback after results start. + if (configurable) apply(properties); + return func(); + }, properties); + }; +}; diff --git a/test/common/wpt/webworker.js b/test/common/wpt/webworker.js index e9a8ebe53d7..8a335c80869 100644 --- a/test/common/wpt/webworker.js +++ b/test/common/wpt/webworker.js @@ -4,6 +4,7 @@ // Refs: https://web-platform-tests.org/writing-tests/testharness.html const { pathToFileURL } = require('url'); +const honorAllowedRejections = require('./rejections'); const { runInThisContext, constants: { USE_MAIN_CONTEXT_DEFAULT_LOADER }, @@ -88,6 +89,7 @@ globalThis.onmessage = ({ data }) => { const result = realImportScripts.apply(this, mapped); if (mapped.includes(testharnessPath)) { applySkips(); + honorAllowedRejections(); } return result; }; diff --git a/test/common/wpt/worker.js b/test/common/wpt/worker.js index ca4f4706f89..7d84f2ac1a3 100644 --- a/test/common/wpt/worker.js +++ b/test/common/wpt/worker.js @@ -9,6 +9,7 @@ const { } = require('vm'); const { setFlagsFromString } = require('v8'); const { inspect } = require('util'); +const honorAllowedRejections = require('./rejections'); const { isMainThread, parentPort, @@ -79,6 +80,7 @@ function run(workerData) { filename: workerData.harness.filename, importModuleDynamically: USE_MAIN_CONTEXT_DEFAULT_LOADER, }); + honorAllowedRejections(); // If there are skip patterns, wrap test functions to prevent execution of // matching tests. This must happen after testharness.js is loaded but before diff --git a/test/parallel/test-common-wpt-allowed-rejections.js b/test/parallel/test-common-wpt-allowed-rejections.js new file mode 100644 index 00000000000..e8f8ed113f3 --- /dev/null +++ b/test/parallel/test-common-wpt-allowed-rejections.js @@ -0,0 +1,189 @@ +'use strict'; + +const common = require('../common'); +const fixtures = require('../common/fixtures'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { backends } = require('../common/wpt'); + +const harnessPath = fixtures.path('wpt', 'resources', 'testharness.js'); +const harness = { + code: fs.readFileSync(harnessPath, 'utf8'), + filename: harnessPath, +}; + +async function check(backend, webWorker, { + name, + code, + allowed, + passes = 1, + singleTest = false, + message = /uncaught rejection probe/, +}) { + const label = `${backend}, ${webWorker}, ${name}`; + const script = { + filename: fixtures.path('wpt-rejection-probe.js'), + // Use a direct worker script for single_test: .any.js wrappers call + // done() automatically, completing the implicit test before its timers. + code: `${webWorker && singleTest ? 'importScripts("/resources/testharness.js");' : ''}\n${code}`, + }; + const workerData = { + testRelativePath: 'rejection-probe.any.js', + wptRunner: path.join(__dirname, '../common/wpt.js'), + wptPath: 'compression', + harness, + scriptsToRun: webWorker ? [] : [script], + webWorker: webWorker ? { + path: script.filename, + modifiedScript: script, + isAnyTest: !singleTest, + variant: '', + scripts: [], + } : undefined, + }; + + let result; + const statuses = []; + const handle = backends[backend](['--experimental-web-worker'], workerData, { + message(message) { + if (message.type === 'result') { + statuses.push(message.result.status); + } else if (message.type === 'completion') { + // The process backend can complete before its uncaught-error handler + // exits. Preserve any failure it already reported. + result ||= message.status; + handle.kill(); + } else { + assert.fail(`Unexpected message type: ${message.type}`); + } + }, + failure(error) { + if (result) return false; + result = { status: 1, message: error.message }; + return true; + }, + }); + const timeout = setTimeout(() => { + handle.kill(); + assert.fail(`WPT rejection probe did not finish: ${label}`); + }, common.platformTimeout(10_000)); + + try { + await handle.finished; + } finally { + clearTimeout(timeout); + } + + assert(result, label); + assert.strictEqual(result.status, allowed ? 0 : 1, label); + if (allowed) { + assert.deepStrictEqual(statuses, Array(passes).fill(0), label); + } else { + assert.match(result.message, message, label); + } +} + +(async () => { + for (const backend of ['thread', 'process']) { + for (const webWorker of [false, true]) { + for (const [setup, allowed] of [ + ['', false], + ['setup({ allow_uncaught_exception: false });', false], + ['setup({ allow_uncaught_exception: true });', true], + ['setup(() => {}, { allow_uncaught_exception: true });', true], + ['setup(null, { allow_uncaught_exception: true });', true], + ['setup({ allow_uncaught_exception: true }); setup({});', true], + ['setup({ allow_uncaught_exception: true }); setup({ allow_uncaught_exception: false });', false], + ['setup(Object.defineProperty({}, "allow_uncaught_exception", { value: true }));', false], + ]) { + await check(backend, webWorker, { + name: setup || 'default settings', + code: `${setup} + const t = async_test('rejection probe'); + Promise.reject(new Error('uncaught rejection probe')); + setTimeout(() => t.done(), 0);`, + allowed, + }); + } + + for (const allowed of [false, true]) { + await check(backend, webWorker, { + name: `nested setup allows rejection: ${allowed}`, + code: `setup(() => setup({ allow_uncaught_exception: ${allowed} }), + { allow_uncaught_exception: ${!allowed} }); + const t = async_test('rejection probe'); + Promise.reject(new Error('uncaught rejection probe')); + setTimeout(() => t.done(), 0);`, + allowed, + }); + + await check(backend, webWorker, { + name: `late setup preserves allowance: ${allowed}`, + code: `setup({ allow_uncaught_exception: ${allowed} }); + const t = async_test('rejection probe'); + test(() => {}, 'first result'); + setup(() => assert_unreached('late setup callback'), + { allow_uncaught_exception: ${!allowed} }); + Promise.reject(new Error('uncaught rejection probe')); + setTimeout(() => t.done(), 0);`, + allowed, + passes: 2, + }); + + await check(backend, webWorker, { + name: `promise_setup applies deferred allowance: ${allowed}`, + code: `promise_setup(async () => {}, { allow_uncaught_exception: ${allowed} }); + setup({ allow_uncaught_exception: ${!allowed} }); + promise_test(async () => { + Promise.reject(new Error('uncaught rejection probe')); + await new Promise(resolve => setTimeout(resolve, 0)); + }, 'rejection probe');`, + allowed, + }); + + await check(backend, webWorker, { + name: `late promise_setup preserves allowance: ${allowed}`, + code: `setup({ allow_uncaught_exception: ${allowed} }); + const t = async_test('rejection probe'); + test(() => {}, 'first result'); + promise_setup(async () => { + Promise.reject(new Error('uncaught rejection probe')); + setTimeout(() => t.done(), 0); + }, { allow_uncaught_exception: ${!allowed} });`, + allowed, + passes: 2, + }); + + await check(backend, webWorker, { + name: `single_test still fails with allowance: ${allowed}`, + code: `setup({ single_test: true, allow_uncaught_exception: ${allowed} }); + setup({ single_test: false }); + Promise.reject(new Error('uncaught rejection probe')); + setTimeout(done, 0);`, + allowed: false, + singleTest: true, + }); + } + + for (const setup of [ + 'setup(() => { throw new Error("setup failure"); }, { allow_uncaught_exception: true });', + 'promise_setup(async () => { throw new Error("setup failure"); }, { allow_uncaught_exception: true });', + ]) { + await check(backend, webWorker, { + name: setup, + code: `${setup}\npromise_test(async () => {}, 'setup must succeed first');`, + allowed: false, + message: /setup failure/, + }); + } + + await check(backend, webWorker, { + name: 'unexpected rejection fails even with a permanently pending test', + code: `async_test('pending forever'); + Promise.reject(new Error('uncaught rejection probe'));`, + allowed: false, + }); + } + } +})().then(common.mustCall()); diff --git a/test/wpt/status/web-locks.json b/test/wpt/status/web-locks.json index 97a0a7900d6..f7774a38fa7 100644 --- a/test/wpt/status/web-locks.json +++ b/test/wpt/status/web-locks.json @@ -1,12 +1,4 @@ { - "held.https.any.js": { - "fail": { - "note": "Flaky on ppc, linux x64 and s390x #59142", - "flaky": [ - "Error: this uncaught rejection is expected" - ] - } - }, "idlharness.https.any.js": { "fail": { "expected": [