From 6381d3a3945f13c96e353929be0508ca26cddf32 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:20:07 +0000 Subject: [PATCH 1/5] node:test: cancel the running file of run() when its signal aborts run({ files, signal }) took the verdict of a file from the exit code of its child. A child that handles SIGTERM and exits with code 0 made an aborted run report success, with no test:fail. The abort now ends the file in the runner, as Test#abortHandler does in Node.js: the file fails with the reason of the abort and the failure type testAborted, it counts as cancelled, and the stream ends at once. The runner ends the child itself and reads both pipes to their end. It publishes nothing of the child after the abort. A file that did not start takes the same path. A child that cannot start fails its file. Neither destroys the stream. --- src/js/node/test.ts | 180 ++++++--- .../fixtures/run-controlled-child-fixture.ts | 379 ++++++++++++++++++ .../test_runner/node-test-run-signal.test.ts | 345 ++++++++++++++++ test/js/node/test_runner/node-test.test.ts | 146 ++++++- 4 files changed, 983 insertions(+), 67 deletions(-) create mode 100644 test/js/node/test_runner/fixtures/run-controlled-child-fixture.ts create mode 100644 test/js/node/test_runner/node-test-run-signal.test.ts diff --git a/src/js/node/test.ts b/src/js/node/test.ts index ace3768f419b..08f5cc397d36 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -391,16 +391,9 @@ async function runFiles(opts: ReturnType, reporter: T if (typeof opts.setup === "function") await (opts.setup as (reporter: TestsStream) => unknown)(reporter); const files = opts.files ?? []; - let i = 0; - for (; i < files.length; i++) { - if (opts.signal?.aborted) break; + for (let i = 0; i < files.length; i++) { await runOneFile(files[i], opts, reporter, counts); } - // Node cancels each not-yet-started FileTest with cancelledByParent rather - // than silently dropping it; an aborted run must not report success:true. - for (; i < files.length; i++) { - reportCancelledFile(files[i], opts, reporter, counts); - } reporter.plan({ __proto__: null, nesting: 0, count: counts.topLevel }); const durationMs = Date.now() - started; @@ -419,29 +412,29 @@ async function runFiles(opts: ReturnType, reporter: T reporter.endStream(); } -function reportCancelledFile( - file: string, - opts: ReturnType, +// Node's Test#abortHandler: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/test_runner/test.js#L1058-L1062 +function abortFailure(signal: AbortSignal) { + let error: any = signal.reason || $makeAbortError("The test was aborted"); + try { + error.failureType = "testAborted"; + } catch { + // Node throws here, for a primitive and for a frozen object. The reason becomes the cause of an AbortError. + error = $makeAbortError("The test was aborted", { cause: error }); + error.failureType = "testAborted"; + } + return error; +} + +// The verdict of a file that has none from its child: the signal cancelled the file, or the child did not start. +function reportFailedFile( + fileNode: object, + error: unknown, + count: "cancelled" | "failed", + durationMs: number, reporter: TestsStream, counts: Record, ) { - const path = require("node:path"); - const absolute = path.resolve(opts.cwd as string, file); - const fileNode = { - nesting: 0, - name: file, - type: "test", - testId: 1, - parentId: 0, - tags: [], - line: 1, - column: 1, - file: absolute, - }; - const error = makeTestFailure("test did not finish before its parent and was cancelled", "cancelledByParent"); - const details = { __proto__: null, duration_ms: 0, type: "test", error }; - reporter.enqueue({ __proto__: null, ...fileNode }); - reporter.dequeue({ __proto__: null, ...fileNode }); + const details = { __proto__: null, duration_ms: durationMs, type: "test", error }; reporter.complete({ __proto__: null, ...fileNode, @@ -451,10 +444,20 @@ function reportCancelledFile( }); reporter.fail({ __proto__: null, ...fileNode, type: undefined, testNumber: 1, details }); counts.tests++; - counts.cancelled++; + counts[count]++; counts.topLevel++; } +// Promise.race([stop, awaited]) without the public `resolve` and `then`, which the caller's program can replace. +function untilStopped(stop: Promise, awaited: Promise): Promise { + const first = $newPromise(); + const resolve = (value: T | undefined) => $resolvePromiseWithFirstResolvingFunctionCallCheck(first, value); + const reject = (err: unknown) => $rejectPromiseWithFirstResolvingFunctionCallCheck(first, err); + stop.$then(resolve, reject); + awaited.$then(resolve, reject); + return first; +} + async function runOneFile( file: string, opts: ReturnType, @@ -486,37 +489,75 @@ async function runOneFile( reporter.enqueue({ __proto__: null, ...fileNode }); reporter.dequeue({ __proto__: null, ...fileNode }); - const proc = Bun.spawn({ - cmd: args, - cwd: opts.cwd as string, - env: { ...(opts.env ?? process.env), BUN_TEST_DRAIN_EVENT_LOOP: "1", [kRunChildEnv]: kRunChildEnvValue }, - stdout: "pipe", - stderr: "pipe", - signal: opts.signal, - }); + const signal: AbortSignal | undefined = opts.signal; + // Node reads the signal before it spawns: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/test_runner/test.js#L1306-L1309 + if (signal?.aborted) { + reportFailedFile(fileNode, abortFailure(signal), "cancelled", 0, reporter, counts); + return; + } - let drainStderr: Promise | undefined; + let proc: import("bun").Subprocess<"ignore", "pipe", "pipe">; try { - let stderrText = ""; - drainStderr = (async () => { + proc = Bun.spawn({ + cmd: args, + cwd: opts.cwd as string, + env: { ...(opts.env ?? process.env), BUN_TEST_DRAIN_EVENT_LOOP: "1", [kRunChildEnv]: kRunChildEnvValue }, + stdout: "pipe", + stderr: "pipe", + }); + } catch (err) { + // Node fails the file with the spawn error: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/test_runner/runner.js#L532-L534 + const error = makeTestFailure(String((err as Error)?.message ?? err), "testCodeFailure"); + error.cause = err; + reportFailedFile(fileNode, error, "failed", Date.now() - fileStarted, reporter, counts); + return; + } + + // False after the abort, and after this function: nothing more of the child is published. + let live = true; + // Ends a wait for the child: it fulfills at the abort and rejects when the stderr reader fails. + let stop: Promise | undefined; + let abortListener: Disposable | undefined; + + try { + if (signal !== undefined) { + stop = $newPromise(); + addAbortListener ??= require("internal/abort_listener").addAbortListener; + // Node's abort listener ends the file test and its child: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/test_runner/test.js#L1064-L1077 + abortListener = addAbortListener!(signal, () => { + live = false; + proc.kill(); + $resolvePromiseWithFirstResolvingFunctionCallCheck(stop!, undefined); + }); + } + + // Reads one pipe of the child to its end, and publishes each line while the file is live. + const readLines = async (pipe: unknown, publish: (line: string) => void) => { const decoder = new TextDecoder(); let carry = ""; - for await (const chunk of proc.stderr as any) { - const text = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true }); - stderrText += text; - carry += text; + for await (const chunk of pipe as any) { + // Node reads on too, so the child never blocks: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/test_runner/runner.js#L536-L538 + if (!live) continue; + carry += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true }); let nl; - while ((nl = carry.indexOf("\n")) !== -1) { + while (live && (nl = carry.indexOf("\n")) !== -1) { const line = carry.slice(0, nl); carry = carry.slice(nl + 1); - if (line.length > 0) reporter.stderr({ __proto__: null, file: absolute, message: line + "\n" }); + publish(line); } } - if (carry.length > 0) reporter.stderr({ __proto__: null, file: absolute, message: carry + "\n" }); - })(); - // Defuse: a throwing test:stderr listener rejects this while the stdout - // loop is still suspended on I/O, before the finally's .catch attaches. - drainStderr.catch(() => {}); + if (live && carry.length > 0) publish(carry); + }; + + let stderrText = ""; + const drainStderr = readLines(proc.stderr, line => { + stderrText += line + "\n"; + if (line.length > 0) reporter.stderr({ __proto__: null, file: absolute, message: line + "\n" }); + }); + // A test:stderr listener that throws fails this reader. Only a run with a signal has a wait that this can end. + drainStderr.$then(undefined, (err: unknown) => { + if (stop !== undefined) $rejectPromiseWithFirstResolvingFunctionCallCheck(stop, err); + }); const handleStdoutLine = (line: string) => { if (line.length === 0) return; @@ -531,6 +572,7 @@ async function runOneFile( const before = line.slice(0, marker).trimEnd(); if (before.length > 0) { reporter.stdout({ __proto__: null, file: absolute, message: before + "\n" }); + if (!live) return; } } let event; @@ -543,21 +585,25 @@ async function runOneFile( republishChildEvent(event, absolute, reporter, fileCounts); }; - const decoder = new TextDecoder(); - let carry = ""; - for await (const chunk of proc.stdout as any) { - carry += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true }); - let nl; - while ((nl = carry.indexOf("\n")) !== -1) { - const line = carry.slice(0, nl); - carry = carry.slice(nl + 1); - handleStdoutLine(line); + const drainStdout = readLines(proc.stdout, handleStdoutLine); + + let exitCode: number | undefined; + if (stop === undefined) { + await drainStdout; + await drainStderr; + exitCode = await proc.exited; + } else { + // Node's file test ends at child exit and stdout end: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/test_runner/runner.js#L557-L560 + await untilStopped(stop, drainStdout); + exitCode = await untilStopped(stop, proc.exited); + if (!live) { + addRunCounts(counts, fileCounts); + reportFailedFile(fileNode, abortFailure(signal!), "cancelled", Date.now() - fileStarted, reporter, counts); + return; } + // The file has its verdict. An abort from here on only ends this wait. + await untilStopped(stop, drainStderr); } - if (carry.length > 0) handleStdoutLine(carry); - - await drainStderr; - const exitCode = await proc.exited; // A nonzero exit with no child-reported failures means the file itself died // (top-level throw); child-reported failures are already covered by the @@ -615,8 +661,10 @@ async function runOneFile( } addRunCounts(counts, fileCounts); } finally { - proc.kill(); - if (drainStderr !== undefined) await drainStderr.catch(() => {}); + // The abort listener ended the child if it ran. + if (live) proc.kill(); + live = false; + abortListener?.[Symbol.dispose](); } } diff --git a/test/js/node/test_runner/fixtures/run-controlled-child-fixture.ts b/test/js/node/test_runner/fixtures/run-controlled-child-fixture.ts new file mode 100644 index 000000000000..fe5dc26efa93 --- /dev/null +++ b/test/js/node/test_runner/fixtures/run-controlled-child-fixture.ts @@ -0,0 +1,379 @@ +// Runs run() of node:test over children that this script controls. Bun.spawn gives the runner a child whose pipes, +// exit and kill() a scenario holds, so each scenario puts the abort at one exact point of a file. +// Prints one JSON object: the result of each scenario, by name. +import { run } from "node:test"; + +type Stream = ReturnType; + +const encoder = new TextEncoder(); +const tick = () => new Promise(resolve => setImmediate(resolve)); + +// The runner takes an event of a child from a line of stdout that has this prefix. +const event = (type: string, data: object) => `\0bun:test:run\0${JSON.stringify({ type, data })}\n`; +const pass = (name: string) => event("test:pass", { name, nesting: 0, testNumber: 1, duration_ms: 0 }); + +function pipe() { + let controller!: ReadableStreamDefaultController; + let unread = false; + let open = true; + let closedByTheRunner = false; + const stream = new ReadableStream( + { + start: it => void (controller = it), + // The runner asks for a chunk, and no chunk waits. + pull: () => void (unread = false), + cancel: () => void ((open = false), (closedByTheRunner = true)), + }, + { highWaterMark: 0 }, + ); + return { + stream, + write(text: string) { + unread = true; + controller.enqueue(encoder.encode(text)); + }, + end() { + if (open) controller.close(); + open = false; + }, + /** True when the runner took each chunk and asked for the next one. */ + isRead: async () => (await tick(), !unread), + closedByTheRunner: () => closedByTheRunner, + }; +} + +function makeChild() { + const exit = Promise.withResolvers(); + const stdout = pipe(); + const stderr = pipe(); + const child = { + stdout, + stderr, + kills: 0, + /** The child exits. Each pipe stays open, as when a process of the child holds it. */ + exit: (code: number) => exit.resolve(code), + /** The child closes each pipe, then exits. */ + end(code: number) { + stdout.end(); + stderr.end(); + exit.resolve(code); + }, + /** What Bun.spawn returns. kill() does not end this child. */ + subprocess: { stdout: stdout.stream, stderr: stderr.stream, exited: exit.promise, kill: () => void child.kills++ }, + }; + return child; +} +type Child = ReturnType; + +let spawns = 0; +let lastChild: Child | undefined; +let spawnFollowsTheSignal = false; +let spawnError: Error | undefined; +let onSpawn: ((child: Child) => void) | undefined; +const nextChild = () => new Promise(resolve => (onSpawn = resolve)); + +Bun.spawn = ((options: { signal?: AbortSignal }) => { + if (spawnError) { + const error = spawnError; + spawnError = undefined; + throw error; + } + spawns++; + spawnFollowsTheSignal ||= options.signal !== undefined; + const child = (lastChild = makeChild()); + onSpawn?.(child); + return child.subprocess; +}) as any; + +let decodes = 0; +const decode = TextDecoder.prototype.decode; +TextDecoder.prototype.decode = function (this: TextDecoder, ...args: Parameters) { + decodes++; + return decode.apply(this, args); +}; + +/** The number of "abort" listeners that the runner has on the signal. */ +function countListeners(signal: AbortSignal) { + let count = 0; + const { addEventListener, removeEventListener } = signal; + signal.addEventListener = function (this: AbortSignal, ...args: any) { + count++; + return addEventListener.apply(this, args); + }; + signal.removeEventListener = function (this: AbortSignal, ...args: any) { + count--; + return removeEventListener.apply(this, args); + }; + return () => count; +} + +/** Records what the runner publishes, at the time that it publishes it. `isReason`: the error is `reason` itself. */ +function observe(stream: Stream, reason?: unknown) { + const types: string[] = []; + const stderr: string[] = []; + const errors: any[] = []; + const failed: { name: string; failureType: unknown; isReason: boolean }[] = []; + let summary: { success: boolean; counts: Record } | undefined; + let plan: number | undefined; + let destroyedWith: string | undefined; + let mark = 0; + + const emit = stream.emit; + stream.emit = function (this: Stream, type: string, data: any) { + if (type.startsWith("test:")) types.push(type); + if (type === "test:stderr") stderr.push(data.message); + if (type === "test:plan" && data.file === undefined) plan = data.count; + if (type === "test:summary" && data.file === undefined) summary = data; + if (type === "test:fail") { + const error = data.details.error; + errors.push(error); + failed.push({ name: data.name, failureType: error?.failureType, isReason: error === reason }); + } + return emit.apply(this, arguments as any); + } as any; + + const closed = new Promise(resolve => stream.on("close", resolve)); + stream.on("error", error => (destroyedWith = error?.message)); + stream.resume(); + + return { + stream, + /** Fulfills when the stream ended or was destroyed. */ + closed, + /** As `closed`, then one more turn: the readers of the runner had the time to publish more. */ + settled: () => closed.then(tick), + stderr, + errors, + plan: () => plan, + destroyedWith: () => destroyedWith, + /** From here on, `published()` lists the types of the events. */ + mark: () => void (mark = types.length), + published: () => types.slice(mark), + /** True when the run has its end now: no event of a child and no exit of a child is necessary for it. */ + endedNow: async () => (await tick(), summary !== undefined || destroyedWith !== undefined), + outcome() { + const { tests, passed, failed: failures, cancelled } = summary?.counts ?? {}; + return { success: summary?.success, counts: { tests, passed, failed: failures, cancelled }, failed }; + }, + }; +} + +const results: Record = {}; + +async function scenario(name: string, body: () => Promise) { + spawns = 0; + lastChild = undefined; + spawnFollowsTheSignal = false; + results[name] = await body(); +} + +await scenario("the abort comes while the child runs", async () => { + const controller = new AbortController(); + const reason = new Error("stop"); + const spawned = nextChild(); + const watch = observe(run({ files: ["a.js"], signal: controller.signal }), reason); + const child = await spawned; + child.stdout.write(pass("before the abort")); + await child.stdout.isRead(); + + watch.mark(); + const decodesBefore = decodes; + controller.abort(reason); + const endedAtAbort = await watch.endedNow(); + const publishedAfterAbort = watch.published(); + + // The child runs on, and writes to each pipe. + child.stdout.write(pass("after the abort")); + child.stderr.write("after the abort\n"); + const readsThePipes = (await child.stdout.isRead()) && (await child.stderr.isRead()); + const closedAPipe = child.stdout.closedByTheRunner() || child.stderr.closedByTheRunner(); + child.end(0); + await watch.settled(); + return { + endedAtAbort, + publishedAfterAbort, + publishedAfterTheEnd: watch.published().slice(publishedAfterAbort.length), + outcome: watch.outcome(), + kills: child.kills, + spawnFollowsTheSignal, + decodedAfterAbort: decodes - decodesBefore, + readsThePipes, + closedAPipe, + }; +}); + +await scenario("a listener aborts in the middle of a chunk", async () => { + const controller = new AbortController(); + const reason = new Error("stop"); + const spawned = nextChild(); + const watch = observe(run({ files: ["a.js"], signal: controller.signal }), reason); + watch.stream.on("test:pass", data => { + if (data.name !== "the listener aborts here") return; + watch.mark(); + controller.abort(reason); + }); + const child = await spawned; + child.stdout.write( + "a line before\n" + pass("the listener aborts here") + "a line after\n" + pass("after the abort") + "no newline", + ); + + const endedAtAbort = await watch.endedNow(); + child.end(0); + await watch.settled(); + return { endedAtAbort, publishedAfterAbort: watch.published(), outcome: watch.outcome(), kills: child.kills }; +}); + +await scenario("the abort comes after stdout ended, and the child never exits", async () => { + const controller = new AbortController(); + const reason = new Error("stop"); + const spawned = nextChild(); + const watch = observe(run({ files: ["a.js"], signal: controller.signal }), reason); + const child = await spawned; + child.stdout.write(pass("passes")); + child.stdout.end(); + await tick(); + + watch.mark(); + controller.abort(reason); + const endedAtAbort = await watch.endedNow(); + const publishedAfterAbort = watch.published(); + child.end(0); + await watch.settled(); + return { endedAtAbort, publishedAfterAbort, outcome: watch.outcome(), kills: child.kills }; +}); + +await scenario("the abort comes after the child exited, while only stderr is open", async () => { + const controller = new AbortController(); + const reason = new Error("stop"); + const spawned = nextChild(); + const watch = observe(run({ files: ["a.js"], signal: controller.signal }), reason); + const child = await spawned; + child.stdout.write(pass("passes")); + child.stdout.end(); + child.exit(0); + await tick(); + + watch.mark(); + controller.abort(reason); + const endedAtAbort = await watch.endedNow(); + const publishedAfterAbort = watch.published(); + child.stderr.end(); + await watch.settled(); + return { endedAtAbort, publishedAfterAbort, outcome: watch.outcome(), kills: child.kills }; +}); + +for (const source of ["stdout", "stderr"] as const) { + for (const signal of ["with a signal", "without a signal"]) { + await scenario(`a test:${source} listener throws, ${signal}`, async () => { + const spawned = nextChild(); + const options = signal === "with a signal" ? { signal: new AbortController().signal } : {}; + const watch = observe(run({ files: ["a.js"], ...options })); + watch.stream.on(`test:${source}`, () => { + throw new Error("the listener throws"); + }); + const child = await spawned; + child[source].write("a line\n"); + + const endedAtThrow = await watch.endedNow(); + child.end(0); + await watch.settled(); + return { endedAtThrow, destroyedWith: watch.destroyedWith(), kills: child.kills }; + }); + } +} + +await scenario("the child of the first file does not start", async () => { + const cause = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }); + spawnError = cause; + const spawned = nextChild(); + const watch = observe(run({ files: ["a.js", "b.js"] })); + // Without a child of "b.js", the run ended at "a.js". + const child = await Promise.race([spawned, watch.closed]); + if (!child) return { destroyedWith: watch.destroyedWith() }; + child.stdout.write(pass("passes")); + child.end(0); + await watch.settled(); + const error = watch.errors[0]; + return { + outcome: watch.outcome(), + error: { code: error?.code, message: error?.message, causeIsTheSpawnError: error?.cause === cause }, + spawns, + }; +}); + +for (const signal of ["with a signal", "without a signal"]) { + await scenario(`the child writes to stderr and exits with code 1, ${signal}`, async () => { + const spawned = nextChild(); + const options = signal === "with a signal" ? { signal: new AbortController().signal } : {}; + const watch = observe(run({ files: ["a.js"], ...options })); + const child = await spawned; + for (const chunk of ["first li", "ne\n\nthird line\r\nlast line, ", "with no newline"]) { + child.stderr.write(chunk); + await child.stderr.isRead(); + } + child.end(1); + await watch.settled(); + return { published: watch.stderr, outcome: watch.outcome(), message: watch.errors[0]?.message, kills: child.kills }; + }); +} + +await scenario("the abort comes after the file ended", async () => { + const controller = new AbortController(); + const reason = new Error("stop"); + const listeners = countListeners(controller.signal); + let spawned = nextChild(); + const watch = observe(run({ files: ["a.js", "b.js"], signal: controller.signal }), reason); + const first = await spawned; + spawned = nextChild(); + first.stdout.write(pass("passes")); + first.end(0); + const second = await spawned; + const whileTheSecondFileRuns = listeners(); + + controller.abort(reason); + const endedAtAbort = await watch.endedNow(); + second.end(0); + await watch.settled(); + return { + endedAtAbort, + outcome: watch.outcome(), + killsOfTheFirstChild: first.kills, + killsOfTheSecondChild: second.kills, + abortListeners: { whileTheSecondFileRuns, afterTheRun: listeners() }, + }; +}); + +await scenario("the signal is aborted before run(), with three files", async () => { + const reason = new Error("stop"); + const watch = observe(run({ files: ["a.js", "b.js", "c.js"], signal: AbortSignal.abort(reason) }), reason); + await watch.settled(); + return { outcome: watch.outcome(), plan: watch.plan(), spawns }; +}); + +await scenario("a test:dequeue listener aborts", async () => { + const controller = new AbortController(); + const reason = new Error("stop"); + const watch = observe(run({ files: ["a.js"], signal: controller.signal }), reason); + watch.stream.once("test:dequeue", () => controller.abort(reason)); + const endedAtAbort = await watch.endedNow(); + lastChild?.end(0); + await watch.settled(); + return { endedAtAbort, outcome: watch.outcome(), spawns }; +}); + +for (const [name, reason] of [ + ["the reason is a string", "stop"], + ["the reason is a frozen object", Object.freeze(new Error("stop"))], +] as const) { + await scenario(name, async () => { + const watch = observe(run({ files: ["a.js"], signal: AbortSignal.abort(reason) }), reason); + await watch.settled(); + const error = watch.errors[0]; + return { + outcome: watch.outcome(), + error: { name: error?.name, message: error?.message, causeIsTheReason: error?.cause === reason }, + }; + }); +} + +console.log(JSON.stringify(results, null, 2)); diff --git a/test/js/node/test_runner/node-test-run-signal.test.ts b/test/js/node/test_runner/node-test-run-signal.test.ts new file mode 100644 index 000000000000..5a95344fe7b7 --- /dev/null +++ b/test/js/node/test_runner/node-test-run-signal.test.ts @@ -0,0 +1,345 @@ +// This file uses node:test and node:assert, because the last test runs it under Node.js too. +import assert from "node:assert"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, run, test } from "node:test"; + +const isBun = typeof Bun !== "undefined"; +const isPosix = process.platform !== "win32"; +const harness = isBun ? await import("harness") : undefined; +const node = harness?.nodeExeAtLeast("26.3.0") ?? null; +const env: Record = harness?.bunEnv ?? process.env; +// A child of the debug build takes seconds to start. +const timeout = 30_000; + +// Each long test of a fixture ends after a minute. A child that a failed run leaves behind ends then, too. +const fixtures = { + "quick.js": ` + require("node:test").test("quick", () => {}); + `, + "late.js": ` + require("node:fs").writeFileSync(process.env.STARTED, "started"); + require("node:test").test("late", () => {}); + `, + // SIGTERM ends this child: its "exit" listener does not run. + "dies.js": ` + const { writeFileSync } = require("node:fs"); + const { test } = require("node:test"); + writeFileSync(process.env.PID, String(process.pid)); + process.on("exit", code => writeFileSync(process.env.EXITED, String(code))); + test("passes", () => {}); + test("runs on", { timeout: 60_000 }, () => new Promise(resolve => setTimeout(resolve, 60_000))); + `, + // SIGTERM lets the one test of this child pass: the child exits with code 0. + "trap.js": ` + const { test } = require("node:test"); + let end; + const ended = new Promise(resolve => (end = resolve)); + process.on("SIGTERM", end); + console.log("ready"); + test("runs until SIGTERM", { timeout: 60_000 }, async () => { + const limit = setTimeout(end, 60_000); + await ended; + clearTimeout(limit); + }); + `, + // SIGTERM makes this child write more than a pipe holds, to each pipe. The child runs on until SIGUSR2. + "held.js": ` + const { writeFileSync } = require("node:fs"); + const { test } = require("node:test"); + let end; + const ended = new Promise(resolve => (end = resolve)); + process.on("SIGUSR2", end); + process.once("SIGTERM", async () => { + const block = Buffer.alloc(64 * 1024, "more than a pipe holds\\n"); + for (let i = 0; i < 40; i++) { + await new Promise(resolve => process.stdout.write(block, resolve)); + await new Promise(resolve => process.stderr.write(block, resolve)); + } + writeFileSync(process.env.WROTE, "all"); + }); + process.on("exit", code => writeFileSync(process.env.EXITED, String(code))); + writeFileSync(process.env.PID, String(process.pid)); + console.log("ready"); + test("runs until SIGUSR2", { timeout: 60_000 }, async () => { + const limit = setTimeout(end, 60_000); + await ended; + clearTimeout(limit); + }); + `, +}; + +const tempDir = harness?.tempDir("node-test-run-signal", fixtures); +const dir = tempDir ? String(tempDir) : mkdtempSync(join(tmpdir(), "node-test-run-signal_")); +if (!tempDir) { + for (const [name, source] of Object.entries(fixtures)) writeFileSync(join(dir, name), source); +} +after(() => (tempDir ? tempDir[Symbol.dispose]() : rmSync(dir, { recursive: true, force: true }))); + +let markers = 0; +/** The path of a file that a fixture writes. The path is new for each call. */ +function marker(name: string) { + return join(dir, `${name}-${++markers}`); +} + +function textOf(path: string) { + return existsSync(path) ? readFileSync(path, "utf8") : ""; +} + +function pidOf(path: string) { + return Number(textOf(path)); +} + +function isRunning(pid: number) { + // process.kill(0) and process.kill(-1) signal more than one process. + assert(pid > 0, `a child wrote its pid: ${pid}`); + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** The timeout of the test is the deadline. */ +async function until(condition: () => boolean) { + while (!condition()) await new Promise(resolve => setTimeout(resolve, 10)); +} + +/** Aborts when the child of a "trap.js" or a "held.js" has its signal listeners. */ +function abortWhenReady(stream: ReturnType, controller: AbortController, reason?: unknown) { + stream.on("test:stdout", ({ message }: { message: string }) => { + if (message.includes("ready")) controller.abort(reason); + }); +} + +/** The verdict of the run and each failed test. `isReason` says that the error is `reason` itself. */ +function outcomeOf(stream: ReturnType, reason?: unknown) { + const failed: { name: string; failureType: unknown; isReason: boolean }[] = []; + let summary: { success: boolean; counts: Record } | undefined; + stream.on("test:fail", (data: any) => { + const error = data.details.error; + failed.push({ name: data.name, failureType: error?.failureType, isReason: error === reason }); + }); + stream.on("test:summary", (data: any) => { + if (data.file === undefined) summary = data; + }); + return new Promise<{ success: unknown; counts: unknown; failed: typeof failed }>((resolve, reject) => { + stream.on("error", reject); + stream.on("end", () => { + const { tests, passed, failed: failures, cancelled } = summary?.counts ?? {}; + resolve({ success: summary?.success, counts: { tests, passed, failed: failures, cancelled }, failed }); + }); + stream.resume(); + }); +} + +test("cancels a file whose child handles SIGTERM and exits with code 0", { skip: !isPosix, timeout }, async () => { + const controller = new AbortController(); + const reason = new Error("stop"); + const stream = run({ files: ["trap.js"], cwd: dir, env, signal: controller.signal }); + abortWhenReady(stream, controller, reason); + + assert.deepStrictEqual(await outcomeOf(stream, reason), { + success: false, + counts: { tests: 1, passed: 0, failed: 0, cancelled: 1 }, + failed: [{ name: "trap.js", failureType: "testAborted", isReason: true }], + }); +}); + +test("cancels the last file, after a file that passed", { skip: !isPosix, timeout }, async () => { + const controller = new AbortController(); + const reason = new Error("stop"); + const stream = run({ files: ["quick.js", "trap.js"], cwd: dir, env, signal: controller.signal }); + abortWhenReady(stream, controller, reason); + + assert.deepStrictEqual(await outcomeOf(stream, reason), { + success: false, + counts: { tests: 2, passed: 1, failed: 0, cancelled: 1 }, + failed: [{ name: "trap.js", failureType: "testAborted", isReason: true }], + }); +}); + +test("ends the child of the running file, and starts no later file", { timeout }, async () => { + const pid = marker("pid"); + const exited = marker("exited"); + const started = marker("started"); + const controller = new AbortController(); + const reason = new Error("stop"); + // A listener of the caller's program cannot hide the abort from the runner. + controller.signal.addEventListener("abort", event => event.stopImmediatePropagation()); + const stream = run({ + files: ["dies.js", "late.js"], + cwd: dir, + env: { ...env, PID: pid, EXITED: exited, STARTED: started }, + signal: controller.signal, + }); + stream.on("test:pass", (data: any) => { + if (data.name === "passes") controller.abort(reason); + }); + + const outcome = await outcomeOf(stream, reason); + const child = pidOf(pid); + await until(() => !isRunning(child)); + assert.deepStrictEqual( + { ...outcome, childExitedByItself: existsSync(exited), laterFileStarted: existsSync(started) }, + { + success: false, + counts: { tests: 3, passed: 1, failed: 0, cancelled: 2 }, + failed: ["dies.js", "late.js"].map(name => ({ name, failureType: "testAborted", isReason: true })), + childExitedByItself: false, + laterFileStarted: false, + }, + ); +}); + +for (const when of ["before run()", "in setup()", "in a test:enqueue listener", "in a test:dequeue listener"]) { + test(`cancels a file and starts no child when the signal aborts ${when}`, async () => { + const started = marker("started"); + const controller = new AbortController(); + const reason = new Error("stop"); + const abort = () => controller.abort(reason); + if (when === "before run()") abort(); + const stream = run({ + files: ["late.js"], + cwd: dir, + env: { ...env, STARTED: started }, + signal: controller.signal, + setup: when === "in setup()" ? abort : undefined, + }); + if (when === "in a test:enqueue listener") stream.once("test:enqueue", abort); + if (when === "in a test:dequeue listener") stream.once("test:dequeue", abort); + + assert.deepStrictEqual( + { ...(await outcomeOf(stream, reason)), fileStarted: existsSync(started) }, + { + success: false, + counts: { tests: 1, passed: 0, failed: 0, cancelled: 1 }, + failed: [{ name: "late.js", failureType: "testAborted", isReason: true }], + fileStarted: false, + }, + ); + }); +} + +test("fails the file with an AbortError when the reason of the abort is null", async () => { + const stream = run({ + files: ["late.js"], + cwd: dir, + env: { ...env, STARTED: marker("started") }, + signal: AbortSignal.abort(null), + }); + let error: any; + stream.on("test:fail", (data: any) => (error = data.details.error)); + await outcomeOf(stream); + + assert.deepStrictEqual( + { + name: error.name, + code: error.code, + message: error.message, + failureType: error.failureType, + hasCause: "cause" in error, + }, + { + name: "AbortError", + code: "ABORT_ERR", + message: "The test was aborted", + failureType: "testAborted", + hasCause: false, + }, + ); +}); + +test( + "ends the stream at the abort, and reads the pipes of a child that runs on", + { skip: !isPosix, timeout }, + async () => { + const pid = marker("pid"); + const wrote = marker("wrote"); + const exited = marker("exited"); + const controller = new AbortController(); + const stream = run({ + files: ["held.js"], + cwd: dir, + env: { ...env, PID: pid, WROTE: wrote, EXITED: exited }, + signal: controller.signal, + }); + let ended = false; + let endedAtAbort: boolean | undefined; + let childRunsAtEnd: boolean | undefined; + stream.on("test:stdout", ({ message }: { message: string }) => { + if (!message.includes("ready")) return; + controller.abort(); + // The run ends in the promise jobs of the abort: no event of a child is necessary. + setImmediate(() => (endedAtAbort = ended)); + }); + stream.on("test:summary", (data: any) => { + if (data.file !== undefined) return; + ended = true; + childRunsAtEnd = isRunning(pidOf(pid)); + }); + const outcome = outcomeOf(stream); + + // The child blocks here if the runner does not read both pipes to their end. + await until(() => textOf(wrote) !== ""); + const childWrote = textOf(wrote); + process.kill(pidOf(pid), "SIGUSR2"); + await until(() => textOf(exited) !== ""); + assert.deepStrictEqual( + { endedAtAbort, childRunsAtEnd, childWrote, childExitCode: textOf(exited), success: (await outcome).success }, + { endedAtAbort: true, childRunsAtEnd: true, childWrote: "all", childExitCode: "0", success: false }, + ); + }, +); + +test("keeps the verdict of a run whose signal aborts after the last file ended", { timeout }, async () => { + const controller = new AbortController(); + const stream = run({ files: ["quick.js"], cwd: dir, env, signal: controller.signal }); + stream.on("test:plan", (data: any) => { + if (data.file === undefined) controller.abort(); + }); + + assert.deepStrictEqual(await outcomeOf(stream), { + success: true, + counts: { tests: 1, passed: 1, failed: 0, cancelled: 0 }, + failed: [], + }); +}); + +test( + "cancels a file when a listener aborts at the summary of that file", + { timeout, todo: isBun && "Bun makes the summary of a file after the child of the file exited" }, + async () => { + const controller = new AbortController(); + const reason = new Error("stop"); + const stream = run({ files: ["quick.js"], cwd: dir, env, signal: controller.signal }); + stream.on("test:summary", (data: any) => { + if (data.file !== undefined) controller.abort(reason); + }); + + assert.deepStrictEqual(await outcomeOf(stream, reason), { + success: false, + counts: { tests: 2, passed: 1, failed: 0, cancelled: 1 }, + failed: [{ name: "quick.js", failureType: "testAborted", isReason: true }], + }); + }, +); + +test("Node.js passes each test of this file", { skip: !node, timeout: 120_000 }, async () => { + await using proc = Bun.spawn({ + cmd: [node!, "--test-reporter=tap", import.meta.filename], + env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const verdict = stdout.split("\n").filter(line => /^# (fail|cancelled) /.test(line)); + assert.deepStrictEqual( + { verdict, exitCode }, + { verdict: ["# fail 0", "# cancelled 0"], exitCode: 0 }, + `Node.js does not pass this file:\n${stdout}\n${stderr}`, + ); +}); diff --git a/test/js/node/test_runner/node-test.test.ts b/test/js/node/test_runner/node-test.test.ts index 2a27963203c0..fa8caa3bb046 100644 --- a/test/js/node/test_runner/node-test.test.ts +++ b/test/js/node/test_runner/node-test.test.ts @@ -1,7 +1,8 @@ import { spawn } from "bun"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; import { join } from "node:path"; +import { run } from "node:test"; describe("node:test", () => { // These three drive the largest fixtures (01-harness has 32 node:test cases); @@ -521,3 +522,146 @@ test("mock.property/mock.method survive a polluted Object.prototype", async () = const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout: stdout.trim(), stderr, exitCode }).toMatchObject({ stdout: "ok", exitCode: 0 }); }); + +describe("node:test run() signal", () => { + // What the runner publishes for a file that the signal cancelled, then for the end of the run. + const verdictThenEnd = [ + "test:complete", + "test:fail", + "test:plan", + ...Array.from({ length: 8 }, () => "test:diagnostic"), + "test:summary", + ]; + const passedThenEnd = ["test:summary", ...verdictThenEnd.slice(2)]; + + // Node.js has the same rule, but its result here depends on how the pipe splits the output of the child. + test("publishes nothing of the child after a listener aborts, not even the rest of a line", async () => { + using dir = tempDir("node-test-run-prefix", { + "prefix.js": ` + process.stdout.write("prefix"); + require("node:test").test("its event follows the prefix on one line", () => {}); + `, + }); + const controller = new AbortController(); + const stream = run({ files: ["prefix.js"], cwd: String(dir), env: bunEnv, signal: controller.signal }); + const publishedAfterAbort: string[] = []; + const emit = stream.emit; + stream.emit = function (this: typeof stream, type: string, data: any) { + if (controller.signal.aborted && type.startsWith("test:")) publishedAfterAbort.push(type); + const result = emit.apply(this, arguments as any); + if (type === "test:stdout" && data.message === "prefix\n") controller.abort(); + return result; + } as any; + const { promise: ended, resolve, reject } = Promise.withResolvers(); + stream.on("end", resolve).on("error", reject).resume(); + await ended; + + expect(publishedAfterAbort).toEqual(verdictThenEnd); + }, 30_000); + + test("ends a file at the abort, whatever the child of the file does", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dirname, "fixtures", "run-controlled-child-fixture.ts")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const cancelled = (name: string, tests: number) => ({ + success: false, + counts: { tests, passed: tests - 1, failed: 0, cancelled: 1 }, + failed: [{ name, failureType: "testAborted", isReason: true }], + }); + const passed = { success: true, counts: { tests: 1, passed: 1, failed: 0, cancelled: 0 }, failed: [] }; + const failedWithStderr = { + published: ["first line\n", "third line\r\n", "last line, with no newline\n"], + outcome: { + success: false, + counts: { tests: 1, passed: 0, failed: 1, cancelled: 0 }, + failed: [{ name: "a.js", failureType: "testCodeFailure", isReason: false }], + }, + message: "first line\n\nthird line\r\nlast line, with no newline", + kills: 1, + }; + const destroyed = (endedAtThrow: boolean) => ({ endedAtThrow, destroyedWith: "the listener throws", kills: 1 }); + const wrapped = { + outcome: { + success: false, + counts: { tests: 1, passed: 0, failed: 0, cancelled: 1 }, + failed: [{ name: "a.js", failureType: "testAborted", isReason: false }], + }, + error: { name: "AbortError", message: "The test was aborted", causeIsTheReason: true }, + }; + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + "the abort comes while the child runs": { + endedAtAbort: true, + publishedAfterAbort: verdictThenEnd, + publishedAfterTheEnd: [], + outcome: cancelled("a.js", 2), + kills: 1, + spawnFollowsTheSignal: false, + decodedAfterAbort: 0, + readsThePipes: true, + closedAPipe: false, + }, + "a listener aborts in the middle of a chunk": { + endedAtAbort: true, + publishedAfterAbort: verdictThenEnd, + outcome: cancelled("a.js", 2), + kills: 1, + }, + "the abort comes after stdout ended, and the child never exits": { + endedAtAbort: true, + publishedAfterAbort: verdictThenEnd, + outcome: cancelled("a.js", 2), + kills: 1, + }, + // Node.js ends a file at the exit of its child and the end of stdout. An abort after that leaves the verdict. + "the abort comes after the child exited, while only stderr is open": { + endedAtAbort: true, + publishedAfterAbort: passedThenEnd, + outcome: passed, + kills: 1, + }, + "a test:stdout listener throws, with a signal": destroyed(true), + "a test:stdout listener throws, without a signal": destroyed(true), + "a test:stderr listener throws, with a signal": destroyed(true), + // Without a signal, the runner waits for the end of stdout first. + "a test:stderr listener throws, without a signal": destroyed(false), + "the child of the first file does not start": { + outcome: { + success: false, + counts: { tests: 2, passed: 1, failed: 1, cancelled: 0 }, + failed: [{ name: "a.js", failureType: "testCodeFailure", isReason: false }], + }, + error: { code: "ERR_TEST_FAILURE", message: "spawn ENOENT", causeIsTheSpawnError: true }, + spawns: 1, + }, + "the child writes to stderr and exits with code 1, with a signal": failedWithStderr, + "the child writes to stderr and exits with code 1, without a signal": failedWithStderr, + "the abort comes after the file ended": { + endedAtAbort: true, + outcome: cancelled("b.js", 2), + killsOfTheFirstChild: 1, + killsOfTheSecondChild: 1, + abortListeners: { whileTheSecondFileRuns: 1, afterTheRun: 0 }, + }, + "the signal is aborted before run(), with three files": { + outcome: { + success: false, + counts: { tests: 3, passed: 0, failed: 0, cancelled: 3 }, + failed: ["a.js", "b.js", "c.js"].map(name => ({ name, failureType: "testAborted", isReason: true })), + }, + plan: 3, + spawns: 0, + }, + "a test:dequeue listener aborts": { endedAtAbort: true, outcome: cancelled("a.js", 1), spawns: 0 }, + "the reason is a string": wrapped, + "the reason is a frozen object": wrapped, + }); + expect(exitCode).toBe(0); + }, 30_000); +}); From b8f31da45b94af0af820668aeec2f229d231d9b3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:07:34 +0000 Subject: [PATCH 2/5] node:test: keep the error of a run() listener that aborts and then throws A listener that aborted the signal and then threw in the same call lost its error: the abort ended the wait first, and the file was reported as cancelled. The error is now recorded when the listener throws, and it is the result of the run, as without the abort. The details of the test:complete event of a cancelled or failed file have no prototype, as every other details object of the stream. The race helper is now firstOf. --- src/js/node/test.ts | 45 ++++++++++++++----- .../fixtures/run-controlled-child-fixture.ts | 37 +++++++++++++++ .../test_runner/node-test-run-signal.test.ts | 4 +- test/js/node/test_runner/node-test.test.ts | 4 ++ 4 files changed, 76 insertions(+), 14 deletions(-) diff --git a/src/js/node/test.ts b/src/js/node/test.ts index 08f5cc397d36..61632f482d48 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -440,7 +440,7 @@ function reportFailedFile( ...fileNode, type: undefined, testNumber: 1, - details: { ...details, passed: false }, + details: { __proto__: null, ...details, passed: false }, }); reporter.fail({ __proto__: null, ...fileNode, type: undefined, testNumber: 1, details }); counts.tests++; @@ -449,7 +449,7 @@ function reportFailedFile( } // Promise.race([stop, awaited]) without the public `resolve` and `then`, which the caller's program can replace. -function untilStopped(stop: Promise, awaited: Promise): Promise { +function firstOf(stop: Promise, awaited: Promise): Promise { const first = $newPromise(); const resolve = (value: T | undefined) => $resolvePromiseWithFirstResolvingFunctionCallCheck(first, value); const reject = (err: unknown) => $rejectPromiseWithFirstResolvingFunctionCallCheck(first, err); @@ -458,6 +458,16 @@ function untilStopped(stop: Promise, awaited: Promise): Promise return first; } +// An error of a listener goes to `onError` at once: the reader that publishes rejects only after it closed its pipe. +function publishLine(publish: (line: string) => void, line: string, onError: (err: unknown) => void) { + try { + publish(line); + } catch (err) { + onError(err); + throw err; + } +} + async function runOneFile( file: string, opts: ReturnType, @@ -515,9 +525,12 @@ async function runOneFile( // False after the abort, and after this function: nothing more of the child is published. let live = true; - // Ends a wait for the child: it fulfills at the abort and rejects when the stderr reader fails. + // Ends a wait for the child: it fulfills at the abort and rejects when a reader fails. let stop: Promise | undefined; let abortListener: Disposable | undefined; + // The first error of a reader. A listener of the caller that throws is one. + let readerFailed = false; + let readerError: unknown; try { if (signal !== undefined) { @@ -531,6 +544,14 @@ async function runOneFile( }); } + // Only a run with a signal has a wait that the error of a reader can end. + const onReaderError = (err: unknown) => { + if (readerFailed) return; + readerFailed = true; + readerError = err; + if (stop !== undefined) $rejectPromiseWithFirstResolvingFunctionCallCheck(stop, err); + }; + // Reads one pipe of the child to its end, and publishes each line while the file is live. const readLines = async (pipe: unknown, publish: (line: string) => void) => { const decoder = new TextDecoder(); @@ -543,10 +564,10 @@ async function runOneFile( while (live && (nl = carry.indexOf("\n")) !== -1) { const line = carry.slice(0, nl); carry = carry.slice(nl + 1); - publish(line); + publishLine(publish, line, onReaderError); } } - if (live && carry.length > 0) publish(carry); + if (live && carry.length > 0) publishLine(publish, carry, onReaderError); }; let stderrText = ""; @@ -554,10 +575,7 @@ async function runOneFile( stderrText += line + "\n"; if (line.length > 0) reporter.stderr({ __proto__: null, file: absolute, message: line + "\n" }); }); - // A test:stderr listener that throws fails this reader. Only a run with a signal has a wait that this can end. - drainStderr.$then(undefined, (err: unknown) => { - if (stop !== undefined) $rejectPromiseWithFirstResolvingFunctionCallCheck(stop, err); - }); + drainStderr.$then(undefined, onReaderError); const handleStdoutLine = (line: string) => { if (line.length === 0) return; @@ -594,15 +612,18 @@ async function runOneFile( exitCode = await proc.exited; } else { // Node's file test ends at child exit and stdout end: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/test_runner/runner.js#L557-L560 - await untilStopped(stop, drainStdout); - exitCode = await untilStopped(stop, proc.exited); + await firstOf(stop, drainStdout); + exitCode = await firstOf(stop, proc.exited); + // A listener that aborts and then throws ends the wait with the abort. Its error is still the result. + if (readerFailed) throw readerError; if (!live) { addRunCounts(counts, fileCounts); reportFailedFile(fileNode, abortFailure(signal!), "cancelled", Date.now() - fileStarted, reporter, counts); return; } // The file has its verdict. An abort from here on only ends this wait. - await untilStopped(stop, drainStderr); + await firstOf(stop, drainStderr); + if (readerFailed) throw readerError; } // A nonzero exit with no child-reported failures means the file itself died diff --git a/test/js/node/test_runner/fixtures/run-controlled-child-fixture.ts b/test/js/node/test_runner/fixtures/run-controlled-child-fixture.ts index fe5dc26efa93..9fccfd790fab 100644 --- a/test/js/node/test_runner/fixtures/run-controlled-child-fixture.ts +++ b/test/js/node/test_runner/fixtures/run-controlled-child-fixture.ts @@ -107,6 +107,9 @@ function countListeners(signal: AbortSignal) { return () => count; } +/** Each `details` object of an event that inherits from Object.prototype, over all scenarios. */ +const detailsWithPrototype: string[] = []; + /** Records what the runner publishes, at the time that it publishes it. `isReason`: the error is `reason` itself. */ function observe(stream: Stream, reason?: unknown) { const types: string[] = []; @@ -121,6 +124,8 @@ function observe(stream: Stream, reason?: unknown) { const emit = stream.emit; stream.emit = function (this: Stream, type: string, data: any) { if (type.startsWith("test:")) types.push(type); + if (data?.details && Object.getPrototypeOf(data.details) !== null) + detailsWithPrototype.push(`${type} ${data.name}`); if (type === "test:stderr") stderr.push(data.message); if (type === "test:plan" && data.file === undefined) plan = data.count; if (type === "test:summary" && data.file === undefined) summary = data; @@ -282,6 +287,36 @@ for (const source of ["stdout", "stderr"] as const) { } } +// The error of the listener is the result, as without the abort. +for (const [name, source, afterExit] of [ + ["a test:stdout listener aborts, then throws", "stdout", false], + ["a test:stderr listener aborts, then throws", "stderr", false], + ["a test:stderr listener aborts, then throws, after the child exited", "stderr", true], +] as const) { + await scenario(name, async () => { + const controller = new AbortController(); + const spawned = nextChild(); + const watch = observe(run({ files: ["a.js"], signal: controller.signal })); + watch.stream.on(`test:${source}`, () => { + controller.abort(); + throw new Error("the listener throws"); + }); + const child = await spawned; + if (afterExit) { + child.stdout.write(pass("passes")); + child.stdout.end(); + child.exit(0); + await tick(); + } + child[source].write("a line\n"); + + const endedAtThrow = await watch.endedNow(); + child.end(0); + await watch.settled(); + return { endedAtThrow, destroyedWith: watch.destroyedWith(), kills: child.kills }; + }); +} + await scenario("the child of the first file does not start", async () => { const cause = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }); spawnError = cause; @@ -376,4 +411,6 @@ for (const [name, reason] of [ }); } +results["details objects that inherit from Object.prototype"] = detailsWithPrototype; + console.log(JSON.stringify(results, null, 2)); diff --git a/test/js/node/test_runner/node-test-run-signal.test.ts b/test/js/node/test_runner/node-test-run-signal.test.ts index 5a95344fe7b7..d57e0502791d 100644 --- a/test/js/node/test_runner/node-test-run-signal.test.ts +++ b/test/js/node/test_runner/node-test-run-signal.test.ts @@ -10,7 +10,7 @@ const isPosix = process.platform !== "win32"; const harness = isBun ? await import("harness") : undefined; const node = harness?.nodeExeAtLeast("26.3.0") ?? null; const env: Record = harness?.bunEnv ?? process.env; -// A child of the debug build takes seconds to start. +// A child of the debug build takes seconds to start. A test that ends too early leaves its run behind. const timeout = 30_000; // Each long test of a fixture ends after a minute. A child that a failed run leaves behind ends then, too. @@ -327,7 +327,7 @@ test( }, ); -test("Node.js passes each test of this file", { skip: !node, timeout: 120_000 }, async () => { +test("Node.js passes each test of this file", { skip: !node, timeout }, async () => { await using proc = Bun.spawn({ cmd: [node!, "--test-reporter=tap", import.meta.filename], env, diff --git a/test/js/node/test_runner/node-test.test.ts b/test/js/node/test_runner/node-test.test.ts index fa8caa3bb046..c86b705832c4 100644 --- a/test/js/node/test_runner/node-test.test.ts +++ b/test/js/node/test_runner/node-test.test.ts @@ -631,6 +631,9 @@ describe("node:test run() signal", () => { "a test:stderr listener throws, with a signal": destroyed(true), // Without a signal, the runner waits for the end of stdout first. "a test:stderr listener throws, without a signal": destroyed(false), + "a test:stdout listener aborts, then throws": destroyed(true), + "a test:stderr listener aborts, then throws": destroyed(true), + "a test:stderr listener aborts, then throws, after the child exited": destroyed(true), "the child of the first file does not start": { outcome: { success: false, @@ -661,6 +664,7 @@ describe("node:test run() signal", () => { "a test:dequeue listener aborts": { endedAtAbort: true, outcome: cancelled("a.js", 1), spawns: 0 }, "the reason is a string": wrapped, "the reason is a frozen object": wrapped, + "details objects that inherit from Object.prototype": [], }); expect(exitCode).toBe(0); }, 30_000); From 6ec9b6de99bb2e5c5bc88f15b9f0b4850ee92799 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:01:54 +0000 Subject: [PATCH 3/5] node:test: give a run() listener's error the same timing with and without a signal The error of a test:stderr listener waits for the end of stdout, as it does without a signal. With a signal it ended the child in the middle of its run, and the rest of the child's results was not published. The details of the test:complete event of a cancelled or failed file are written out. The spread of an object that has __proto__ did not type check (TS2783). The tests that Node.js passes too are a fixture now. node-test.test.ts runs the fixture with Bun and with Node.js, so there is no second test file, and the test file imports harness statically. --- src/js/node/test.ts | 16 +-- .../30-run-signal.js} | 100 +++++++----------- .../fixtures/run-controlled-child-fixture.ts | 63 +++++++++-- test/js/node/test_runner/node-test.test.ts | 55 +++++++++- 4 files changed, 158 insertions(+), 76 deletions(-) rename test/js/node/test_runner/{node-test-run-signal.test.ts => fixtures/30-run-signal.js} (76%) diff --git a/src/js/node/test.ts b/src/js/node/test.ts index 61632f482d48..fd0e024bad70 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -434,14 +434,14 @@ function reportFailedFile( reporter: TestsStream, counts: Record, ) { - const details = { __proto__: null, duration_ms: durationMs, type: "test", error }; reporter.complete({ __proto__: null, ...fileNode, type: undefined, testNumber: 1, - details: { __proto__: null, ...details, passed: false }, + details: { __proto__: null, duration_ms: durationMs, type: "test", passed: false, error }, }); + const details = { __proto__: null, duration_ms: durationMs, type: "test", error }; reporter.fail({ __proto__: null, ...fileNode, type: undefined, testNumber: 1, details }); counts.tests++; counts[count]++; @@ -525,12 +525,14 @@ async function runOneFile( // False after the abort, and after this function: nothing more of the child is published. let live = true; - // Ends a wait for the child: it fulfills at the abort and rejects when a reader fails. + // Ends a wait for the child: it fulfills at the abort, and it rejects with the error of a reader. let stop: Promise | undefined; let abortListener: Disposable | undefined; // The first error of a reader. A listener of the caller that throws is one. let readerFailed = false; let readerError: unknown; + // True after the wait for stdout. From then on the error of a reader ends a wait at once, as without a signal. + let afterStdout = false; try { if (signal !== undefined) { @@ -544,12 +546,11 @@ async function runOneFile( }); } - // Only a run with a signal has a wait that the error of a reader can end. const onReaderError = (err: unknown) => { if (readerFailed) return; readerFailed = true; readerError = err; - if (stop !== undefined) $rejectPromiseWithFirstResolvingFunctionCallCheck(stop, err); + if (afterStdout) $rejectPromiseWithFirstResolvingFunctionCallCheck(stop!, err); }; // Reads one pipe of the child to its end, and publishes each line while the file is live. @@ -575,6 +576,7 @@ async function runOneFile( stderrText += line + "\n"; if (line.length > 0) reporter.stderr({ __proto__: null, file: absolute, message: line + "\n" }); }); + // This reader is the last that the file waits for. Its error has a handler from the start. drainStderr.$then(undefined, onReaderError); const handleStdoutLine = (line: string) => { @@ -613,8 +615,10 @@ async function runOneFile( } else { // Node's file test ends at child exit and stdout end: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/test_runner/runner.js#L557-L560 await firstOf(stop, drainStdout); + afterStdout = true; + // The stderr reader failed before stdout ended, or a listener aborted and then threw. The error is the result. + if (readerFailed) throw readerError; exitCode = await firstOf(stop, proc.exited); - // A listener that aborts and then throws ends the wait with the abort. Its error is still the result. if (readerFailed) throw readerError; if (!live) { addRunCounts(counts, fileCounts); diff --git a/test/js/node/test_runner/node-test-run-signal.test.ts b/test/js/node/test_runner/fixtures/30-run-signal.js similarity index 76% rename from test/js/node/test_runner/node-test-run-signal.test.ts rename to test/js/node/test_runner/fixtures/30-run-signal.js index d57e0502791d..44e2f41b5d01 100644 --- a/test/js/node/test_runner/node-test-run-signal.test.ts +++ b/test/js/node/test_runner/fixtures/30-run-signal.js @@ -1,20 +1,20 @@ -// This file uses node:test and node:assert, because the last test runs it under Node.js too. -import assert from "node:assert"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { after, run, test } from "node:test"; +// The tests of run({ signal }) that Node.js passes too. node-test.test.ts runs this file with Bun and with Node.js. +const assert = require("node:assert"); +const { existsSync, readFileSync, writeFileSync } = require("node:fs"); +const { join } = require("node:path"); +const { run, test } = require("node:test"); const isBun = typeof Bun !== "undefined"; const isPosix = process.platform !== "win32"; -const harness = isBun ? await import("harness") : undefined; -const node = harness?.nodeExeAtLeast("26.3.0") ?? null; -const env: Record = harness?.bunEnv ?? process.env; +// An empty directory, from the test that runs this file. The files of each run and their markers go there. +const dir = process.env.RUN_SIGNAL_DIR; +assert(dir, "RUN_SIGNAL_DIR is an empty directory"); +const env = process.env; // A child of the debug build takes seconds to start. A test that ends too early leaves its run behind. const timeout = 30_000; -// Each long test of a fixture ends after a minute. A child that a failed run leaves behind ends then, too. -const fixtures = { +// Each long test of a file ends after a minute. A child that a failed run leaves behind ends then, too. +const files = { "quick.js": ` require("node:test").test("quick", () => {}); `, @@ -69,29 +69,23 @@ const fixtures = { }); `, }; - -const tempDir = harness?.tempDir("node-test-run-signal", fixtures); -const dir = tempDir ? String(tempDir) : mkdtempSync(join(tmpdir(), "node-test-run-signal_")); -if (!tempDir) { - for (const [name, source] of Object.entries(fixtures)) writeFileSync(join(dir, name), source); -} -after(() => (tempDir ? tempDir[Symbol.dispose]() : rmSync(dir, { recursive: true, force: true }))); +for (const [name, source] of Object.entries(files)) writeFileSync(join(dir, name), source); let markers = 0; -/** The path of a file that a fixture writes. The path is new for each call. */ -function marker(name: string) { +/** The path of a file that a child writes. The path is new for each call. */ +function marker(name) { return join(dir, `${name}-${++markers}`); } -function textOf(path: string) { +function textOf(path) { return existsSync(path) ? readFileSync(path, "utf8") : ""; } -function pidOf(path: string) { +function pidOf(path) { return Number(textOf(path)); } -function isRunning(pid: number) { +function isRunning(pid) { // process.kill(0) and process.kill(-1) signal more than one process. assert(pid > 0, `a child wrote its pid: ${pid}`); try { @@ -103,29 +97,29 @@ function isRunning(pid: number) { } /** The timeout of the test is the deadline. */ -async function until(condition: () => boolean) { +async function until(condition) { while (!condition()) await new Promise(resolve => setTimeout(resolve, 10)); } /** Aborts when the child of a "trap.js" or a "held.js" has its signal listeners. */ -function abortWhenReady(stream: ReturnType, controller: AbortController, reason?: unknown) { - stream.on("test:stdout", ({ message }: { message: string }) => { +function abortWhenReady(stream, controller, reason) { + stream.on("test:stdout", ({ message }) => { if (message.includes("ready")) controller.abort(reason); }); } /** The verdict of the run and each failed test. `isReason` says that the error is `reason` itself. */ -function outcomeOf(stream: ReturnType, reason?: unknown) { - const failed: { name: string; failureType: unknown; isReason: boolean }[] = []; - let summary: { success: boolean; counts: Record } | undefined; - stream.on("test:fail", (data: any) => { +function outcomeOf(stream, reason) { + const failed = []; + let summary; + stream.on("test:fail", data => { const error = data.details.error; failed.push({ name: data.name, failureType: error?.failureType, isReason: error === reason }); }); - stream.on("test:summary", (data: any) => { + stream.on("test:summary", data => { if (data.file === undefined) summary = data; }); - return new Promise<{ success: unknown; counts: unknown; failed: typeof failed }>((resolve, reject) => { + return new Promise((resolve, reject) => { stream.on("error", reject); stream.on("end", () => { const { tests, passed, failed: failures, cancelled } = summary?.counts ?? {}; @@ -175,7 +169,7 @@ test("ends the child of the running file, and starts no later file", { timeout } env: { ...env, PID: pid, EXITED: exited, STARTED: started }, signal: controller.signal, }); - stream.on("test:pass", (data: any) => { + stream.on("test:pass", data => { if (data.name === "passes") controller.abort(reason); }); @@ -230,8 +224,8 @@ test("fails the file with an AbortError when the reason of the abort is null", a env: { ...env, STARTED: marker("started") }, signal: AbortSignal.abort(null), }); - let error: any; - stream.on("test:fail", (data: any) => (error = data.details.error)); + let error; + stream.on("test:fail", data => (error = data.details.error)); await outcomeOf(stream); assert.deepStrictEqual( @@ -267,26 +261,27 @@ test( signal: controller.signal, }); let ended = false; - let endedAtAbort: boolean | undefined; - let childRunsAtEnd: boolean | undefined; - stream.on("test:stdout", ({ message }: { message: string }) => { + let endedAtAbort; + let childRunsAtEnd; + stream.on("test:stdout", ({ message }) => { if (!message.includes("ready")) return; controller.abort(); // The run ends in the promise jobs of the abort: no event of a child is necessary. setImmediate(() => (endedAtAbort = ended)); }); - stream.on("test:summary", (data: any) => { + stream.on("test:summary", data => { if (data.file !== undefined) return; ended = true; childRunsAtEnd = isRunning(pidOf(pid)); }); const outcome = outcomeOf(stream); + const childIsGone = () => pidOf(pid) > 0 && !isRunning(pidOf(pid)); // The child blocks here if the runner does not read both pipes to their end. - await until(() => textOf(wrote) !== ""); + await until(() => textOf(wrote) !== "" || childIsGone()); const childWrote = textOf(wrote); - process.kill(pidOf(pid), "SIGUSR2"); - await until(() => textOf(exited) !== ""); + if (!childIsGone()) process.kill(pidOf(pid), "SIGUSR2"); + await until(() => textOf(exited) !== "" || childIsGone()); assert.deepStrictEqual( { endedAtAbort, childRunsAtEnd, childWrote, childExitCode: textOf(exited), success: (await outcome).success }, { endedAtAbort: true, childRunsAtEnd: true, childWrote: "all", childExitCode: "0", success: false }, @@ -297,7 +292,7 @@ test( test("keeps the verdict of a run whose signal aborts after the last file ended", { timeout }, async () => { const controller = new AbortController(); const stream = run({ files: ["quick.js"], cwd: dir, env, signal: controller.signal }); - stream.on("test:plan", (data: any) => { + stream.on("test:plan", data => { if (data.file === undefined) controller.abort(); }); @@ -315,7 +310,7 @@ test( const controller = new AbortController(); const reason = new Error("stop"); const stream = run({ files: ["quick.js"], cwd: dir, env, signal: controller.signal }); - stream.on("test:summary", (data: any) => { + stream.on("test:summary", data => { if (data.file !== undefined) controller.abort(reason); }); @@ -326,20 +321,3 @@ test( }); }, ); - -test("Node.js passes each test of this file", { skip: !node, timeout }, async () => { - await using proc = Bun.spawn({ - cmd: [node!, "--test-reporter=tap", import.meta.filename], - env, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - const verdict = stdout.split("\n").filter(line => /^# (fail|cancelled) /.test(line)); - assert.deepStrictEqual( - { verdict, exitCode }, - { verdict: ["# fail 0", "# cancelled 0"], exitCode: 0 }, - `Node.js does not pass this file:\n${stdout}\n${stderr}`, - ); -}); diff --git a/test/js/node/test_runner/fixtures/run-controlled-child-fixture.ts b/test/js/node/test_runner/fixtures/run-controlled-child-fixture.ts index 9fccfd790fab..afe5e65b8d52 100644 --- a/test/js/node/test_runner/fixtures/run-controlled-child-fixture.ts +++ b/test/js/node/test_runner/fixtures/run-controlled-child-fixture.ts @@ -287,11 +287,62 @@ for (const source of ["stdout", "stderr"] as const) { } } +// The error of a test:stderr listener waits for the end of stdout, and not for the exit of the child. +for (const signal of ["with a signal", "without a signal"]) { + await scenario(`a test:stderr listener throws, then stdout ends and the child runs on, ${signal}`, async () => { + const spawned = nextChild(); + const options = signal === "with a signal" ? { signal: new AbortController().signal } : {}; + const watch = observe(run({ files: ["a.js"], ...options })); + watch.stream.on("test:stderr", () => { + throw new Error("the listener throws"); + }); + const child = await spawned; + child.stderr.write("a line\n"); + const endedAtThrow = await watch.endedNow(); + + watch.mark(); + child.stdout.write(pass("after the throw")); + child.stdout.end(); + const endedAtEndOfStdout = await watch.endedNow(); + child.end(0); + await watch.settled(); + return { + endedAtThrow, + endedAtEndOfStdout, + publishedAfterThrow: watch.published(), + destroyedWith: watch.destroyedWith(), + kills: child.kills, + }; + }); +} + +// Once stdout ended, the error of a test:stderr listener ends the run at once. +for (const signal of ["with a signal", "without a signal"]) { + await scenario(`a test:stderr listener throws after stdout ended, and the child runs on, ${signal}`, async () => { + const spawned = nextChild(); + const options = signal === "with a signal" ? { signal: new AbortController().signal } : {}; + const watch = observe(run({ files: ["a.js"], ...options })); + watch.stream.on("test:stderr", () => { + throw new Error("the listener throws"); + }); + const child = await spawned; + child.stdout.end(); + await tick(); + child.stderr.write("a line\n"); + + const endedAtThrow = await watch.endedNow(); + child.end(0); + await watch.settled(); + return { endedAtThrow, destroyedWith: watch.destroyedWith(), kills: child.kills }; + }); +} + // The error of the listener is the result, as without the abort. -for (const [name, source, afterExit] of [ - ["a test:stdout listener aborts, then throws", "stdout", false], - ["a test:stderr listener aborts, then throws", "stderr", false], - ["a test:stderr listener aborts, then throws, after the child exited", "stderr", true], +for (const [name, source, childState] of [ + ["a test:stdout listener aborts, then throws", "stdout", "runs"], + ["a test:stderr listener aborts, then throws", "stderr", "runs"], + ["a test:stderr listener aborts, then throws, after stdout ended", "stderr", "ended stdout"], + ["a test:stderr listener aborts, then throws, after the child exited", "stderr", "exited"], ] as const) { await scenario(name, async () => { const controller = new AbortController(); @@ -302,10 +353,10 @@ for (const [name, source, afterExit] of [ throw new Error("the listener throws"); }); const child = await spawned; - if (afterExit) { + if (childState !== "runs") { child.stdout.write(pass("passes")); child.stdout.end(); - child.exit(0); + if (childState === "exited") child.exit(0); await tick(); } child[source].write("a line\n"); diff --git a/test/js/node/test_runner/node-test.test.ts b/test/js/node/test_runner/node-test.test.ts index c86b705832c4..0b07793a29c3 100644 --- a/test/js/node/test_runner/node-test.test.ts +++ b/test/js/node/test_runner/node-test.test.ts @@ -1,6 +1,6 @@ import { spawn } from "bun"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, nodeExeAtLeast, tempDir } from "harness"; import { join } from "node:path"; import { run } from "node:test"; @@ -524,6 +524,45 @@ test("mock.property/mock.method survive a polluted Object.prototype", async () = }); describe("node:test run() signal", () => { + const fixture = join(import.meta.dirname, "fixtures", "30-run-signal.js"); + const node = nodeExeAtLeast("26.3.0"); + + // The fixture has the tests of run({ signal }) that Node.js passes too. They start real children, six at a time here. + test("passes the tests that Node.js passes", async () => { + using dir = tempDir("node-test-run-signal", {}); + const { exitCode, stderr } = await runTests(["30-run-signal.js"], { RUN_SIGNAL_DIR: String(dir) }, [ + "--concurrent", + ]); + + // The todo test is the one difference from Node.js. Three tests need POSIX signals. + const summary = stderr.split("\n").flatMap(line => line.match(/^ *(\d+ (?:pass|skip|todo|fail))$/)?.[1] ?? []); + expect({ summary, stderr, exitCode }).toMatchObject({ + summary: isWindows ? ["7 pass", "3 skip", "1 todo", "0 fail"] : ["10 pass", "1 todo", "0 fail"], + exitCode: 0, + }); + }, 30_000); + + test.skipIf(!node)( + "Node.js passes the same tests", + async () => { + using dir = tempDir("node-test-run-signal-node", {}); + await using proc = Bun.spawn({ + cmd: [node!, "--test-reporter=tap", fixture], + env: { ...bunEnv, RUN_SIGNAL_DIR: String(dir) }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const summary = stdout.split("\n").filter(line => /^# (fail|cancelled|todo) /.test(line)); + expect({ summary, stdout, stderr, exitCode }).toMatchObject({ + summary: ["# fail 0", "# cancelled 0", "# todo 0"], + exitCode: 0, + }); + }, + 30_000, + ); + // What the runner publishes for a file that the signal cancelled, then for the end of the run. const verdictThenEnd = [ "test:complete", @@ -585,6 +624,11 @@ describe("node:test run() signal", () => { kills: 1, }; const destroyed = (endedAtThrow: boolean) => ({ endedAtThrow, destroyedWith: "the listener throws", kills: 1 }); + const destroyedAtEndOfStdout = { + ...destroyed(false), + endedAtEndOfStdout: true, + publishedAfterThrow: ["test:pass"], + }; const wrapped = { outcome: { success: false, @@ -628,11 +672,16 @@ describe("node:test run() signal", () => { }, "a test:stdout listener throws, with a signal": destroyed(true), "a test:stdout listener throws, without a signal": destroyed(true), - "a test:stderr listener throws, with a signal": destroyed(true), - // Without a signal, the runner waits for the end of stdout first. + // The runner publishes the rest of stdout first, as on main. + "a test:stderr listener throws, with a signal": destroyed(false), "a test:stderr listener throws, without a signal": destroyed(false), + "a test:stderr listener throws, then stdout ends and the child runs on, with a signal": destroyedAtEndOfStdout, + "a test:stderr listener throws, then stdout ends and the child runs on, without a signal": destroyedAtEndOfStdout, + "a test:stderr listener throws after stdout ended, and the child runs on, with a signal": destroyed(true), + "a test:stderr listener throws after stdout ended, and the child runs on, without a signal": destroyed(true), "a test:stdout listener aborts, then throws": destroyed(true), "a test:stderr listener aborts, then throws": destroyed(true), + "a test:stderr listener aborts, then throws, after stdout ended": destroyed(true), "a test:stderr listener aborts, then throws, after the child exited": destroyed(true), "the child of the first file does not start": { outcome: { From 70260d35146bf233fa5ca71fa5ebdb8e322e9d70 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:21:20 +0000 Subject: [PATCH 4/5] node:test: keep main's key order in the details of a cancelled file The details of test:complete for a file that run() cancelled has duration_ms, type, error, passed again, as on main and in Node.js. abortFailure is next to makeTestFailure now. Two open changes add a function of that name at that place, so the declarations meet in one conflict and not as two functions of one name. Tests: the case of Node.js's test-runner-run.mjs ("should accept a signal") is in the fixture, the Node.js run has the thread caps of the other Node.js checks, and the controlled-child fixture pins the keys of details and what the runner publishes after the error of a listener. --- src/js/node/test.ts | 28 ++++++++--------- .../test_runner/fixtures/30-run-signal.js | 30 +++++++++++++++++++ .../fixtures/run-controlled-child-fixture.ts | 15 +++++++++- test/js/node/test_runner/node-test.test.ts | 20 +++++++++---- 4 files changed, 73 insertions(+), 20 deletions(-) diff --git a/src/js/node/test.ts b/src/js/node/test.ts index fd0e024bad70..510e5ed13641 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -412,19 +412,6 @@ async function runFiles(opts: ReturnType, reporter: T reporter.endStream(); } -// Node's Test#abortHandler: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/test_runner/test.js#L1058-L1062 -function abortFailure(signal: AbortSignal) { - let error: any = signal.reason || $makeAbortError("The test was aborted"); - try { - error.failureType = "testAborted"; - } catch { - // Node throws here, for a primitive and for a frozen object. The reason becomes the cause of an AbortError. - error = $makeAbortError("The test was aborted", { cause: error }); - error.failureType = "testAborted"; - } - return error; -} - // The verdict of a file that has none from its child: the signal cancelled the file, or the child did not start. function reportFailedFile( fileNode: object, @@ -439,7 +426,7 @@ function reportFailedFile( ...fileNode, type: undefined, testNumber: 1, - details: { __proto__: null, duration_ms: durationMs, type: "test", passed: false, error }, + details: { __proto__: null, duration_ms: durationMs, type: "test", error, passed: false }, }); const details = { __proto__: null, duration_ms: durationMs, type: "test", error }; reporter.fail({ __proto__: null, ...fileNode, type: undefined, testNumber: 1, details }); @@ -1493,6 +1480,19 @@ function makeTestFailure(message: string, failureType?: string) { return error; } +// Node's Test#abortHandler: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/test_runner/test.js#L1058-L1062 +function abortFailure(signal: AbortSignal) { + let error: any = signal.reason || $makeAbortError("The test was aborted"); + try { + error.failureType = "testAborted"; + } catch { + // Node throws here, for a primitive and for a frozen object. The reason becomes the cause of an AbortError. + error = $makeAbortError("The test was aborted", { cause: error }); + error.failureType = "testAborted"; + } + return error; +} + class TestPlan { expected: number; actual = 0; diff --git a/test/js/node/test_runner/fixtures/30-run-signal.js b/test/js/node/test_runner/fixtures/30-run-signal.js index 44e2f41b5d01..16b6a5eb1814 100644 --- a/test/js/node/test_runner/fixtures/30-run-signal.js +++ b/test/js/node/test_runner/fixtures/30-run-signal.js @@ -22,6 +22,15 @@ const files = { require("node:fs").writeFileSync(process.env.STARTED, "started"); require("node:test").test("late", () => {}); `, + "never-sync.js": ` + require("node:test").test("never ends", { timeout: 60_000 }, () => { + const end = Date.now() + 60_000; + while (Date.now() < end); + }); + `, + "never-async.js": ` + require("node:test").test("never ends", { timeout: 60_000 }, () => new Promise(resolve => setTimeout(resolve, 60_000))); + `, // SIGTERM ends this child: its "exit" listener does not run. "dies.js": ` const { writeFileSync } = require("node:fs"); @@ -129,6 +138,27 @@ function outcomeOf(stream, reason) { }); } +// The case of Node.js, test/parallel/test-runner-run.mjs: "should accept a signal". +test("fails each of two files that never end when a timeout signal aborts", { timeout }, async () => { + const signal = AbortSignal.timeout(50); + const stream = run({ files: ["never-sync.js", "never-async.js"], cwd: dir, env, signal }); + const errors = []; + let passed = 0; + stream.on("test:fail", data => errors.push(data.details.error)); + stream.on("test:pass", () => passed++); + + assert.deepStrictEqual( + { ...(await outcomeOf(stream)), passed, errorsAreTheReason: errors.map(error => error === signal.reason) }, + { + success: false, + counts: { tests: 2, passed: 0, failed: 0, cancelled: 2 }, + failed: ["never-sync.js", "never-async.js"].map(name => ({ name, failureType: "testAborted", isReason: false })), + passed: 0, + errorsAreTheReason: [true, true], + }, + ); +}); + test("cancels a file whose child handles SIGTERM and exits with code 0", { skip: !isPosix, timeout }, async () => { const controller = new AbortController(); const reason = new Error("stop"); diff --git a/test/js/node/test_runner/fixtures/run-controlled-child-fixture.ts b/test/js/node/test_runner/fixtures/run-controlled-child-fixture.ts index afe5e65b8d52..c9010c5cceec 100644 --- a/test/js/node/test_runner/fixtures/run-controlled-child-fixture.ts +++ b/test/js/node/test_runner/fixtures/run-controlled-child-fixture.ts @@ -116,6 +116,7 @@ function observe(stream: Stream, reason?: unknown) { const stderr: string[] = []; const errors: any[] = []; const failed: { name: string; failureType: unknown; isReason: boolean }[] = []; + const detailsKeys: Record = {}; let summary: { success: boolean; counts: Record } | undefined; let plan: number | undefined; let destroyedWith: string | undefined; @@ -124,6 +125,7 @@ function observe(stream: Stream, reason?: unknown) { const emit = stream.emit; stream.emit = function (this: Stream, type: string, data: any) { if (type.startsWith("test:")) types.push(type); + if (data?.details) detailsKeys[`${type} ${data.name}`] = Object.keys(data.details); if (data?.details && Object.getPrototypeOf(data.details) !== null) detailsWithPrototype.push(`${type} ${data.name}`); if (type === "test:stderr") stderr.push(data.message); @@ -149,6 +151,8 @@ function observe(stream: Stream, reason?: unknown) { settled: () => closed.then(tick), stderr, errors, + /** The keys of each `details` object, in their order. */ + detailsKeys, plan: () => plan, destroyedWith: () => destroyedWith, /** From here on, `published()` lists the types of the events. */ @@ -199,6 +203,7 @@ await scenario("the abort comes while the child runs", async () => { publishedAfterAbort, publishedAfterTheEnd: watch.published().slice(publishedAfterAbort.length), outcome: watch.outcome(), + detailsKeys: watch.detailsKeys, kills: child.kills, spawnFollowsTheSignal, decodedAfterAbort: decodes - decodesBefore, @@ -280,9 +285,17 @@ for (const source of ["stdout", "stderr"] as const) { child[source].write("a line\n"); const endedAtThrow = await watch.endedNow(); + // The file ended for a test:stdout listener. The runner still reads stderr, and publishes nothing of it. + const afterTheEnd = source === "stdout" ? { readsTheOtherPipe: false, published: [] as string[] } : undefined; + if (afterTheEnd) { + watch.mark(); + child.stderr.write("after the throw\n"); + afterTheEnd.readsTheOtherPipe = await child.stderr.isRead(); + afterTheEnd.published = watch.published(); + } child.end(0); await watch.settled(); - return { endedAtThrow, destroyedWith: watch.destroyedWith(), kills: child.kills }; + return { endedAtThrow, destroyedWith: watch.destroyedWith(), kills: child.kills, afterTheEnd }; }); } } diff --git a/test/js/node/test_runner/node-test.test.ts b/test/js/node/test_runner/node-test.test.ts index 0b07793a29c3..523ebbe7e9f9 100644 --- a/test/js/node/test_runner/node-test.test.ts +++ b/test/js/node/test_runner/node-test.test.ts @@ -537,7 +537,7 @@ describe("node:test run() signal", () => { // The todo test is the one difference from Node.js. Three tests need POSIX signals. const summary = stderr.split("\n").flatMap(line => line.match(/^ *(\d+ (?:pass|skip|todo|fail))$/)?.[1] ?? []); expect({ summary, stderr, exitCode }).toMatchObject({ - summary: isWindows ? ["7 pass", "3 skip", "1 todo", "0 fail"] : ["10 pass", "1 todo", "0 fail"], + summary: isWindows ? ["8 pass", "3 skip", "1 todo", "0 fail"] : ["11 pass", "1 todo", "0 fail"], exitCode: 0, }); }, 30_000); @@ -547,8 +547,8 @@ describe("node:test run() signal", () => { async () => { using dir = tempDir("node-test-run-signal-node", {}); await using proc = Bun.spawn({ - cmd: [node!, "--test-reporter=tap", fixture], - env: { ...bunEnv, RUN_SIGNAL_DIR: String(dir) }, + cmd: [node!, "--v8-pool-size=1", "--test-reporter=tap", fixture], + env: { ...bunEnv, UV_THREADPOOL_SIZE: "2", RUN_SIGNAL_DIR: String(dir) }, stdout: "pipe", stderr: "pipe", }); @@ -624,6 +624,7 @@ describe("node:test run() signal", () => { kills: 1, }; const destroyed = (endedAtThrow: boolean) => ({ endedAtThrow, destroyedWith: "the listener throws", kills: 1 }); + const destroyedAtOnce = { ...destroyed(true), afterTheEnd: { readsTheOtherPipe: true, published: [] } }; const destroyedAtEndOfStdout = { ...destroyed(false), endedAtEndOfStdout: true, @@ -646,6 +647,11 @@ describe("node:test run() signal", () => { publishedAfterTheEnd: [], outcome: cancelled("a.js", 2), kills: 1, + detailsKeys: { + "test:pass before the abort": ["duration_ms", "type"], + "test:complete a.js": ["duration_ms", "type", "error", "passed"], + "test:fail a.js": ["duration_ms", "type", "error"], + }, spawnFollowsTheSignal: false, decodedAfterAbort: 0, readsThePipes: true, @@ -670,8 +676,11 @@ describe("node:test run() signal", () => { outcome: passed, kills: 1, }, - "a test:stdout listener throws, with a signal": destroyed(true), - "a test:stdout listener throws, without a signal": destroyed(true), + // Node.js fails the file with the error of a listener, as an uncaught exception, and goes on: + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/test_runner/harness.js#L164-L165 + // Bun destroys the stream with that error, on main too. These rows pin when, and what comes after. + "a test:stdout listener throws, with a signal": destroyedAtOnce, + "a test:stdout listener throws, without a signal": destroyedAtOnce, // The runner publishes the rest of stdout first, as on main. "a test:stderr listener throws, with a signal": destroyed(false), "a test:stderr listener throws, without a signal": destroyed(false), @@ -701,6 +710,7 @@ describe("node:test run() signal", () => { killsOfTheSecondChild: 1, abortListeners: { whileTheSecondFileRuns: 1, afterTheRun: 0 }, }, + // Node.js publishes the first file and a plan of 1. It reports the other two to listeners after the summary. "the signal is aborted before run(), with three files": { outcome: { success: false, From dcd047cb53fb14f94477af6910351490bb9206e3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:08:47 +0000 Subject: [PATCH 5/5] ci: retrigger