feat: add spawnTest harness global by bavulapati · Pull Request #54 · nodejs/node-api-cts · GitHub
Skip to content
3 changes: 2 additions & 1 deletion eslint.config.js
16 changes: 16 additions & 0 deletions implementors/node/child_process.d.ts
Original file line number Diff line number Diff line change
@@ -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,
): SpawnTestResult;
62 changes: 62 additions & 0 deletions implementors/node/child_process.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { spawnSync } 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 {{ status: number | null, aborted: boolean, stdout: string, stderr: string }}
*/
export const spawnTest = (filePath, options = {}) => {
Comment thread
bavulapati marked this conversation as resolved.
// --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,
];

const result = spawnSync(process.execPath, args, {
cwd: options.cwd ?? process.cwd(),
maxBuffer: 100 * 1024 * 1024,
stdio: ['ignore', options.stdout ?? 'pipe', 'pipe'],
});
if (result.error) throw result.error;
return {
status: result.status,
aborted: result.signal !== null || ABORT_EXIT_CODES.includes(result.status),
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 });
9 changes: 8 additions & 1 deletion implementors/node/features.js
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
1 change: 1 addition & 0 deletions implementors/node/harness.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
80 changes: 25 additions & 55 deletions implementors/node/tests.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
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',
);

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 });
Expand All @@ -36,53 +31,28 @@ export function listDirectoryEntries(dir: string) {
return { directories, files };
}

export function runFileInSubprocess(
cwd: string,
filePath: string,
): Promise<void> {
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}`,
),
);
});
export function runFileInSubprocess(cwd: string, filePath: string): void {
// 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 } = spawnTest(filePath, {
cwd,
stdout: 'inherit',
});

if (status === 0) return;

const reason = aborted ?
'aborted' :
status !== null ? `exit code ${status}` : '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}`,
);
}
4 changes: 4 additions & 0 deletions tests/harness/spawn-test-fail-child.mjs
Original file line number Diff line number Diff line change
@@ -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');
5 changes: 5 additions & 0 deletions tests/harness/spawn-test-ok-child.mjs
Original file line number Diff line number Diff line change
@@ -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');
}
73 changes: 73 additions & 0 deletions tests/harness/spawn-test.js
Loading