diff --git a/scripts/test-parallel-utils.mjs b/scripts/test-parallel-utils.mjs new file mode 100644 index 00000000000..96bf86a3c34 --- /dev/null +++ b/scripts/test-parallel-utils.mjs @@ -0,0 +1,35 @@ +const DEFAULT_OUTPUT_CAPTURE_LIMIT = 200_000; + +const fatalOutputPatterns = [ + /FATAL ERROR:.*heap out of memory/i, + /Allocation failed - JavaScript heap out of memory/i, + /node::OOMErrorHandler/i, +]; + +export function appendCapturedOutput(current, chunk, limit = DEFAULT_OUTPUT_CAPTURE_LIMIT) { + if (!chunk) { + return current; + } + const next = `${current}${chunk}`; + if (next.length <= limit) { + return next; + } + return next.slice(-limit); +} + +export function hasFatalTestRunOutput(output) { + return fatalOutputPatterns.some((pattern) => pattern.test(output)); +} + +export function resolveTestRunExitCode({ code, signal, output }) { + if (typeof code === "number" && code !== 0) { + return code; + } + if (signal) { + return 1; + } + if (hasFatalTestRunOutput(output)) { + return 1; + } + return code ?? 0; +} diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 1a128cf70dd..9571668f3ba 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { channelTestPrefixes } from "../vitest.channel-paths.mjs"; import { isUnitConfigTestFile } from "../vitest.unit-paths.mjs"; +import { appendCapturedOutput, resolveTestRunExitCode } from "./test-parallel-utils.mjs"; import { loadTestRunnerBehavior, loadUnitTimingManifest, @@ -740,10 +741,11 @@ const runOnce = (entry, extraArgs = []) => const resolvedNodeOptions = heapFlag ? `${nextNodeOptions} ${heapFlag}`.trim() : nextNodeOptions; + let output = ""; let child; try { child = spawn(pnpm, args, { - stdio: "inherit", + stdio: ["inherit", "pipe", "pipe"], env: { ...process.env, VITEST_GROUP: entry.name, NODE_OPTIONS: resolvedNodeOptions }, shell: isWindows, }); @@ -753,17 +755,26 @@ const runOnce = (entry, extraArgs = []) => return; } children.add(child); + child.stdout?.on("data", (chunk) => { + const text = chunk.toString(); + output = appendCapturedOutput(output, text); + process.stdout.write(chunk); + }); + child.stderr?.on("data", (chunk) => { + const text = chunk.toString(); + output = appendCapturedOutput(output, text); + process.stderr.write(chunk); + }); child.on("error", (err) => { console.error(`[test-parallel] child error: ${String(err)}`); }); - child.on("exit", (code, signal) => { + child.on("close", (code, signal) => { children.delete(child); + const resolvedCode = resolveTestRunExitCode({ code, signal, output }); console.log( - `[test-parallel] done ${entry.name} code=${String(code ?? (signal ? 1 : 0))} elapsed=${formatElapsedMs( - Date.now() - startedAt, - )}`, + `[test-parallel] done ${entry.name} code=${String(resolvedCode)} elapsed=${formatElapsedMs(Date.now() - startedAt)}`, ); - resolve(code ?? (signal ? 1 : 0)); + resolve(resolvedCode); }); }); diff --git a/test/scripts/test-parallel.test.ts b/test/scripts/test-parallel.test.ts new file mode 100644 index 00000000000..d5826de5412 --- /dev/null +++ b/test/scripts/test-parallel.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + appendCapturedOutput, + hasFatalTestRunOutput, + resolveTestRunExitCode, +} from "../../scripts/test-parallel-utils.mjs"; + +describe("scripts/test-parallel fatal output guard", () => { + it("fails a zero exit when V8 reports an out-of-memory fatal", () => { + const output = [ + "FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory", + "node::OOMErrorHandler(char const*, v8::OOMDetails const&)", + "[test-parallel] done unit-fast code=0 elapsed=210.9s", + ].join("\n"); + + expect(hasFatalTestRunOutput(output)).toBe(true); + expect(resolveTestRunExitCode({ code: 0, signal: null, output })).toBe(1); + }); + + it("keeps a clean zero exit green", () => { + expect( + resolveTestRunExitCode({ + code: 0, + signal: null, + output: "Test Files 3 passed (3)", + }), + ).toBe(0); + }); + + it("preserves explicit non-zero exits", () => { + expect(resolveTestRunExitCode({ code: 2, signal: null, output: "" })).toBe(2); + }); + + it("keeps only the tail of captured output", () => { + const output = appendCapturedOutput("", "abc", 5); + expect(appendCapturedOutput(output, "defg", 5)).toBe("cdefg"); + }); +});