diff --git a/implementors/node/child_process.d.ts b/implementors/node/child_process.d.ts index d63b290..73bbc68 100644 --- a/implementors/node/child_process.d.ts +++ b/implementors/node/child_process.d.ts @@ -1,6 +1,7 @@ export interface SpawnTestOptions { cwd?: string; stdout?: 'pipe' | 'inherit'; + worker?: boolean; } export interface SpawnTestResult { diff --git a/implementors/node/child_process.js b/implementors/node/child_process.js index d43e85b..641cc91 100644 --- a/implementors/node/child_process.js +++ b/implementors/node/child_process.js @@ -7,6 +7,10 @@ import { pathToFileURL } from 'node:url'; // into one --import keeps the child's command line short. const HARNESS_MODULE_PATH = path.join(import.meta.dirname, 'harness.js'); +// Entry point used for `worker: true`: it takes the test file as an argument +// and runs it on a worker thread of the child rather than on its main thread. +const WORKER_ENTRY_PATH = path.join(import.meta.dirname, 'worker-entry.js'); + // Exit codes that signify the runtime aborted (rather than exiting cleanly with // a non-zero status). On POSIX an abort surfaces as a fatal signal; on Windows // as one of a small set of exit codes. Mirrors Node.js's @@ -22,12 +26,19 @@ const ABORT_EXIT_CODES = [132, 133, 134, 139, 0xc0000409, 0xc000001d]; * * @param {string} filePath - Path to the JS/MJS file to execute. Resolved * against `options.cwd` if relative. - * @param {{ cwd?: string, stdout?: 'pipe' | 'inherit' }} [options] + * @param {{ cwd?: string, stdout?: 'pipe' | 'inherit', worker?: boolean }} [options] * - `cwd`: working directory for the child; defaults to `process.cwd()`. * - `stdout`: `'pipe'` (default) captures the child's stdout into the result; * `'inherit'` streams it straight to the terminal as the child runs (so the * output of a slow or hanging test is visible immediately) and leaves the * returned `stdout` empty. stderr is always captured for diagnostics. + * - `worker`: run the file in a worker thread of the child instead of on its + * main thread, giving it a secondary Node-API environment. The result still + * describes the host process, which is the point: native output from the + * worker's environment (a printf from a finalizer or an instance-data delete + * hook) goes to the process's stdout, not to the worker's JS-level stream. + * Gate such a test in the parent file: `skipTest()` inside a worker ends + * that thread with code 0, which the caller cannot tell from a pass. * @returns {Promise<{ status: number | null, aborted: boolean, stdout: string, stderr: string }>} */ export const spawnTest = (filePath, options = {}) => { @@ -35,11 +46,13 @@ export const spawnTest = (filePath, options = {}) => { // without it. // pathToFileURL handles Windows drive letters and backslashes; a bare // 'file://' + path is malformed there (e.g. file://C:\...). + // In worker mode the child runs worker-entry.js, which takes the test file as + // its argument and starts it on a worker thread. const args = [ '--expose-gc', '--import', pathToFileURL(HARNESS_MODULE_PATH).href, - filePath, + ...(options.worker ? [WORKER_ENTRY_PATH, filePath] : [filePath]), ]; // spawn (not spawnSync) so a hung child doesn't block the event loop and the diff --git a/implementors/node/features.js b/implementors/node/features.js index fec2137..6bd388f 100644 --- a/implementors/node/features.js +++ b/implementors/node/features.js @@ -24,6 +24,13 @@ globalThis.runtimeFeatures = { // and need not provide a spawnTest implementation. spawn: true, + // Node.js can run a test file in a worker thread, giving it a secondary + // Node-API environment, so spawnTest accepts `{ worker: true }`. Declared + // separately from `spawn` because the two capabilities are independent: a + // browser has workers but no subprocesses. Runtimes with neither set both to + // false. + worker: true, + // napi_create_dataview accepts a SharedArrayBuffer-backed buffer only since // Node.js v24.13.1 and v25.4.0 (nodejs/node#60473). It was not backported to // v20.x or v22.x, where such calls fail with "invalid argument". diff --git a/implementors/node/worker-entry.js b/implementors/node/worker-entry.js new file mode 100644 index 0000000..9fd41a9 --- /dev/null +++ b/implementors/node/worker-entry.js @@ -0,0 +1,18 @@ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { Worker } from 'node:worker_threads'; + +// Entry point for spawnTest(file, { worker: true }): boots the test file in a +// worker thread, giving it a secondary Node-API environment inside this process. +// +// The worker inherits this process's execArgv, so the harness --import applies +// there too and the test file sees the same globals as on the main thread. +// +// No 'error' handler is installed on purpose: an unhandled worker error is +// re-thrown on this thread, so a failing test file still exits the process +// non-zero with its stack on stderr, exactly as it would on the main thread. +const [filePath] = process.argv.slice(2); + +// Worker resolves a relative specifier against the cwd, but a bare filename +// (no leading './') would read as a package specifier - so make it absolute. +new Worker(pathToFileURL(path.resolve(filePath))); diff --git a/tests/harness/spawn-test-worker.js b/tests/harness/spawn-test-worker.js new file mode 100644 index 0000000..06c1128 --- /dev/null +++ b/tests/harness/spawn-test-worker.js @@ -0,0 +1,50 @@ +'use strict'; + +// Running a test file in a secondary environment (a worker) is an optional +// harness capability, declared separately from `spawn` because a runtime can +// have one without the other: a browser has workers but no subprocesses. +// +// The Node harness delivers it as an option on spawnTest rather than as its own +// global, because observing a secondary environment's *native* output requires +// capturing the stdout of the process hosting it - a worker's own piped stdout +// carries JS-level writes only, so a printf from an addon bypasses it. +assert.strictEqual( + typeof runtimeFeatures.worker, + 'boolean', + 'Expected runtimeFeatures.worker to be a boolean', +); +if (!runtimeFeatures.spawn || !runtimeFeatures.worker) { + skipTest(); +} + +// That the file ran in a secondary environment rather than the main one is not +// observable from portable ECMAScript; proving that needs per-environment +// Node-API state (see the test_instance_data suite). What is pinned here is the +// plumbing: the file runs, harness globals reach it, and a failure inside the +// worker still surfaces as a non-zero status with its stderr intact instead of +// being swallowed by the host thread. +{ + const result = await spawnTest('spawn-test-ok-child.mjs', { worker: true }); + assert.strictEqual(result.status, 0, `ok child exited with status ${result.status}; stderr:\n${result.stderr}`); + assert.strictEqual(result.aborted, false); + assert.strictEqual(result.stderr, ''); +} + +{ + const result = await spawnTest('spawn-test-fail-child.mjs', { worker: true }); + assert.notStrictEqual(result.status, 0, 'fail child should exit non-zero'); + assert.strictEqual(result.aborted, false); + if (!result.stderr.includes('spawn-test-fail-marker')) { + throw new Error(`Expected stderr to include the failure marker, got:\n${result.stderr}`); + } +} + +// cwd still applies in worker mode: the test file is resolved against it, so an +// unresolvable filename must fail loudly rather than pass as an empty worker. +{ + const result = await spawnTest('spawn-test-ok-child.mjs', { worker: true, cwd: '..' }); + assert.notStrictEqual(result.status, 0, 'expected cwd ".." to make the child filename unresolvable'); + if (!result.stderr.includes('spawn-test-ok-child.mjs')) { + throw new Error(`Expected stderr to reference the unresolved child filename, got:\n${result.stderr}`); + } +}