Skip to content

Commit 525bba8

Browse files
committed
feat: add spawnTest harness global
1 parent 943ad7f commit 525bba8

8 files changed

Lines changed: 144 additions & 88 deletions

File tree

eslint.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export default defineConfig([
99
tseslint.configs.recommended,
1010
{
1111
files: [
12-
"tests/**/*.js",
12+
"tests/**/*.{mjs,js}",
1313
],
1414
languageOptions: {
1515
// Only allow ECMAScript built-ins and CTS harness globals.
@@ -22,6 +22,7 @@ export default defineConfig([
2222
mustCall: "readonly",
2323
mustNotCall: "readonly",
2424
gcUntil: "readonly",
25+
spawnTest: "readonly",
2526
experimentalFeatures: "readonly",
2627
napiVersion: "readonly",
2728
skipTest: "readonly",
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export interface SpawnTestOptions {
2+
cwd?: string;
3+
nodeFlags?: string[];
4+
}
5+
6+
export interface SpawnTestResult {
7+
status: number | null;
8+
signal: NodeJS.Signals | null;
9+
stdout: string;
10+
stderr: string;
11+
}
12+
13+
export function spawnTest(
14+
filePath: string,
15+
options?: SpawnTestOptions
16+
): SpawnTestResult;

implementors/node/child_process.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { spawnSync } from "node:child_process";
2+
import path from "node:path";
3+
4+
const ROOT_PATH = path.resolve(import.meta.dirname, "..", "..");
5+
const HARNESS_MODULE_PATHS = [
6+
"features.js",
7+
"assert.js",
8+
"load-addon.js",
9+
"gc.js",
10+
"must-call.js",
11+
"child_process.js",
12+
].map((file) => path.join(ROOT_PATH, "implementors", "node", file));
13+
14+
/**
15+
* Runs a test file in a fresh Node.js subprocess with the CTS harness globals
16+
* pre-loaded, and returns its exit status, signal, and captured output.
17+
*
18+
* @param {string} filePath - Path to the JS/MJS file to execute. Resolved
19+
* against `options.cwd` if relative.
20+
* @param {{ cwd?: string, nodeFlags?: string[] }} [options]
21+
* - `cwd`: working directory for the child; defaults to `process.cwd()`.
22+
* - `nodeFlags`: CLI flags passed to `node` before the `--import` chain
23+
* (e.g., `["--expose-gc"]`). Defaults to no flags so each caller declares
24+
* what its child needs.
25+
* @returns {{ status: number | null, signal: NodeJS.Signals | null, stdout: string, stderr: string }}
26+
*/
27+
export const spawnTest = (filePath, options = {}) => {
28+
const args = [...(options.nodeFlags ?? [])];
29+
for (const modulePath of HARNESS_MODULE_PATHS) {
30+
args.push("--import", "file://" + modulePath);
31+
}
32+
args.push(filePath);
33+
34+
const result = spawnSync(process.execPath, args, {
35+
cwd: options.cwd || process.cwd(),
36+
});
37+
return {
38+
status: result.status,
39+
signal: result.signal,
40+
stderr: result.stderr?.toString() ?? "",
41+
stdout: result.stdout?.toString() ?? "",
42+
};
43+
};
44+
45+
Object.assign(globalThis, { spawnTest });

implementors/node/tests.ts

Lines changed: 23 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,16 @@
11
import assert from "node:assert";
2-
import { spawn } from "node:child_process";
32
import fs from "node:fs";
43
import path from "node:path";
54

5+
import { spawnTest } from "./child_process.js";
6+
67
assert(
78
typeof import.meta.dirname === "string",
89
"Expecting a recent Node.js runtime API version"
910
);
1011

1112
const ROOT_PATH = path.resolve(import.meta.dirname, "..", "..");
1213
const TESTS_ROOT_PATH = path.join(ROOT_PATH, "tests");
13-
const FEATURES_MODULE_PATH = path.join(
14-
ROOT_PATH,
15-
"implementors",
16-
"node",
17-
"features.js"
18-
);
19-
const ASSERT_MODULE_PATH = path.join(
20-
ROOT_PATH,
21-
"implementors",
22-
"node",
23-
"assert.js"
24-
);
25-
const LOAD_ADDON_MODULE_PATH = path.join(
26-
ROOT_PATH,
27-
"implementors",
28-
"node",
29-
"load-addon.js"
30-
);
31-
const GC_MODULE_PATH = path.join(
32-
ROOT_PATH,
33-
"implementors",
34-
"node",
35-
"gc.js"
36-
);
37-
const MUST_CALL_MODULE_PATH = path.join(
38-
ROOT_PATH,
39-
"implementors",
40-
"node",
41-
"must-call.js"
42-
);
4314

4415
export function listDirectoryEntries(dir: string) {
4516
const entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -60,61 +31,26 @@ export function listDirectoryEntries(dir: string) {
6031
return { directories, files };
6132
}
6233

63-
export function runFileInSubprocess(
64-
cwd: string,
65-
filePath: string
66-
): Promise<void> {
67-
return new Promise((resolve, reject) => {
68-
const child = spawn(
69-
process.execPath,
70-
[
71-
// Using file scheme prefix when to enable imports on Windows
72-
"--expose-gc",
73-
"--import",
74-
"file://" + FEATURES_MODULE_PATH,
75-
"--import",
76-
"file://" + ASSERT_MODULE_PATH,
77-
"--import",
78-
"file://" + LOAD_ADDON_MODULE_PATH,
79-
"--import",
80-
"file://" + GC_MODULE_PATH,
81-
"--import",
82-
"file://" + MUST_CALL_MODULE_PATH,
83-
filePath,
84-
],
85-
{ cwd }
86-
);
87-
88-
let stderrOutput = "";
89-
child.stderr.setEncoding("utf8");
90-
child.stderr.on("data", (chunk) => {
91-
stderrOutput += chunk;
92-
});
93-
94-
child.stdout.pipe(process.stdout);
95-
96-
child.on("error", reject);
97-
98-
child.on("close", (code, signal) => {
99-
if (code === 0) {
100-
resolve();
101-
return;
102-
}
103-
104-
const reason =
105-
code !== null ? `exit code ${code}` : `signal ${signal ?? "unknown"}`;
106-
const trimmedStderr = stderrOutput.trim();
107-
const stderrSuffix = trimmedStderr
108-
? `\n--- stderr ---\n${trimmedStderr}\n--- end stderr ---`
109-
: "";
110-
reject(
111-
new Error(
112-
`Test file ${path.relative(
113-
TESTS_ROOT_PATH,
114-
filePath
115-
)} failed (${reason})${stderrSuffix}`
116-
)
117-
);
118-
});
34+
export function runFileInSubprocess(cwd: string, filePath: string): void {
35+
const { status, signal, stdout, stderr } = spawnTest(filePath, {
36+
cwd,
37+
nodeFlags: ["--expose-gc"],
11938
});
39+
40+
if (stdout) process.stdout.write(stdout);
41+
42+
if (status === 0) return;
43+
44+
const reason =
45+
status !== null ? `exit code ${status}` : `signal ${signal ?? "unknown"}`;
46+
const trimmedStderr = stderr.trim();
47+
const stderrSuffix = trimmedStderr
48+
? `\n--- stderr ---\n${trimmedStderr}\n--- end stderr ---`
49+
: "";
50+
throw new Error(
51+
`Test file ${path.relative(
52+
TESTS_ROOT_PATH,
53+
filePath
54+
)} failed (${reason})${stderrSuffix}`
55+
);
12056
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Spawned by spawn-test.js. Throws an error with a recognizable marker so the
2+
// parent can assert that stderr was captured and that the non-zero exit status
3+
// is surfaced.
4+
throw new Error('spawn-test-fail-marker');
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Spawned by spawn-test.js to verify that custom nodeFlags reach the child
2+
// process. With --expose-gc, Node installs `gc` on globalThis; without the
3+
// flag, it is undefined.
4+
// eslint-disable-next-line no-restricted-syntax
5+
if (typeof globalThis.gc !== 'function') {
6+
throw new Error('Expected globalThis.gc to be a function when --expose-gc is forwarded');
7+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Spawned by spawn-test.js. Confirms harness globals are injected into the
2+
// child process by using `assert`, then exits 0.
3+
assert.ok(true, 'assert should be a CTS harness global inside spawned children');

tests/harness/spawn-test.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// spawnTest is a function
2+
if (typeof spawnTest !== 'function') {
3+
throw new Error('Expected a global spawnTest function');
4+
}
5+
6+
// Successful child: exits 0, stderr empty, and harness globals were available
7+
// inside the child (the child asserts this itself via `assert.ok(true)`).
8+
{
9+
const result = spawnTest('spawn-test-ok-child.mjs');
10+
assert.strictEqual(result.status, 0, `ok child exited with status ${result.status}; stderr:\n${result.stderr}`);
11+
assert.strictEqual(result.signal, null);
12+
assert.strictEqual(result.stderr, '');
13+
}
14+
15+
// Failing child: non-zero status and stderr contains the thrown marker.
16+
{
17+
const result = spawnTest('spawn-test-fail-child.mjs');
18+
assert.notStrictEqual(result.status, 0, 'fail child should exit non-zero');
19+
if (!result.stderr.includes('spawn-test-fail-marker')) {
20+
throw new Error(`Expected stderr to include the failure marker, got:\n${result.stderr}`);
21+
}
22+
}
23+
24+
// Result shape: all four fields are present.
25+
{
26+
const result = spawnTest('spawn-test-ok-child.mjs');
27+
for (const key of ['status', 'signal', 'stdout', 'stderr']) {
28+
if (!(key in result)) {
29+
throw new Error(`Expected spawnTest result to have "${key}" field`);
30+
}
31+
}
32+
assert.strictEqual(typeof result.stdout, 'string');
33+
assert.strictEqual(typeof result.stderr, 'string');
34+
}
35+
36+
// nodeFlags are forwarded to the child: without --expose-gc the gc child
37+
// exits non-zero; passing it via nodeFlags makes the child exit 0.
38+
{
39+
const withoutFlag = spawnTest('spawn-test-gc-child.mjs');
40+
assert.notStrictEqual(withoutFlag.status, 0, 'gc child should fail when --expose-gc is not forwarded');
41+
42+
const withFlag = spawnTest('spawn-test-gc-child.mjs', { nodeFlags: ['--expose-gc'] });
43+
assert.strictEqual(withFlag.status, 0, `gc child exited with status ${withFlag.status}; stderr:\n${withFlag.stderr}`);
44+
}

0 commit comments

Comments
 (0)