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 <panva.ip@gmail.com>
Assisted-by: Claude, Codex
PR-URL: https://github.com/nodejs/node/pull/66322
Reviewed-By: Daeyeon Jeong <daeyeon.dev@gmail.com>
This commit is contained in:
Filip Skokan authored and Node.js GitHub Bot committed 2026-10-03 16:24:37 +00:00
1 parent 3f2450f630
commit 0ded159ee1
5 files changed
+258 -8

No files matched your search

+65
View File
@@ -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);
};
};
+2
View File
@@ -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;
};
+2
View File
@@ -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
@@ -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());
-8
View File
@@ -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": [