Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions implementors/node/child_process.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export interface SpawnTestOptions {
cwd?: string;
stdout?: 'pipe' | 'inherit';
worker?: boolean;
}

export interface SpawnTestResult {
Expand Down
17 changes: 15 additions & 2 deletions implementors/node/child_process.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,24 +26,33 @@ 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 = {}) => {
// --expose-gc is mandatory: gc.js (loaded via harness.js) throws at import
// 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
Expand Down
7 changes: 7 additions & 0 deletions implementors/node/features.js
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
18 changes: 18 additions & 0 deletions implementors/node/worker-entry.js
Original file line number Diff line number Diff line change
@@ -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)));
50 changes: 50 additions & 0 deletions tests/harness/spawn-test-worker.js
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
Loading