diff --git a/eslint.config.js b/eslint.config.js index acb8ac2..ef33613 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -87,7 +87,7 @@ export default defineConfig([ }, { files: [ - 'tests/**/*.js', + 'tests/**/*.{mjs,js}', ], languageOptions: { // Only allow ECMAScript built-ins and CTS harness globals. @@ -101,6 +101,7 @@ export default defineConfig([ mustNotCall: 'readonly', gc: 'readonly', gcUntil: 'readonly', + spawnTest: 'readonly', experimentalFeatures: 'readonly', runtimeFeatures: 'readonly', onUncaughtException: 'readonly', diff --git a/implementors/node/child_process.d.ts b/implementors/node/child_process.d.ts new file mode 100644 index 0000000..d63b290 --- /dev/null +++ b/implementors/node/child_process.d.ts @@ -0,0 +1,16 @@ +export interface SpawnTestOptions { + cwd?: string; + stdout?: 'pipe' | 'inherit'; +} + +export interface SpawnTestResult { + status: number | null; + aborted: boolean; + stdout: string; + stderr: string; +} + +export function spawnTest( + filePath: string, + options?: SpawnTestOptions, +): Promise; diff --git a/implementors/node/child_process.js b/implementors/node/child_process.js new file mode 100644 index 0000000..d43e85b --- /dev/null +++ b/implementors/node/child_process.js @@ -0,0 +1,79 @@ +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +// A single module that imports every harness global (including this one, so +// spawned children can recursively call spawnTest). Consolidating the harness +// into one --import keeps the child's command line short. +const HARNESS_MODULE_PATH = path.join(import.meta.dirname, 'harness.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 +// `common.nodeProcessAborted`. The POSIX 128+N codes and the Windows NTSTATUS +// codes share one list because the ranges don't overlap. Keeping this here, in +// the Node implementor, lets portable tests ask "did it abort?" via the +// `aborted` flag without encoding any runtime-specific process semantics. +const ABORT_EXIT_CODES = [132, 133, 134, 139, 0xc0000409, 0xc000001d]; + +/** + * Runs a test file in a fresh Node.js subprocess with the CTS harness globals + * pre-loaded, and returns its exit status, whether it aborted, and output. + * + * @param {string} filePath - Path to the JS/MJS file to execute. Resolved + * against `options.cwd` if relative. + * @param {{ cwd?: string, stdout?: 'pipe' | 'inherit' }} [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. + * @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:\...). + const args = [ + '--expose-gc', + '--import', + pathToFileURL(HARNESS_MODULE_PATH).href, + filePath, + ]; + + // spawn (not spawnSync) so a hung child doesn't block the event loop and the + // test runner can still enforce its per-test timeout. + const child = spawn(process.execPath, args, { + cwd: options.cwd ?? process.cwd(), + stdio: ['ignore', options.stdout ?? 'pipe', 'pipe'], + }); + + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + // child.stdout is null when stdout is 'inherit' (streamed to the terminal). + child.stdout?.setEncoding('utf8').on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.setEncoding('utf8').on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', reject); + // 'close' (not 'exit') fires once the stdio streams have drained, so stdout + // and stderr are complete. + child.on('close', (status, signal) => { + resolve({ + status, + aborted: signal !== null || ABORT_EXIT_CODES.includes(status), + stderr, + stdout, + }); + }); + }); +}; + +// This module is loaded in both contexts: imported by the parent test runner +// (tests.ts) and `--import`ed into every spawned child. The side effect below +// installs `spawnTest` on the child's globalThis so tests can call it directly. +Object.assign(globalThis, { spawnTest }); diff --git a/implementors/node/features.js b/implementors/node/features.js index ba8126d..fec2137 100644 --- a/implementors/node/features.js +++ b/implementors/node/features.js @@ -15,8 +15,15 @@ globalThis.experimentalFeatures = { postFinalizer: true, }; -// Version-dependent behaviors of stable (non-experimental) Node-API. +// Version-dependent behaviors of stable Node-API, plus harness/runtime +// capabilities that aren't tied to Node-API at all (e.g. whether the runtime +// can spawn subprocesses). Implementors that lack a capability set it to false. globalThis.runtimeFeatures = { + // Node.js can spawn isolated subprocesses, so the spawnTest global is + // available. Runtimes that can't (e.g. WASM, React Native) set this to false + // and need not provide a spawnTest implementation. + spawn: 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/harness.js b/implementors/node/harness.js index c3fbcd1..98ebf7a 100644 --- a/implementors/node/harness.js +++ b/implementors/node/harness.js @@ -6,3 +6,4 @@ import './must-call.js'; import './on-uncaught-exception.js'; import './skip-test.js'; import './napi-version.js'; +import './child_process.js'; diff --git a/implementors/node/tests.ts b/implementors/node/tests.ts index 23283fb..ab784cf 100644 --- a/implementors/node/tests.ts +++ b/implementors/node/tests.ts @@ -1,8 +1,9 @@ import assert from 'node:assert'; -import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { spawnTest } from './child_process.js'; + assert( typeof import.meta.dirname === 'string', 'Expecting a recent Node.js runtime API version', @@ -10,12 +11,6 @@ assert( const ROOT_PATH = path.resolve(import.meta.dirname, '..', '..'); const TESTS_ROOT_PATH = path.join(ROOT_PATH, 'tests'); -const HARNESS_MODULE_PATH = path.join( - ROOT_PATH, - 'implementors', - 'node', - 'harness.js', -); export function listDirectoryEntries(dir: string) { const entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -36,53 +31,31 @@ export function listDirectoryEntries(dir: string) { return { directories, files }; } -export function runFileInSubprocess( +export async function runFileInSubprocess( cwd: string, filePath: string, ): Promise { - return new Promise((resolve, reject) => { - const child = spawn( - process.execPath, - [ - // Using file scheme prefix when to enable imports on Windows - '--expose-gc', - '--import', - 'file://' + HARNESS_MODULE_PATH, - filePath, - ], - { cwd }, - ); - - let stderrOutput = ''; - child.stderr.setEncoding('utf8'); - child.stderr.on('data', (chunk) => { - stderrOutput += chunk; - }); - - child.stdout.pipe(process.stdout); - - child.on('error', reject); - - child.on('close', (code, signal) => { - if (code === 0) { - resolve(); - return; - } - - const reason = - code !== null ? `exit code ${code}` : `signal ${signal ?? 'unknown'}`; - const trimmedStderr = stderrOutput.trim(); - const stderrSuffix = trimmedStderr ? - `\n--- stderr ---\n${trimmedStderr}\n--- end stderr ---` : - ''; - reject( - new Error( - `Test file ${path.relative( - TESTS_ROOT_PATH, - filePath, - )} failed (${reason})${stderrSuffix}`, - ), - ); - }); + // Stream stdout live rather than buffering it, so output from a slow or + // hanging test shows up immediately. stderr is still captured to attach to + // the failure message below. + const { status, aborted, stderr } = await spawnTest(filePath, { + cwd, + stdout: 'inherit', }); + + if (status === 0) return; + + // A null status means the child was killed by a signal, which already sets + // `aborted`, so the non-aborted branch always has a numeric exit code. + const reason = aborted ? 'aborted' : `exit code ${status}`; + const trimmedStderr = stderr.trim(); + const stderrSuffix = trimmedStderr ? + `\n--- stderr ---\n${trimmedStderr}\n--- end stderr ---` : + ''; + throw new Error( + `Test file ${path.relative( + TESTS_ROOT_PATH, + path.join(cwd, filePath), + )} failed (${reason})${stderrSuffix}`, + ); } diff --git a/tests/harness/spawn-test-fail-child.mjs b/tests/harness/spawn-test-fail-child.mjs new file mode 100644 index 0000000..b18caa2 --- /dev/null +++ b/tests/harness/spawn-test-fail-child.mjs @@ -0,0 +1,4 @@ +// Spawned by spawn-test.js. Throws an error with a recognizable marker so the +// parent can assert that stderr was captured and that the non-zero exit status +// is surfaced. +throw new Error('spawn-test-fail-marker'); diff --git a/tests/harness/spawn-test-ok-child.mjs b/tests/harness/spawn-test-ok-child.mjs new file mode 100644 index 0000000..b29ee8c --- /dev/null +++ b/tests/harness/spawn-test-ok-child.mjs @@ -0,0 +1,5 @@ +// Spawned by spawn-test.js. Confirms harness globals are injected into the +// child process by checking `assert` exists, then exits 0. +if (typeof assert !== 'function') { + throw new Error('Expected `assert` to be a CTS harness global inside spawned children'); +} diff --git a/tests/harness/spawn-test.js b/tests/harness/spawn-test.js new file mode 100644 index 0000000..f9e9f66 --- /dev/null +++ b/tests/harness/spawn-test.js @@ -0,0 +1,73 @@ +// Spawning subprocesses is an optional harness capability. Runtimes that can't +// spawn (e.g. WASM, React Native) declare runtimeFeatures.spawn = false and need +// not provide a spawnTest implementation; this whole spawn-only test is then +// skipped. +assert.strictEqual( + typeof runtimeFeatures.spawn, + 'boolean', + 'Expected runtimeFeatures.spawn to be a boolean', +); +if (!runtimeFeatures.spawn) { + skipTest(); +} + +// spawnTest is a function +if (typeof spawnTest !== 'function') { + throw new Error('Expected a global spawnTest function'); +} + +// Successful child: exits 0, stderr empty, and harness globals are available +// inside the child (the child checks `typeof assert === 'function'` itself). +{ + const result = await spawnTest('spawn-test-ok-child.mjs'); + 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, ''); +} + +// Failing child: non-zero status and stderr contains the thrown marker. +{ + const result = await spawnTest('spawn-test-fail-child.mjs'); + assert.notStrictEqual(result.status, 0, 'fail child should exit non-zero'); + // A thrown error is a clean non-zero exit, not an abnormal termination. + 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}`); + } +} + +// Result shape: all four fields are present. +{ + const result = await spawnTest('spawn-test-ok-child.mjs'); + for (const key of ['status', 'aborted', 'stdout', 'stderr']) { + if (!(key in result)) { + throw new Error(`Expected spawnTest result to have "${key}" field`); + } + } + assert.strictEqual(typeof result.aborted, 'boolean'); + assert.strictEqual(typeof result.stdout, 'string'); + assert.strictEqual(typeof result.stderr, 'string'); +} + +// stdout: 'inherit' streams the child's stdout straight to the terminal as it +// runs (so a slow or hanging test's output is visible immediately) instead of +// buffering it. The streamed output bypasses the result, so captured stdout is +// empty; the child still runs to a clean exit. (Asserting that the stream +// actually reached the terminal would need the child to write to stdout, which +// isn't expressible in portable ECMAScript, so we only check the contract here.) +{ + const result = await spawnTest('spawn-test-ok-child.mjs', { stdout: 'inherit' }); + assert.strictEqual(result.status, 0, `ok child exited with status ${result.status}; stderr:\n${result.stderr}`); + assert.strictEqual(result.stdout, '', 'inherited stdout should not be captured'); +} + +// cwd is forwarded to the child: running from the parent of tests/harness +// makes the bare child filename unresolvable. The child's stderr must name +// the specific file Node tried to load, proving cwd actually shifted. +{ + const result = await spawnTest('spawn-test-ok-child.mjs', { 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}`); + } +}