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
3 changes: 2 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -22,6 +22,7 @@ export default defineConfig([
mustCall: "readonly",
mustNotCall: "readonly",
gcUntil: "readonly",
spawnTest: "readonly",
experimentalFeatures: "readonly",
napiVersion: "readonly",
skipTest: "readonly",
Expand Down
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;
nodeFlags?: string[];
}

export interface SpawnTestResult {
status: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
}

export function spawnTest(
filePath: string,
options?: SpawnTestOptions
): SpawnTestResult;
49 changes: 49 additions & 0 deletions implementors/node/child_process.js
Original file line number Diff line number Diff line change
@@ -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 });
110 changes: 23 additions & 87 deletions implementors/node/tests.ts
Original file line number Diff line number Diff line change
@@ -1,45 +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 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 });
Expand All @@ -60,61 +31,26 @@ 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://" + 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}`
);
}
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');
7 changes: 7 additions & 0 deletions tests/harness/spawn-test-gc-child.mjs
Original file line number Diff line number Diff line change
@@ -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');
}
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');
}
55 changes: 55 additions & 0 deletions tests/harness/spawn-test.js
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
Loading