From 2795471b4fe0b850dacd34f5ca31619e2ccd74ac Mon Sep 17 00:00:00 2001 From: Balakrishna Avulapati Date: Thu, 16 Apr 2026 20:51:49 +0530 Subject: [PATCH] feat: add spawnTest harness global --- eslint.config.js | 3 +- implementors/node/child_process.d.ts | 16 ++++ implementors/node/child_process.js | 49 +++++++++++ implementors/node/tests.ts | 110 +++++------------------- tests/harness/spawn-test-fail-child.mjs | 4 + tests/harness/spawn-test-gc-child.mjs | 7 ++ tests/harness/spawn-test-ok-child.mjs | 5 ++ tests/harness/spawn-test.js | 55 ++++++++++++ 8 files changed, 161 insertions(+), 88 deletions(-) create mode 100644 implementors/node/child_process.d.ts create mode 100644 implementors/node/child_process.js create mode 100644 tests/harness/spawn-test-fail-child.mjs create mode 100644 tests/harness/spawn-test-gc-child.mjs create mode 100644 tests/harness/spawn-test-ok-child.mjs create mode 100644 tests/harness/spawn-test.js diff --git a/eslint.config.js b/eslint.config.js index c5c7e38..0fa1bca 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,7 +9,7 @@ export default defineConfig([ tseslint.configs.recommended, { files: [ - "tests/**/*.js", + "tests/**/*.{mjs,js}", ], languageOptions: { // Only allow ECMAScript built-ins and CTS harness globals. @@ -22,6 +22,7 @@ export default defineConfig([ mustCall: "readonly", mustNotCall: "readonly", gcUntil: "readonly", + spawnTest: "readonly", experimentalFeatures: "readonly", napiVersion: "readonly", skipTest: "readonly", diff --git a/implementors/node/child_process.d.ts b/implementors/node/child_process.d.ts new file mode 100644 index 0000000..51e5e01 --- /dev/null +++ b/implementors/node/child_process.d.ts @@ -0,0 +1,16 @@ +export interface SpawnTestOptions { + cwd?: string; + nodeFlags?: string[]; +} + +export interface SpawnTestResult { + status: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; +} + +export function spawnTest( + filePath: string, + options?: SpawnTestOptions +): SpawnTestResult; diff --git a/implementors/node/child_process.js b/implementors/node/child_process.js new file mode 100644 index 0000000..2dd89b8 --- /dev/null +++ b/implementors/node/child_process.js @@ -0,0 +1,49 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; + +const HARNESS_MODULE_PATHS = [ + "features.js", + "assert.js", + "load-addon.js", + "gc.js", + "must-call.js", + "child_process.js", +].map((file) => path.join(import.meta.dirname, file)); + +/** + * Runs a test file in a fresh Node.js subprocess with the CTS harness globals + * pre-loaded, and returns its exit status, signal, and captured output. + * + * @param {string} filePath - Path to the JS/MJS file to execute. Resolved + * against `options.cwd` if relative. + * @param {{ cwd?: string, nodeFlags?: string[] }} [options] + * - `cwd`: working directory for the child; defaults to `process.cwd()`. + * - `nodeFlags`: CLI flags passed to `node` before the `--import` chain + * (e.g., `["--expose-gc"]`). Defaults to no flags so each caller declares + * what its child needs. + * @returns {{ status: number | null, signal: NodeJS.Signals | null, stdout: string, stderr: string }} + */ +export const spawnTest = (filePath, options = {}) => { + const args = [...(options.nodeFlags ?? [])]; + for (const modulePath of HARNESS_MODULE_PATHS) { + args.push("--import", "file://" + modulePath); + } + args.push(filePath); + + const result = spawnSync(process.execPath, args, { + cwd: options.cwd ?? process.cwd(), + maxBuffer: 100 * 1024 * 1024, + }); + if (result.error) throw result.error; + return { + status: result.status, + signal: result.signal, + stderr: result.stderr?.toString() ?? "", + stdout: result.stdout?.toString() ?? "", + }; +}; + +// 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/tests.ts b/implementors/node/tests.ts index d4f8b2c..f9b7dee 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,36 +11,6 @@ assert( const ROOT_PATH = path.resolve(import.meta.dirname, "..", ".."); const TESTS_ROOT_PATH = path.join(ROOT_PATH, "tests"); -const FEATURES_MODULE_PATH = path.join( - ROOT_PATH, - "implementors", - "node", - "features.js" -); -const ASSERT_MODULE_PATH = path.join( - ROOT_PATH, - "implementors", - "node", - "assert.js" -); -const LOAD_ADDON_MODULE_PATH = path.join( - ROOT_PATH, - "implementors", - "node", - "load-addon.js" -); -const GC_MODULE_PATH = path.join( - ROOT_PATH, - "implementors", - "node", - "gc.js" -); -const MUST_CALL_MODULE_PATH = path.join( - ROOT_PATH, - "implementors", - "node", - "must-call.js" -); export function listDirectoryEntries(dir: string) { const entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -60,61 +31,26 @@ export function listDirectoryEntries(dir: string) { return { directories, files }; } -export 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://" + FEATURES_MODULE_PATH, - "--import", - "file://" + ASSERT_MODULE_PATH, - "--import", - "file://" + LOAD_ADDON_MODULE_PATH, - "--import", - "file://" + GC_MODULE_PATH, - "--import", - "file://" + MUST_CALL_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}` - ) - ); - }); +export function runFileInSubprocess(cwd: string, filePath: string): void { + const { status, signal, stdout, stderr } = spawnTest(filePath, { + cwd, + nodeFlags: ["--expose-gc"], }); + + if (stdout) process.stdout.write(stdout); + + if (status === 0) return; + + const reason = + status !== null ? `exit code ${status}` : `signal ${signal ?? "unknown"}`; + 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-gc-child.mjs b/tests/harness/spawn-test-gc-child.mjs new file mode 100644 index 0000000..e7de301 --- /dev/null +++ b/tests/harness/spawn-test-gc-child.mjs @@ -0,0 +1,7 @@ +// Spawned by spawn-test.js to verify that custom nodeFlags reach the child +// process. With --expose-gc, Node installs `gc` on globalThis; without the +// flag, it is undefined. +// eslint-disable-next-line no-restricted-syntax +if (typeof globalThis.gc !== 'function') { + throw new Error('Expected globalThis.gc to be a function when --expose-gc is forwarded'); +} 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..fe7be44 --- /dev/null +++ b/tests/harness/spawn-test.js @@ -0,0 +1,55 @@ +// 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 = 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.signal, null); + assert.strictEqual(result.stderr, ''); +} + +// Failing child: non-zero status and stderr contains the thrown marker. +{ + const result = spawnTest('spawn-test-fail-child.mjs'); + assert.notStrictEqual(result.status, 0, 'fail child should exit non-zero'); + 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 = spawnTest('spawn-test-ok-child.mjs'); + for (const key of ['status', 'signal', 'stdout', 'stderr']) { + if (!(key in result)) { + throw new Error(`Expected spawnTest result to have "${key}" field`); + } + } + assert.strictEqual(typeof result.stdout, 'string'); + assert.strictEqual(typeof result.stderr, 'string'); +} + +// nodeFlags are forwarded to the child: without --expose-gc the gc child +// exits non-zero; passing it via nodeFlags makes the child exit 0. +{ + const withoutFlag = spawnTest('spawn-test-gc-child.mjs'); + assert.notStrictEqual(withoutFlag.status, 0, 'gc child should fail when --expose-gc is not forwarded'); + + const withFlag = spawnTest('spawn-test-gc-child.mjs', { nodeFlags: ['--expose-gc'] }); + assert.strictEqual(withFlag.status, 0, `gc child exited with status ${withFlag.status}; stderr:\n${withFlag.stderr}`); +} + +// 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 = 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}`); + } +}