diff --git a/src/js/node/test.ts b/src/js/node/test.ts index ace3768f419b..510e5ed13641 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,42 +412,49 @@ async function runFiles(opts: ReturnType, reporter: T reporter.endStream(); } -function reportCancelledFile( - file: string, - opts: ReturnType, +// 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 }); reporter.complete({ __proto__: null, ...fileNode, type: undefined, testNumber: 1, - details: { ...details, passed: false }, + 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 }); 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 firstOf(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; +} + +// 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, @@ -486,37 +486,85 @@ 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 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) { + 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); + }); + } + + const onReaderError = (err: unknown) => { + if (readerFailed) return; + readerFailed = true; + readerError = err; + if (afterStdout) $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(); 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" }); + publishLine(publish, line, onReaderError); } } - 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) publishLine(publish, carry, onReaderError); + }; + + let stderrText = ""; + const drainStderr = readLines(proc.stderr, line => { + 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) => { if (line.length === 0) return; @@ -531,6 +579,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 +592,30 @@ 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 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); + 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 firstOf(stop, drainStderr); + if (readerFailed) throw readerError; } - 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 +673,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](); } } @@ -1420,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 new file mode 100644 index 000000000000..16b6a5eb1814 --- /dev/null +++ b/test/js/node/test_runner/fixtures/30-run-signal.js @@ -0,0 +1,353 @@ +// 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"; +// 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 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", () => {}); + `, + "late.js": ` + 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"); + 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); + }); + `, +}; +for (const [name, source] of Object.entries(files)) writeFileSync(join(dir, name), source); + +let markers = 0; +/** 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) { + return existsSync(path) ? readFileSync(path, "utf8") : ""; +} + +function pidOf(path) { + return Number(textOf(path)); +} + +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 { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** The timeout of the test is the deadline. */ +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, 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, 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 => { + if (data.file === undefined) summary = data; + }); + return new Promise((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(); + }); +} + +// 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"); + 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 => { + 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; + stream.on("test:fail", data => (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; + 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 => { + 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) !== "" || childIsGone()); + const childWrote = textOf(wrote); + 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 }, + ); + }, +); + +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 => { + 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 => { + 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 }], + }); + }, +); 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..c9010c5cceec --- /dev/null +++ b/test/js/node/test_runner/fixtures/run-controlled-child-fixture.ts @@ -0,0 +1,480 @@ +// 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; +} + +/** 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[] = []; + 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; + let mark = 0; + + 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); + 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, + /** The keys of each `details` object, in their order. */ + detailsKeys, + 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(), + detailsKeys: watch.detailsKeys, + 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(); + // 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, afterTheEnd }; + }); + } +} + +// 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, 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(); + 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 (childState !== "runs") { + child.stdout.write(pass("passes")); + child.stdout.end(); + if (childState === "exited") 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; + 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 }, + }; + }); +} + +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.test.ts b/test/js/node/test_runner/node-test.test.ts index 2a27963203c0..523ebbe7e9f9 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, isWindows, nodeExeAtLeast, 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,209 @@ 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", () => { + 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 ? ["8 pass", "3 skip", "1 todo", "0 fail"] : ["11 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!, "--v8-pool-size=1", "--test-reporter=tap", fixture], + env: { ...bunEnv, UV_THREADPOOL_SIZE: "2", 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", + "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 destroyedAtOnce = { ...destroyed(true), afterTheEnd: { readsTheOtherPipe: true, published: [] } }; + const destroyedAtEndOfStdout = { + ...destroyed(false), + endedAtEndOfStdout: true, + publishedAfterThrow: ["test:pass"], + }; + 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, + 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, + 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, + }, + // 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), + "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: { + 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 }, + }, + // 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, + 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, + "details objects that inherit from Object.prototype": [], + }); + expect(exitCode).toBe(0); + }, 30_000); +});