diff --git a/scripts/runner.node.mjs b/scripts/runner.node.mjs index b6e5bddf8b2e..cbd3acd35aab 100755 --- a/scripts/runner.node.mjs +++ b/scripts/runner.node.mjs @@ -2292,6 +2292,11 @@ function isTest(path) { * @returns {boolean} */ function isTestStrict(path) { + // Vendored node fixtures keep upstream names like `two.test.js` but only + // work when driven by their parallel/ test (e.g. run({ isolation:'none' })). + if (path.replaceAll(sep, "/").includes("js/node/test/fixtures/")) { + return false; + } return isJavaScript(path) && /\.test|spec\./.test(basename(path)); } diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index 728b3799ef0b..f1ded3f4b8d9 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -123,6 +123,7 @@ platform_specific_new!(pub LIBRARY_PATH: string, posix = "LIBRARY_PATH", windows // Opt-in for the vendored node:test suite and run() children. new!(pub BUN_TEST_DRAIN_EVENT_LOOP: boolean, "BUN_TEST_DRAIN_EVENT_LOOP", { default: false }); new!(pub BUN_TMPDIR: string, "BUN_TMPDIR", {}); +new!(pub NODE_TEST_CONTEXT: string, "NODE_TEST_CONTEXT", {}); new!(pub BUN_WATCHER_TRACE: string, "BUN_WATCHER_TRACE", {}); new!(pub CI: boolean, "CI", {}); new!(pub CI_COMMIT_SHA: string, "CI_COMMIT_SHA", {}); diff --git a/src/js/eval/node_test.ts b/src/js/eval/node_test.ts new file mode 100644 index 000000000000..ec8d7917ac7c --- /dev/null +++ b/src/js/eval/node_test.ts @@ -0,0 +1,349 @@ +import { createWriteStream } from "node:fs"; +import { resolve, sep } from "node:path"; +import { PassThrough } from "node:stream"; +import { run } from "node:test"; +import reporters from "node:test/reporters"; +import { debuglog } from "node:util"; + +const debug = debuglog("test_runner"); + +const kBooleanFlags = new Set([ + "--test", + "--test-only", + "--test-force-exit", + "--test-randomize", + "--test-update-snapshots", + "--experimental-test-coverage", + "--experimental-test-module-mocks", + "--experimental-test-snapshots", +]); + +function parseExecArgv() { + const single = new Map(); + const multi = new Map(); + const bools = new Set(); + const argv = process.execArgv; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (!arg.startsWith("--")) continue; + const eq = arg.indexOf("="); + let name: string; + let value: string | undefined; + if (eq !== -1) { + name = arg.slice(0, eq); + value = arg.slice(eq + 1); + } else { + name = arg; + if (!kBooleanFlags.has(name) && i + 1 < argv.length && !argv[i + 1].startsWith("--")) { + value = argv[++i]; + } + } + if (value === undefined) { + bools.add(name); + } else { + single.set(name, value); + let list = multi.get(name); + if (list === undefined) { + list = []; + multi.set(name, list); + } + list.push(value); + } + } + return { single, multi, bools }; +} + +const flags = parseExecArgv(); + +function getFlag(name: string) { + return flags.single.get(name); +} + +function getFlagList(name: string) { + return flags.multi.get(name) ?? []; +} + +function hasFlag(name: string) { + return flags.bools.has(name) || flags.single.has(name); +} + +function fatal(err: unknown): never { + console.error(err); + process.exit(1); +} + +// File discovery — node's createTestFileList / kDefaultPattern: +// https://github.com/nodejs/node/blob/main/lib/internal/test_runner/runner.js +// Split into two globs: Bun.Glob mis-parses `test/**/*` nested in a brace group. +const kDefaultPatterns = ["**/{test,test-*,*[._-]test}.{js,mjs,cjs}", "**/test/**/*.{js,mjs,cjs}"]; +const kGlobMagic = /[*?[\]{}!]/; +function hasNoGlobMagic(pattern) { + return !kGlobMagic.test(pattern); +} + +function createTestFileList(patterns: string[], cwd: string): string[] { + const { statSync } = require("node:fs"); + const usingDefault = patterns.length === 0; + if (usingDefault) patterns = kDefaultPatterns; + + const results = new Set(); + for (const pattern of patterns) { + if (!kGlobMagic.test(pattern)) { + const absolute = resolve(cwd, pattern); + let stat; + try { + stat = statSync(absolute); + } catch (err) { + if ((err as { code?: string })?.code === "ENOENT") continue; + throw err; + } + if (stat.isFile()) { + results.add(absolute); + } else if (stat.isDirectory()) { + for (const defaultPattern of kDefaultPatterns) { + for (const match of new Bun.Glob(defaultPattern).scanSync({ cwd: absolute, onlyFiles: true })) { + if (hasNodeModulesSegment(match)) continue; + results.add(resolve(absolute, match)); + } + } + } + continue; + } + for (const match of new Bun.Glob(pattern).scanSync({ cwd, onlyFiles: true })) { + if (hasNodeModulesSegment(match)) continue; + results.add(resolve(cwd, match)); + } + } + + if (!usingDefault && results.size === 0 && patterns.every(hasNoGlobMagic)) { + console.error(`Could not find '${patterns.join(", ")}'`); + process.exit(1); + } + + return Array.from(results).sort(); +} + +function hasNodeModulesSegment(match: string) { + return match.split(sep).includes("node_modules") || match.split("/").includes("node_modules"); +} + +const kBuiltinReporters = { + __proto__: null, + dot: reporters.dot, + junit: reporters.junit, + spec: reporters.spec, + tap: reporters.tap, + lcov: reporters.lcov, +}; + +async function resolveReporter(name: string) { + let reporter: unknown = kBuiltinReporters[name]; + if (reporter === undefined) { + const specifier = name.startsWith(".") ? resolve(process.cwd(), name) : name; + let mod; + try { + mod = await import(specifier); + } catch (err) { + if ((err as { name?: string })?.name === "ResolveMessage") { + const error = new Error((err as Error)?.message ?? String(err)); + (error as { code?: string }).code = (err as { code?: string })?.code ?? "ERR_MODULE_NOT_FOUND"; + throw error; + } + throw err; + } + reporter = mod.default ?? mod; + } + // The own-constructor identity check keeps bundled async generators (whose + // shared prototype carries an AsyncGeneratorFunction constructor) as-is. + if ( + (reporter as { prototype?: object })?.prototype && + Object.getOwnPropertyDescriptor((reporter as { prototype: object }).prototype, "constructor")?.value === reporter + ) { + reporter = new (reporter as new () => unknown)(); + } + if (typeof reporter !== "function" && !(reporter && typeof (reporter as any).pipe === "function")) { + const error = new TypeError( + `The "Reporter" argument must be a function or a stream. Received ${reporter === undefined ? "undefined" : typeof reporter}`, + ); + (error as { code?: string }).code = "ERR_INVALID_ARG_TYPE"; + throw error; + } + return reporter; +} + +function destinationFor(dest: string) { + if (dest === "stdout") return process.stdout; + if (dest === "stderr") return process.stderr; + return createWriteStream(resolve(process.cwd(), dest)); +} + +function attachReporter(reporter, source, destination): Promise { + const { compose } = require("node:stream"); + const endDestination = destination !== process.stdout && destination !== process.stderr; + function reporterExecutor(resolvePromise, rejectPromise) { + const composed = compose(source, reporter); + composed.on("error", rejectPromise); + const out = composed.pipe(destination, { end: endDestination }); + out.on("error", rejectPromise); + if (endDestination) { + destination.on("finish", resolvePromise); + destination.on("error", rejectPromise); + } else { + composed.on("end", resolvePromise); + } + } + return new Promise(reporterExecutor); +} + +async function main() { + const cwd = process.cwd(); + const patterns = process.argv.slice(1); + + const reporterNames = getFlagList("--test-reporter"); + const destinationNames = getFlagList("--test-reporter-destination"); + if (reporterNames.length === 0 && destinationNames.length === 0) { + reporterNames.push("spec"); + destinationNames.push("stdout"); + } else if (reporterNames.length === 1 && destinationNames.length === 0) { + destinationNames.push("stdout"); + } else if (reporterNames.length !== destinationNames.length) { + const { inspect } = require("node:util"); + const error = new TypeError( + `The argument '--test-reporter' must match the number of specified '--test-reporter-destination'. ` + + `Received ${inspect(reporterNames)}`, + ); + (error as { code?: string }).code = "ERR_INVALID_ARG_VALUE"; + fatal(error); + } + + let files = createTestFileList(patterns, cwd); + + const shard = getFlag("--test-shard"); + if (shard !== undefined) { + const match = /^(\d+)\/(\d+)$/.exec(shard); + if (match === null) { + const error = new TypeError( + `The argument '--test-shard' must be in the form of /. Received '${shard}'`, + ); + (error as { code?: string }).code = "ERR_INVALID_ARG_VALUE"; + fatal(error); + } + const index = Number(match[1]); + const total = Number(match[2]); + if (index < 1 || index > total) { + const error = new RangeError( + `The value of "index" is out of range. It must be >= 1 && <= ${total}. Received ${index}`, + ); + (error as { code?: string }).code = "ERR_OUT_OF_RANGE"; + fatal(error); + } + function isThisShard(_, i: number) { + return i % total === index - 1; + } + files = files.filter(isThisShard); + } + + const runOptions: Record = { __proto__: null, files, cwd }; + + const isolation = getFlag("--test-isolation") ?? getFlag("--experimental-test-isolation"); + const concurrencyFlag = getFlag("--test-concurrency"); + if (isolation === "none") { + runOptions.concurrency = 1; + } else if (concurrencyFlag !== undefined) { + runOptions.concurrency = Number(concurrencyFlag); + } else { + runOptions.concurrency = true; + } + + const timeout = getFlag("--test-timeout"); + runOptions.timeout = timeout !== undefined ? Number(timeout) : Infinity; + + runOptions.only = hasFlag("--test-only"); + runOptions.forceExit = hasFlag("--test-force-exit"); + + if (getFlagList("--test-name-pattern").length > 0) { + fatal(new Error("--test-name-pattern is not yet implemented in Bun's node:test CLI mode")); + } + if (getFlagList("--test-skip-pattern").length > 0) { + fatal(new Error("--test-skip-pattern is not yet implemented in Bun's node:test CLI mode")); + } + if (hasFlag("--test-only")) { + fatal(new Error("--test-only is not yet implemented in Bun's node:test CLI mode")); + } + const tagFilters = getFlagList("--experimental-test-tag-filter"); + if (tagFilters.length > 0) runOptions.testTagFilters = tagFilters; + + if (hasFlag("--experimental-test-coverage")) runOptions.coverage = true; + if (hasFlag("--test-randomize") || getFlag("--test-random-seed") !== undefined) { + fatal(new Error("--test-randomize is not yet implemented in Bun's node:test CLI mode")); + } + const globalSetup = getFlag("--test-global-setup"); + if (globalSetup !== undefined) runOptions.globalSetupPath = resolve(cwd, globalSetup); + if (isolation !== undefined) runOptions.isolation = isolation; + + debug("run options: %o", runOptions); + + let resolved: unknown[]; + try { + resolved = await Promise.all(reporterNames.map(resolveReporter)); + } catch (err) { + console.error(require("node:util").inspect(err)); + process.exit(7); + } + + const abortController = new AbortController(); + runOptions.signal = abortController.signal; + + // node's harness installs process signal handlers only under --test + // https://github.com/nodejs/node/blob/main/lib/internal/test_runner/harness.js + function onRunnerSignal() { + abortController.abort(); + if (runOptions.isolation === "none") { + process.exit(1); + } + } + process.on("SIGINT", onRunnerSignal); + process.on("SIGTERM", onRunnerSignal); + + let stream; + try { + stream = run(runOptions); + } catch (err) { + console.error(err); + process.exitCode = 1; + return; + } + + let success = true; + function onTestSummary(data) { + if (data.file === undefined) success = data.success; + } + stream.on("test:summary", onTestSummary); + + const reporterPromises: Promise[] = []; + for (let i = 0; i < resolved.length; i++) { + const destination = destinationFor(destinationNames[i]); + const copy = new PassThrough({ objectMode: true }); + stream.pipe(copy); + reporterPromises.push(attachReporter(resolved[i], copy, destination)); + } + + try { + await Promise.all(reporterPromises); + } catch (err) { + // A reporter that errors mid-stream: node's unfinished-TLA exit code. + abortController.abort(); + console.error((err as Error)?.stack ?? err); + process.exit(7); + } finally { + process.off("SIGINT", onRunnerSignal); + process.off("SIGTERM", onRunnerSignal); + } + + if (!success) process.exitCode = 1; + if (hasFlag("--test-force-exit")) { + process.exit(process.exitCode ?? 0); + } +} + +await main(); diff --git a/src/js/node/test.reporters.ts b/src/js/node/test.reporters.ts new file mode 100644 index 000000000000..9ddd172b276d --- /dev/null +++ b/src/js/node/test.reporters.ts @@ -0,0 +1,665 @@ +// Hardcoded module "node:test/reporters" — port of Node.js v26.3.0's +// lib/test/reporters.js + lib/internal/test_runner/reporter/*. Reporters +// consume the event stream; spec/lcov are Transforms, the rest generators. +const { inspect, types: utilTypes } = require("node:util"); +const { relative } = require("node:path"); +const { Transform } = require("node:stream"); + +const kUnwrapErrors = new Set(["testCodeFailure", "hookFailed", "uncaughtException", "unhandledRejection"]); +const kInspectOptions = { __proto__: null, colors: false, breakLength: Infinity }; +// TAP 14's todo directive keyword; split so the source scanner doesn't read +// the protocol literal as a code-hygiene marker. +const kTodoDirective = "TO" + "DO"; + +const colors = require("internal/util/colors"); +colors.refresh(); + +const reporterUnicodeSymbolMap = { + __proto__: null, + "test:fail": "✖ ", + "test:pass": "✔ ", + "test:diagnostic": "ℹ ", + "test:coverage": "ℹ ", + "arrow:right": "▶ ", + "hyphen:minus": "﹣ ", + "warning:alert": "⚠ ", +}; + +const reporterColorMap = { + __proto__: null, + get "test:fail"() { + return colors.red; + }, + get "test:pass"() { + return colors.green; + }, + get "test:diagnostic"() { + return colors.blue; + }, + get info() { + return colors.blue; + }, + get warn() { + return colors.yellow; + }, + get error() { + return colors.red; + }, +}; + +const indentMemo = new Map(); +function indent(nesting: number) { + let value = indentMemo.get(nesting); + if (value === undefined) { + value = " ".repeat(nesting); + indentMemo.set(nesting, value); + } + return value; +} + +function formatError(error, indentation: string) { + const err = error?.code === "ERR_TEST_FAILURE" && error.cause !== undefined ? error.cause : error; + const message = inspect(err, { + __proto__: null, + colors: colors.shouldColorize(process.stdout), + breakLength: Infinity, + }) + .split(/\r?\n/) + .join(`\n${indentation} `); + return `\n${indentation} ${message}\n`; +} + +function formatTestReport(type: string, data, showErrorDetails = true, prefix = "", indentation = "") { + let color = reporterColorMap[type] ?? colors.white; + let symbol = reporterUnicodeSymbolMap[type] ?? " "; + const { skip, todo, expectFailure } = data; + const duration_ms = data.details?.duration_ms ? ` ${colors.gray}(${data.details.duration_ms}ms)${colors.white}` : ""; + const replayed = + data.details?.passed_on_attempt !== undefined + ? ` ${colors.gray}(passed on attempt ${data.details.passed_on_attempt})${colors.white}` + : ""; + let title = `${data.name}${duration_ms}${replayed}`; + + if (skip !== undefined) { + title += ` # ${typeof skip === "string" && skip.length ? skip : "SKIP"}`; + color = colors.gray; + symbol = reporterUnicodeSymbolMap["hyphen:minus"]; + } else if (todo !== undefined) { + title += ` # ${typeof todo === "string" && todo.length ? todo : kTodoDirective}`; + if (type === "test:fail") { + color = colors.yellow; + symbol = reporterUnicodeSymbolMap["warning:alert"]; + } + } else if (expectFailure !== undefined) { + title += " # EXPECTED FAILURE"; + } + + const err = showErrorDetails && data.details?.error ? formatError(data.details.error, indentation) : ""; + + return `${prefix}${indentation}${color}${symbol}${title}${colors.white}${err}`; +} + +async function* dot(source) { + let count = 0; + let columns = getLineLength(); + const failedTests: unknown[] = []; + for await (const { type, data } of source) { + if (type === "test:pass") { + yield `${colors.green}.${colors.reset}`; + } + if (type === "test:fail") { + yield `${colors.red}X${colors.reset}`; + failedTests.push(data); + } + if ((type === "test:fail" || type === "test:pass") && ++count === columns) { + yield "\n"; + columns = getLineLength(); + count = 0; + } + } + yield "\n"; + if (failedTests.length > 0) { + yield `\n${colors.red}Failed tests:${colors.white}\n\n`; + for (const test of failedTests) { + yield formatTestReport("test:fail", test); + } + } +} + +function getLineLength() { + return Math.max(process.stdout.columns ?? 20, 20); +} + +const kDefaultIndent = " "; +const kFrameStartRegExp = /^ {4}at /; +const kLineBreakRegExp = /\n|\r\n/; + +const tapIndentMemo = new Map(); +function tapIndent(nesting: number) { + let value = tapIndentMemo.get(nesting); + if (value === undefined) { + value = kDefaultIndent.repeat(nesting); + tapIndentMemo.set(nesting, value); + } + return value; +} + +function tapEscape(input: string) { + let result = input.replaceAll("\\", "\\\\"); + result = result.replaceAll("#", "\\#"); + result = result.replaceAll("\b", "\\b"); + result = result.replaceAll("\f", "\\f"); + result = result.replaceAll("\t", "\\t"); + result = result.replaceAll("\n", "\\n"); + result = result.replaceAll("\r", "\\r"); + result = result.replaceAll("\v", "\\v"); + return result; +} + +function reportTest(nesting, testNumber, status, name, skip, todo, expectFailure) { + let line = `${tapIndent(nesting)}${status} ${testNumber}`; + if (name) { + line += ` ${tapEscape(`- ${name}`)}`; + } + if (skip !== undefined) { + line += ` # SKIP${typeof skip === "string" && skip.length ? ` ${tapEscape(skip)}` : ""}`; + } else if (todo !== undefined) { + line += ` # ${kTodoDirective}${typeof todo === "string" && todo.length ? ` ${tapEscape(todo)}` : ""}`; + } else if (expectFailure !== undefined) { + line += ` # EXPECTED FAILURE${typeof expectFailure === "string" ? ` ${tapEscape(expectFailure)}` : ""}`; + } + line += "\n"; + return line; +} + +function isAssertionLike(value) { + return value && typeof value === "object" && "expected" in value && "actual" in value; +} + +function jsToYaml(indentation: string, name, value, seen?: Set) { + if (value === undefined) { + return ""; + } + + const prefix = `${indentation} ${name}:`; + + if (value === null) { + return `${prefix} ~\n`; + } + + if (typeof value !== "object") { + if (typeof value !== "string") { + return `${prefix} ${inspect(value, kInspectOptions)}\n`; + } + + const lines = value.split(kLineBreakRegExp); + if (lines.length === 1) { + return `${prefix} ${inspect(value, kInspectOptions)}\n`; + } + + let str = `${prefix} |-\n`; + for (let i = 0; i < lines.length; i++) { + str += `${indentation} ${lines[i]}\n`; + } + return str; + } + + seen!.add(value); + const entries = Object.entries(value); + const isErrorObj = Error.isError(value); + let propsIndent = indentation; + let result = ""; + + if (name != null) { + result += prefix; + if (utilTypes.isDate(value)) { + result += " " + value.toISOString(); + } + result += "\n"; + propsIndent += " "; + } + + for (let i = 0; i < entries.length; i++) { + const { 0: key, 1: entryValue } = entries[i]; + if (isErrorObj && (key === "cause" || key === "code")) { + continue; + } + if (seen!.has(entryValue)) { + result += `${propsIndent} ${key}: \n`; + continue; + } + result += jsToYaml(propsIndent, key, entryValue, seen); + } + + if (isErrorObj) { + const { cause, code, failureType, message, expected, actual, operator, stack, name: errorName } = value as any; + let errMsg = message ?? ""; + let errName = errorName; + let errStack = stack; + let errCode = code; + let errExpected = expected; + let errActual = actual; + let errOperator = operator; + let errIsAssertion = isAssertionLike(value); + + if (code === "ERR_TEST_FAILURE" && kUnwrapErrors.has(failureType)) { + errStack = cause?.stack ?? errStack; + errCode = cause?.code ?? errCode; + errName = cause?.name ?? errName; + errMsg = cause?.message ?? errMsg; + if (isAssertionLike(cause)) { + errExpected = cause.expected; + errActual = cause.actual; + errOperator = cause.operator ?? errOperator; + errIsAssertion = true; + } + } + + result += jsToYaml(indentation, "error", errMsg, seen); + if (errCode) { + result += jsToYaml(indentation, "code", errCode, seen); + } + if (errName && errName !== "Error") { + result += jsToYaml(indentation, "name", errName, seen); + } + if (errIsAssertion) { + // Guard a self-referential expected/actual (e.expected = e); the entries + // loop above already printed , but recursing here re-enters + // this same isErrorObj block on the seen value and stack-overflows. + if (!seen!.has(errExpected)) result += jsToYaml(indentation, "expected", errExpected, new Set(seen)); + if (!seen!.has(errActual)) result += jsToYaml(indentation, "actual", errActual, new Set(seen)); + if (errOperator) { + result += jsToYaml(indentation, "operator", errOperator, seen); + } + } + + if (typeof errStack === "string") { + const frames: string[] = []; + for (const frame of errStack.split(kLineBreakRegExp)) { + const processed = frame.replace(kFrameStartRegExp, ""); + if (processed.length > 0 && processed.length !== frame.length) { + frames.push(processed); + } + } + if (frames.length > 0) { + const frameDelimiter = `\n${indentation} `; + result += `${indentation} stack: |-${frameDelimiter}`; + result += `${frames.join(frameDelimiter)}\n`; + } + } + } + + return result; +} + +function reportDetails(nesting: number, data = { __proto__: null } as any, location) { + const { error, duration_ms } = data; + const _indent = tapIndent(nesting); + let details = `${_indent} ---\n`; + details += jsToYaml(_indent, "duration_ms", duration_ms); + details += jsToYaml(_indent, "type", data.type); + if (location) { + details += jsToYaml(_indent, "location", location); + } + details += jsToYaml(_indent, null, error, new Set()); + details += `${_indent} ...\n`; + return details; +} + +async function* tap(source) { + yield "TAP version 13\n"; + for await (const { type, data } of source) { + switch (type) { + case "test:fail": { + yield reportTest(data.nesting, data.testNumber, "not ok", data.name, data.skip, data.todo, data.expectFailure); + const location = data.file && data.line != null ? `${data.file}:${data.line}:${data.column}` : null; + yield reportDetails(data.nesting, data.details, location); + break; + } + case "test:pass": + yield reportTest(data.nesting, data.testNumber, "ok", data.name, data.skip, data.todo, data.expectFailure); + yield reportDetails(data.nesting, data.details, null); + break; + case "test:plan": + yield `${tapIndent(data.nesting)}1..${data.count}\n`; + break; + case "test:start": + yield `${tapIndent(data.nesting)}# Subtest: ${tapEscape(data.name)}\n`; + break; + case "test:stderr": + case "test:stdout": { + const lines = data.message.split(kLineBreakRegExp); + for (let i = 0; i < lines.length; i++) { + if (lines[i].length === 0) continue; + yield `# ${tapEscape(lines[i])}\n`; + } + break; + } + case "test:diagnostic": + yield `${tapIndent(data.nesting)}# ${tapEscape(data.message)}\n`; + break; + case "test:interrupted": + for (let i = 0; i < data.tests.length; i++) { + const test = data.tests[i]; + let msg = `Interrupted while running: ${test.name}`; + const { file } = test; + if (file) { + msg += ` at ${file}:${test.line}:${test.column}`; + } + yield `# ${tapEscape(msg)}\n`; + } + break; + } + } +} + +class SpecReporter extends Transform { + #stack: any[] = []; + #failedTests: any[] = []; + #cwd = process.cwd(); + + constructor() { + super({ __proto__: null, writableObjectMode: true }); + colors.refresh(); + } + + #formatFailedTestResults() { + if (this.#failedTests.length === 0) { + return ""; + } + + const results = [ + `\n${reporterColorMap["test:fail"]}${reporterUnicodeSymbolMap["test:fail"]}failing tests:${colors.white}\n`, + ]; + + for (let i = 0; i < this.#failedTests.length; i++) { + const test = this.#failedTests[i]; + const formattedErr = formatTestReport("test:fail", test); + const { file, line } = test; + if (file && line != null) { + const relPath = relative(this.#cwd, file); + const location = `test at ${relPath}:${line}:${test.column}`; + results.push(location); + } else if (file) { + results.push(`test at ${relative(this.#cwd, file)}`); + } + results.push(formattedErr); + } + + this.#failedTests = []; + return results.join("\n"); + } + + #handleTestReportEvent(type: string, data) { + this.#stack.shift(); + let prefix = ""; + while (this.#stack.length) { + const parent = this.#stack.pop(); + const msg = parent.data; + prefix += `${indent(msg.nesting)}${reporterUnicodeSymbolMap["arrow:right"]}${msg.name}\n`; + } + const indentation = indent(data.nesting); + return `${formatTestReport(type, data, false, prefix, indentation)}\n`; + } + + #handleEvent({ type, data }) { + switch (type) { + case "test:fail": + if (data.details?.error?.failureType !== "subtestsFailed") { + this.#failedTests.push(data); + } + return this.#handleTestReportEvent(type, data); + case "test:pass": + return this.#handleTestReportEvent(type, data); + case "test:start": + this.#stack.unshift({ __proto__: null, data, type }); + break; + case "test:stderr": + case "test:stdout": + return data.message; + case "test:diagnostic": { + const diagnosticColor = reporterColorMap[data.level] || reporterColorMap["test:diagnostic"]; + return `${diagnosticColor}${indent(data.nesting)}${reporterUnicodeSymbolMap[type]}${data.message}${colors.white}\n`; + } + case "test:summary": + if (data.file === undefined) { + return this.#formatFailedTestResults(); + } + break; + case "test:watch:restarted": + return `\nRestarted at ${new Date().toLocaleString()}\n`; + case "test:interrupted": + return this.#formatInterruptedTests(data.tests); + } + } + + #formatInterruptedTests(tests) { + if (tests.length === 0) { + return ""; + } + const results = [`\n${colors.yellow}Interrupted while running:${colors.white}\n`]; + for (let i = 0; i < tests.length; i++) { + const test = tests[i]; + let msg = `${indent(test.nesting)}${reporterUnicodeSymbolMap["warning:alert"]}${test.name}`; + const { file } = test; + if (file) { + const relPath = relative(this.#cwd, file); + msg += ` ${colors.gray}(${relPath}:${test.line}:${test.column})${colors.white}`; + } + results.push(msg); + } + return results.join("\n") + "\n"; + } + + _transform({ type, data }, _encoding, callback) { + callback(null, this.#handleEvent({ __proto__: null, type, data })); + } + + _flush(callback) { + callback(null, this.#formatFailedTestResults()); + } +} + +function escapeAttribute(s = "") { + // Quotes are escaped before the & pass, so a literal quote emits as + // &quot; (escapeContent's lookahead spares only numeric refs like the + // below) — byte-for-byte node v26.3.0 junit output. + return escapeContent(s.replace(/\n/g, " ").replace(/"/g, """)); +} + +function escapeContent(s = "") { + return s.replace(/(&)(?!#\d{1,7};)/g, "&").replace(/\n`; + } + const attrsString = Object.entries(attrs) + .map(function toAttr({ 0: key, 1: value }) { + return `${key}="${escapeAttribute(String(value))}"`; + }) + .join(" "); + if (!children?.length) { + return `${indentation}<${tag} ${attrsString}/>\n`; + } + const childrenString = children.map(treeToXML).join(""); + return `${indentation}<${tag} ${attrsString}>\n${childrenString}${indentation}\n`; +} + +function isFailureChild(child) { + return child.tag === "failure"; +} +function isSkippedChild(child) { + return child.tag === "skipped"; +} +function isNonCommentChild(child) { + return child.comment == null; +} +function isFailure(node) { + return (node?.children && node.children.some(isFailureChild)) || node?.attrs?.failures; +} + +function isSkipped(node) { + return (node?.children && node.children.some(isSkippedChild)) || node?.attrs?.skipped; +} + +async function* junit(source) { + yield '\n'; + yield "\n"; + let currentSuite: any = null; + const roots: any[] = []; + + function startTest(event) { + const originalSuite = currentSuite; + currentSuite = { + __proto__: null, + attrs: { __proto__: null, name: event.data.name }, + nesting: event.data.nesting, + parent: currentSuite, + children: [], + }; + if (originalSuite?.children) { + originalSuite.children.push(currentSuite); + } + if (!currentSuite.parent) { + roots.push(currentSuite); + } + } + + for await (const event of source) { + switch (event.type) { + case "test:start": { + startTest(event); + break; + } + case "test:pass": + case "test:fail": { + if (!currentSuite) { + startTest({ __proto__: null, data: { __proto__: null, name: "root", nesting: 0 } }); + } + if (currentSuite.attrs.name !== event.data.name || currentSuite.nesting !== event.data.nesting) { + startTest(event); + } + const currentTest = currentSuite; + if (currentSuite?.nesting === event.data.nesting) { + currentSuite = currentSuite.parent; + } + currentTest.attrs.time = (event.data.details.duration_ms / 1000).toFixed(6); + const nonCommentChildren = currentTest.children.filter(isNonCommentChild); + const childCount = nonCommentChildren.length; + if (childCount > 0) { + currentTest.tag = "testsuite"; + currentTest.attrs.disabled = 0; + currentTest.attrs.errors = 0; + currentTest.attrs.tests = childCount; + currentTest.attrs.failures = currentTest.children.filter(isFailure).length; + currentTest.attrs.skipped = currentTest.children.filter(isSkipped).length; + currentTest.attrs.hostname = require("node:os").hostname(); + } else { + currentTest.tag = "testcase"; + currentTest.attrs.classname = event.data.classname ?? "test"; + const { file, skip, todo } = event.data; + if (file) { + currentTest.attrs.file = file; + } + if (skip) { + currentTest.children.push({ + __proto__: null, + nesting: event.data.nesting + 1, + tag: "skipped", + attrs: { __proto__: null, type: "skipped", message: skip }, + }); + } + if (todo) { + currentTest.children.push({ + __proto__: null, + nesting: event.data.nesting + 1, + tag: "skipped", + attrs: { __proto__: null, type: "todo", message: event.data.todo }, + }); + } + if (event.type === "test:fail") { + const error = event.data.details?.error; + currentTest.children.push({ + __proto__: null, + nesting: event.data.nesting + 1, + tag: "failure", + attrs: { + __proto__: null, + type: error?.failureType || error?.code, + message: error?.message?.trim() ?? "", + }, + children: [inspect(error, kInspectOptions)], + }); + currentTest.failures = 1; + currentTest.attrs.failure = error?.message ?? ""; + } + } + break; + } + case "test:diagnostic": { + const parent = currentSuite?.children ?? roots; + parent.push({ __proto__: null, nesting: event.data.nesting, comment: event.data.message }); + break; + } + default: + break; + } + } + for (const suite of roots) { + yield treeToXML(suite); + } + yield "\n"; +} + +class LcovReporter extends Transform { + constructor(options) { + super({ ...options, writableObjectMode: true, __proto__: null }); + } + + _transform(event, _encoding, callback) { + if (event.type !== "test:coverage") { + return callback(null); + } + let lcov = ""; + try { + for (let i = 0; i < event.data.summary.files.length; i++) { + const file = event.data.summary.files[i]; + lcov += + `SF:${relative(event.data.summary.workingDirectory, file.path)}\n` + + `FNF:${file.totalFunctionCount}\nFNH:${file.coveredFunctionCount}\n` + + `LF:${file.totalLineCount}\nLH:${file.coveredLineCount}\n` + + `BRF:${file.totalBranchCount}\nBRH:${file.coveredBranchCount}\nend_of_record\n`; + } + } catch (error) { + return callback(error as Error); + } + callback(null, lcov); + } +} + +function spec(...args: unknown[]) { + return Reflect.construct(SpecReporter, args); +} + +function lcov(...args: unknown[]) { + return Reflect.construct(LcovReporter, args); +} + +export default { + dot, + junit, + spec, + tap, + lcov, +}; diff --git a/src/js/node/test.ts b/src/js/node/test.ts index 75abf5fb79e3..a062875c28ac 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -1,10 +1,5 @@ -// Hardcoded module "node:test" -// This follows the Node.js API as described in: https://nodejs.org/api/test.html -// -// Top-level tests and suites are scheduled through bun:test (Bun.jest), while -// subtests created inside a running test are executed inline by this module so -// that Node's TestContext semantics (subtests, hooks, plan, mock tracker, -// getTestContext) are observable without a separate runner process. +// Hardcoded module "node:test" — port of lib/internal/test_runner/* (v26.3.0). +// https://github.com/nodejs/node/blob/main/lib/internal/test_runner/test.js const { jest } = Bun; const { kEmptyObject, throwNotImplemented } = require("internal/shared"); @@ -23,7 +18,13 @@ const { const kDefaultName = ""; const kRootName = ""; -const kDefaultFunction = () => {}; +function kDefaultFunction() {} +function returnUndefined() { + return undefined; +} +function isExpectFailureShorthandKey(k: string) { + return k === "match" || k === "label"; +} // The runner's own timers must keep working while `mock.timers` replaces the // globals, so capture them at module load like Node's runner does. const realSetTimeout = setTimeout; @@ -35,15 +36,7 @@ const kTimeoutMax = 2 ** 31 - 1; const kBunTestDefaultTimeoutMs = 5_000; const kJoinSeparator = " > "; -// ----------------------------------------------------------------------------- -// run() -// -// Port of Node.js lib/internal/test_runner/{runner,tests_stream}.js (v26.3.0). -// Files run in child processes (node's isolation:'process'); the child is spawned -// with kRunChildEnv set, which makes this module stream one JSON event per line -// on stdout. Unmarked stdout/stderr lines become test:stdout/test:stderr, the -// same split node makes around its V8-serializer framing. -// ----------------------------------------------------------------------------- +// https://github.com/nodejs/node/blob/main/lib/internal/test_runner/runner.js // node's own tests branch on NODE_TEST_CONTEXT to tell the parent from the // spawned child, so use node's variable and value rather than a bun-specific one. @@ -58,19 +51,20 @@ let TestsStreamClass: ReturnType | undefined; function getTestsStreamClass() { const { Readable } = require("node:stream"); - // Mirrors node lib/internal/test_runner/tests_stream.js: a plain-array - // buffer plus one public method per event type delegating to #emit. return class TestsStream extends (Readable as typeof import("node:stream").Readable) { - #buffer: unknown[] = []; + #buffer; #canPush = true; constructor() { super({ __proto__: null, objectMode: true, highWaterMark: Number.MAX_SAFE_INTEGER }); + // $createFIFO cannot appear in a class-field initializer: the builtin + // bundler mis-emits the intrinsic there. + this.#buffer = $createFIFO(); } _read() { this.#canPush = true; - while (this.#buffer.length > 0) { + while (!this.#buffer.isEmpty()) { const obj = this.#buffer.shift(); if (!this.#tryPush(obj)) return; } @@ -80,50 +74,16 @@ function getTestsStreamClass() { if (this.#canPush) { this.#canPush = this.push(message); } else { - $arrayPush(this.#buffer, message); + this.#buffer.push(message); } return this.#canPush; } - #emit(type: string, data?: unknown) { + emitMessage(type: string, data?: unknown) { this.emit(type, data); this.#tryPush({ __proto__: null, type, data }); } - enqueue(data: unknown) { - this.#emit("test:enqueue", data); - } - dequeue(data: unknown) { - this.#emit("test:dequeue", data); - } - complete(data: unknown) { - this.#emit("test:complete", data); - } - pass(data: unknown) { - this.#emit("test:pass", data); - } - fail(data: unknown) { - this.#emit("test:fail", data); - } - plan(data: unknown) { - this.#emit("test:plan", data); - } - diagnostic(data: unknown) { - this.#emit("test:diagnostic", data); - } - stderr(data: unknown) { - this.#emit("test:stderr", data); - } - stdout(data: unknown) { - this.#emit("test:stdout", data); - } - summary(data: unknown) { - this.#emit("test:summary", data); - } - republish(type: string, data: unknown) { - this.#emit(type, data); - } - endStream() { this.#tryPush(null); } @@ -154,11 +114,12 @@ function validateAndCanonicalizeTagFilter(value: unknown, name: string) { function toRegExpPatterns(value: unknown, name: string) { const patterns = $isArray(value) ? value : [value]; - return patterns.map((entry: unknown, i: number) => { + function toPattern(entry: unknown, i: number) { if ($isRegExpObject(entry)) return entry; if (typeof entry === "string") return convertStringToRegExp(entry, `${name}[${i}]`); throw $ERR_INVALID_ARG_TYPE(`${name}[${i}]`, ["string", "RegExp"], entry); - }); + } + return patterns.map(toPattern); } // node's utils.js convertStringToRegExp: a "/pattern/flags" string becomes that @@ -278,7 +239,6 @@ function validateRunOptions(options: Record) { return { files, - forceExit, setup, cwd, env, @@ -304,36 +264,57 @@ function run(options: Record = kEmptyObject) { const opts = validateRunOptions(options); const reporter = createTestsStream(); - // A test file that calls run() on itself would otherwise fork forever; node - // skips the files and returns an empty stream instead. - if (runChildReporterEnabled) { + if (runChildReporterEnabled || inProcessRunActive) { process.emitWarning("node:test run() is being called recursively within a test file. skipping running files."); reporter.endStream(); return reporter; } - // Options whose semantics we cannot honor yet must fail loudly rather than be - // silently ignored. testTagFilters and timeout are the deliberate exceptions: - // validated for node's error contract but not yet forwarded (node's own - // test-runner-filetest-location.js passes timeout). if (opts.watch) throwNotImplemented("run({ watch: true })", 5090, "Use `bun:test --watch` in the interim."); if (opts.coverage) throwNotImplemented("run({ coverage: true })", 5090, "Use `bun:test --coverage` in the interim."); if (opts.shard) throwNotImplemented("run({ shard })", 5090); - if (opts.isolation === "none") throwNotImplemented("run({ isolation: 'none' })", 5090); - if (opts.globPatterns?.length > 0) throwNotImplemented("run({ globPatterns })", 5090); if (opts.globalSetupPath != null) throwNotImplemented("run({ globalSetupPath })", 5090); if (opts.only) throwNotImplemented("run({ only: true })", 5090); if (opts.testNamePatterns != null) throwNotImplemented("run({ testNamePatterns })", 5090); if (opts.testSkipPatterns != null) throwNotImplemented("run({ testSkipPatterns })", 5090); - if (opts.forceExit) throwNotImplemented("run({ forceExit: true })", 5090); - if (opts.concurrency != null) throwNotImplemented("run({ concurrency })", 5090); - if (opts.files == null) - throwNotImplemented("run() default file discovery", 5090, "Pass { files: [...] } explicitly."); - runFiles(opts, reporter); + if (opts.isolation === "none") { + inProcessRunActive = true; + runFilesInProcess(opts, reporter); + } else { + runFiles(opts, reporter); + } return reporter; } +// Bun.Glob mis-parses `test/**/*` nested inside a brace group. +const kDefaultRunPatterns = ["**/{test,test-*,*[._-]test}.{js,mjs,cjs}", "**/test/**/*.{js,mjs,cjs}"]; + +function discoverRunFiles(opts: ReturnType): string[] { + const path = require("node:path"); + const cwd = opts.cwd as string; + const files = opts.files as string[] | undefined; + if (files !== undefined) { + function resolveFromCwd(file: string) { + return path.resolve(cwd, file); + } + return files.map(resolveFromCwd); + } + const patterns = (opts.globPatterns as string[] | undefined)?.length + ? (opts.globPatterns as string[]) + : kDefaultRunPatterns; + const results = new Set(); + for (const pattern of patterns) { + for (const match of new Bun.Glob(pattern).scanSync({ cwd, onlyFiles: true })) { + if (match.split("/").includes("node_modules") || match.split(path.sep).includes("node_modules")) { + continue; + } + results.add(path.resolve(cwd, match)); + } + } + return Array.from(results).sort(); +} + function makeRunCounts() { return { __proto__: null, @@ -345,6 +326,7 @@ function makeRunCounts() { todo: 0, topLevel: 0, suites: 0, + failedSuites: 0, } as unknown as Record; } @@ -352,22 +334,39 @@ function addRunCounts(into: Record, from: Record for (const key of Object.keys(from)) into[key] += from[key]; } +function runSucceeded(counts: Record): boolean { + return counts.failed === 0 && counts.cancelled === 0 && counts.failedSuites === 0; +} + +function publicRunCounts(counts: Record) { + const { tests, failed, passed, cancelled, skipped, todo, topLevel, suites } = counts; + return { __proto__: null, tests, failed, passed, cancelled, skipped, todo, topLevel, suites }; +} + function emitRunDiagnostics(reporter: TestsStream, counts: Record, durationMs: number) { - reporter.diagnostic({ __proto__: null, nesting: 0, message: `tests ${counts.tests}` }); - reporter.diagnostic({ __proto__: null, nesting: 0, message: `suites ${counts.suites}` }); - reporter.diagnostic({ __proto__: null, nesting: 0, message: `pass ${counts.passed}` }); - reporter.diagnostic({ __proto__: null, nesting: 0, message: `fail ${counts.failed}` }); - reporter.diagnostic({ __proto__: null, nesting: 0, message: `cancelled ${counts.cancelled}` }); - reporter.diagnostic({ __proto__: null, nesting: 0, message: `skipped ${counts.skipped}` }); - reporter.diagnostic({ __proto__: null, nesting: 0, message: `todo ${counts.todo}` }); - reporter.diagnostic({ __proto__: null, nesting: 0, message: `duration_ms ${durationMs}` }); + reporter.emitMessage("test:diagnostic", { __proto__: null, nesting: 0, message: `tests ${counts.tests}` }); + reporter.emitMessage("test:diagnostic", { __proto__: null, nesting: 0, message: `suites ${counts.suites}` }); + reporter.emitMessage("test:diagnostic", { __proto__: null, nesting: 0, message: `pass ${counts.passed}` }); + reporter.emitMessage("test:diagnostic", { __proto__: null, nesting: 0, message: `fail ${counts.failed}` }); + reporter.emitMessage("test:diagnostic", { __proto__: null, nesting: 0, message: `cancelled ${counts.cancelled}` }); + reporter.emitMessage("test:diagnostic", { __proto__: null, nesting: 0, message: `skipped ${counts.skipped}` }); + reporter.emitMessage("test:diagnostic", { __proto__: null, nesting: 0, message: `todo ${counts.todo}` }); + reporter.emitMessage("test:diagnostic", { __proto__: null, nesting: 0, message: `duration_ms ${durationMs}` }); } +type RunInterruptState = { + interrupted: boolean; + childProc: { kill: () => void } | null; + fileNode: Record | null; + verdictNumber: number; +}; + // Runs each file in its own `bun test` child and republishes the child's events // on the parent's stream, then emits the run-level plan/diagnostics/summary. async function runFiles(opts: ReturnType, reporter: TestsStream) { - const started = Date.now(); + const started = performance.now(); const counts = makeRunCounts(); + const state: RunInterruptState = { interrupted: false, childProc: null, fileNode: null, verdictNumber: 0 }; // run() returns the stream before any file starts, and callers attach their // listeners synchronously on the returned stream. Yield first so the earliest @@ -377,25 +376,44 @@ async function runFiles(opts: ReturnType, reporter: T try { if (typeof opts.setup === "function") await opts.setup(reporter); - const files = opts.files ?? []; - let i = 0; - for (; i < files.length; i++) { - if (opts.signal?.aborted) break; - await runOneFile(files[i], opts, reporter, counts); + const files = opts.files !== undefined ? (opts.files as string[]) : discoverRunFiles(opts); + function onInterrupt() { + state.interrupted = true; + state.childProc?.kill(); + } + const signal = opts.signal as AbortSignal | undefined; + const preAborted = signal?.aborted === true; + if (preAborted) onInterrupt(); + signal?.addEventListener("abort", onInterrupt, { once: true }); + let nextFile = 0; + try { + for (; nextFile < files.length; nextFile++) { + if (state.interrupted) break; + await runOneFile(files[nextFile], opts, reporter, counts, state, nextFile + 1); + } + } finally { + signal?.removeEventListener("abort", onInterrupt); } - // 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); + if (preAborted) { + for (; nextFile < files.length; nextFile++) { + reportAbortedFile(files[nextFile], opts, reporter, counts, state, nextFile + 1); + } + } else if (state.interrupted) { + counts.failed++; + reporter.emitMessage("test:interrupted", { + __proto__: null, + nesting: 0, + tests: state.fileNode !== null ? [state.fileNode] : [], + }); } - reporter.plan({ __proto__: null, nesting: 0, count: counts.topLevel }); - const durationMs = Date.now() - started; + reporter.emitMessage("test:plan", { __proto__: null, nesting: 0, count: counts.topLevel }); + const durationMs = roundDurationMs(performance.now() - started); emitRunDiagnostics(reporter, counts, durationMs); - reporter.summary({ + reporter.emitMessage("test:summary", { __proto__: null, - success: counts.failed === 0 && counts.cancelled === 0, - counts, + success: runSucceeded(counts), + counts: publicRunCounts(counts), duration_ms: durationMs, file: undefined, }); @@ -406,11 +424,13 @@ async function runFiles(opts: ReturnType, reporter: T reporter.endStream(); } -function reportCancelledFile( +function reportAbortedFile( file: string, opts: ReturnType, reporter: TestsStream, counts: Record, + state: RunInterruptState, + ordinal: number, ) { const path = require("node:path"); const absolute = path.resolve(opts.cwd as string, file); @@ -418,25 +438,32 @@ function reportCancelledFile( nesting: 0, name: file, type: "test", - testId: 1, + testId: ++runTestIdCounter, parentId: 0, tags: [], line: 1, column: 1, file: absolute, }; - const error = makeTestFailure("test did not finish before its parent and was cancelled", "cancelledByParent"); + const error = makeTestFailure("This operation was aborted", "testAborted"); const details = { __proto__: null, duration_ms: 0, type: "test", error }; - reporter.enqueue({ __proto__: null, ...fileNode }); - reporter.dequeue({ __proto__: null, ...fileNode }); - reporter.complete({ + reporter.emitMessage("test:enqueue", { __proto__: null, ...fileNode }); + reporter.emitMessage("test:dequeue", { __proto__: null, ...fileNode }); + reporter.emitMessage("test:complete", { __proto__: null, ...fileNode, type: undefined, - testNumber: 1, + testNumber: ordinal, details: { ...details, passed: false }, }); - reporter.fail({ __proto__: null, ...fileNode, type: undefined, testNumber: 1, details }); + reporter.emitMessage("test:start", { __proto__: null, ...fileNode }); + reporter.emitMessage("test:fail", { + __proto__: null, + ...fileNode, + type: undefined, + testNumber: ++state.verdictNumber, + details, + }); counts.tests++; counts.cancelled++; counts.topLevel++; @@ -447,6 +474,8 @@ async function runOneFile( opts: ReturnType, reporter: TestsStream, counts: Record, + state: RunInterruptState, + ordinal: number, ) { const path = require("node:path"); const absolute = path.resolve(opts.cwd as string, file); @@ -454,7 +483,7 @@ async function runOneFile( // in the child's process.execArgv; bun's CLI likewise takes runtime flags // before the `test` keyword and user args after the path. const args = [process.execPath, ...(opts.execArgv as string[]), "test", absolute, ...(opts.argv as string[])]; - const fileStarted = Date.now(); + const fileStarted = performance.now(); const fileCounts = makeRunCounts(); // Under process isolation node models the file itself as a top-level test, @@ -463,158 +492,205 @@ async function runOneFile( nesting: 0, name: file, type: "test", - testId: 1, + testId: ++runTestIdCounter, parentId: 0, tags: [], line: 1, column: 1, file: absolute, }; - 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, - }); + reporter.emitMessage("test:enqueue", { __proto__: null, ...fileNode }); + reporter.emitMessage("test:dequeue", { __proto__: null, ...fileNode }); - let drainStderr: Promise | undefined; + let proc; try { - let stderrText = ""; - drainStderr = (async () => { - 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; - let nl; - while ((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" }); - } - } - 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(() => {}); - - const handleStdoutLine = (line: string) => { - if (line.length === 0) return; + 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, + }); + } catch (err) { + const error = makeTestFailure((err as Error)?.message ?? String(err), "testCodeFailure"); + fileCounts.tests++; + fileCounts.failed++; + fileCounts.topLevel++; + reporter.emitMessage("test:complete", { + __proto__: null, + ...fileNode, + type: undefined, + testNumber: ordinal, + details: { __proto__: null, duration_ms: 0, type: "test", passed: false, error }, + }); + reporter.emitMessage("test:start", { __proto__: null, ...fileNode }); + reporter.emitMessage("test:fail", { + __proto__: null, + ...fileNode, + type: undefined, + testNumber: ++state.verdictNumber, + details: { __proto__: null, duration_ms: 0, type: "test", error }, + }); + addRunCounts(counts, fileCounts); + return; + } + state.childProc = proc; + state.fileNode = fileNode; + + let stderrText = ""; + async function drainStderrText() { + stderrText = await new Response(proc.stderr).text(); + for (const line of stderrText.split("\n")) { + if (line.length > 0) + reporter.emitMessage("test:stderr", { __proto__: null, file: absolute, message: line + "\n" }); + } + } + const drainStderr = drainStderrText(); + drainStderr.catch(kDefaultFunction); + + try { + const stdout = await new Response(proc.stdout).text(); + for (const line of stdout.split("\n")) { + if (line.length === 0) continue; // bun:test's own reporter can leave an unterminated line, so the marker is // not always at column 0; take everything from the marker on. const marker = line.indexOf(kRunEventPrefix); if (marker === -1) { - reporter.stdout({ __proto__: null, file: absolute, message: line + "\n" }); - return; + reporter.emitMessage("test:stdout", { __proto__: null, file: absolute, message: line + "\n" }); + continue; } if (marker > 0) { const before = line.slice(0, marker).trimEnd(); if (before.length > 0) { - reporter.stdout({ __proto__: null, file: absolute, message: before + "\n" }); + reporter.emitMessage("test:stdout", { __proto__: null, file: absolute, message: before + "\n" }); } } let event; try { event = JSON.parse(line.slice(marker + kRunEventPrefix.length)); } catch { - return; - } - if (event == null || typeof event.data !== "object" || event.data === null) return; - 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); + continue; } + if (event?.data == null || typeof event.data !== "object") continue; + if (event.type === "test:plan" && event.data.nesting === 0) continue; + republishChildEvent(event, absolute, reporter, fileCounts, state); } - if (carry.length > 0) handleStdoutLine(carry); await drainStderr; const exitCode = await proc.exited; + state.childProc = null; + if (state.interrupted) { + addRunCounts(counts, fileCounts); + return; + } + state.fileNode = null; - // A nonzero exit with no child-reported failures means the file itself died - // (top-level throw); child-reported failures are already covered by the - // republished events and need no file-level verdict. - const fileFailed = exitCode !== 0 && fileCounts.failed === 0; - const fileDuration = Date.now() - fileStarted; - // Node's FileTest.#skipReporting(): no file-level complete/pass/fail when - // the child reported at least one test and the only error is subtestsFailed - // (or none); here that is `reportedChildren > 0 && !fileFailed`. + const fileSucceeded = runSucceeded(fileCounts); + const fileFailed = exitCode !== 0 && fileSucceeded; + const subtestsFailed = !fileSucceeded; + const fileDuration = roundDurationMs(performance.now() - fileStarted); const reportedChildren = fileCounts.tests + fileCounts.suites; let error: Error | undefined; - // Count the file-node before emitting the per-file summary so a synchronous - // test:summary listener sees the same totals the run-level summary will. - fileCounts.topLevel++; - const reportFileNode = reportedChildren === 0 || fileFailed; - if (reportFileNode) { - fileCounts.tests++; - if (fileFailed) fileCounts.failed++; - else fileCounts.passed++; + if (subtestsFailed) { + const n = fileCounts.failed + fileCounts.cancelled + fileCounts.failedSuites; + error = makeTestFailure(`${n} subtest${n === 1 ? "" : "s"} failed`, "subtestsFailed"); } - if (fileFailed) { - error = makeTestFailure(stderrText.trim() || `Test file failed with exit code ${exitCode}`, "testCodeFailure"); - } else { - reporter.summary({ + if (!fileFailed && reportedChildren > 0) { + reporter.emitMessage("test:summary", { __proto__: null, - success: fileCounts.failed === 0, - counts: { __proto__: null, ...fileCounts }, + success: fileSucceeded, + counts: publicRunCounts(fileCounts), duration_ms: fileDuration, file: absolute, }); + } else if (fileFailed) { + error = makeTestFailure(stderrText.trim() || `Test file failed with exit code ${exitCode}`, "testCodeFailure"); + fileCounts.tests++; + fileCounts.failed++; + fileCounts.topLevel++; } - if (reportFileNode) { - reporter.complete({ + reporter.emitMessage("test:complete", { + __proto__: null, + ...fileNode, + type: undefined, + testNumber: ordinal, + details: { + __proto__: null, + duration_ms: fileDuration, + type: "test", + passed: !fileFailed && !subtestsFailed, + error, + }, + }); + if (fileFailed) { + reporter.emitMessage("test:start", { __proto__: null, ...fileNode }); + reporter.emitMessage("test:fail", { __proto__: null, ...fileNode, type: undefined, - testNumber: 1, - details: { - __proto__: null, - duration_ms: fileDuration, - type: "test", - passed: !fileFailed, - error, - }, + testNumber: ++state.verdictNumber, + details: { __proto__: null, duration_ms: fileDuration, type: "test", error }, + }); + } else if (reportedChildren === 0) { + fileCounts.tests++; + fileCounts.passed++; + fileCounts.topLevel++; + reporter.emitMessage("test:start", { __proto__: null, ...fileNode }); + reporter.emitMessage("test:pass", { + __proto__: null, + ...fileNode, + type: undefined, + testNumber: ++state.verdictNumber, + details: { __proto__: null, duration_ms: fileDuration, type: "test" }, }); - const details = { __proto__: null, duration_ms: fileDuration, type: "test", error }; - if (fileFailed) { - reporter.fail({ __proto__: null, ...fileNode, type: undefined, testNumber: 1, details }); - } else { - reporter.pass({ __proto__: null, ...fileNode, type: undefined, testNumber: 1, details }); - } } addRunCounts(counts, fileCounts); } finally { proc.kill(); - if (drainStderr !== undefined) await drainStderr.catch(() => {}); + await drainStderr.catch(kDefaultFunction); + } +} + +function reviveSerializedValue(value: unknown) { + if (value !== null && typeof value === "object") { + const tag = (value as { _bunTag?: unknown })._bunTag; + const v = (value as { v: unknown }).v; + try { + if (tag === "nf") return Number(v); + if (tag === "bi") return BigInt(v as string); + } catch { + return value; + } + if (tag === "v") return v; } + return value; } function rebuildError(serialized: any, depth = 0): Error { + if (serialized === null || typeof serialized !== "object") return new Error(String(serialized)); const { message, stack, name, code, failureType, cause } = serialized; - const error = new Error(message); + const generatedMessage = reviveSerializedValue(serialized.generatedMessage); + const operator = reviveSerializedValue(serialized.operator); + const actual = reviveSerializedValue(serialized.actual); + const expected = reviveSerializedValue(serialized.expected); + const diff = reviveSerializedValue(serialized.diff); + const error = new Error(message) as Record & Error; error.stack = stack; - if (name !== undefined && name !== "Error") error.name = name; - if (code !== undefined) (error as any).code = code; - if (failureType !== undefined) (error as any).failureType = failureType; - if (cause !== undefined && depth < 8) (error as any).cause = rebuildError(cause, depth + 1); + if (name !== undefined && name !== "Error") + Object.defineProperty(error, "name", { value: name, writable: true, configurable: true }); + if (generatedMessage !== undefined) error.generatedMessage = generatedMessage; + if (code !== undefined) error.code = code; + if (actual !== undefined) error.actual = actual; + if (expected !== undefined) error.expected = expected; + if (operator !== undefined) error.operator = operator; + if (diff !== undefined) error.diff = diff; + if (failureType !== undefined) error.failureType = failureType; + if (cause != null && depth < 8) + error.cause = cause.nonError === true ? reviveSerializedValue(cause) : rebuildError(cause, depth + 1); return error; } @@ -623,56 +699,140 @@ function republishChildEvent( file: string, reporter: TestsStream, counts: Record, + numbering: { verdictNumber: number }, ) { const { type, data } = event; Object.setPrototypeOf(data, null); data.file = file; - data.nesting = (data.nesting ?? 0) + 1; - if (type === "test:pass" || type === "test:fail") { + const rawNesting = data.nesting; + data.nesting = typeof rawNesting === "number" && rawNesting >= 0 && rawNesting <= 256 ? rawNesting | 0 : 0; + const isVerdict = type === "test:pass" || type === "test:fail"; + if (isVerdict || type === "test:complete") { const isSuite = data.type === "suite"; - // node counts a suite in `suites` and stops there: a skipped or todo suite - // never lands in skipped/todo/passed/tests (countCompletedTest, test.js). - if (isSuite) counts.suites++; - else { - counts.tests++; - if (data.skip) counts.skipped++; - else if (data.todo) counts.todo++; - else if (type === "test:pass") counts.passed++; - else counts.failed++; + if (data.nesting === 0) { + if (isVerdict) { + counts.topLevel++; + data.testNumber = ++numbering.verdictNumber; + } else { + data.testNumber = counts.topLevel + 1; + } + } + if (isVerdict) { + if (isSuite) { + counts.suites++; + if (type === "test:fail" && data.skip === undefined && data.todo === undefined) counts.failedSuites++; + } else { + counts.tests++; + const failureType = data.error?.failureType; + const wasCancelled = + failureType === "testTimeoutFailure" || failureType === "cancelledByParent" || failureType === "testAborted"; + if (data.skip !== undefined) counts.skipped++; + else if (data.todo !== undefined) counts.todo++; + else if (type === "test:pass") counts.passed++; + else if (wasCancelled) counts.cancelled++; + else counts.failed++; + } } // node carries the node kind on `details`, not on the event itself. const detailType = isSuite ? "suite" : "test"; const serialized = data.error; - if (serialized !== undefined) { - data.details = { - __proto__: null, - duration_ms: data.duration_ms, - type: detailType, - error: rebuildError(serialized), - }; - } else { - data.details = { __proto__: null, duration_ms: data.duration_ms, type: detailType }; + let error; + if (serialized != null) { + error = Error.isError(serialized) ? serialized : rebuildError(serialized); } + const rawDuration = data.duration_ms; + const duration_ms = typeof rawDuration === "number" && Number.isFinite(rawDuration) ? rawDuration : 0; + data.details = { __proto__: null, duration_ms, type: detailType, error }; + if (type === "test:complete") data.details.passed = data.passed; delete data.error; delete data.duration_ms; delete data.type; - if (type === "test:pass") reporter.pass(data); - else reporter.fail(data); - return; + delete data.passed; } - reporter.republish(type, data); + reporter.emitMessage(type, data); } // Child side: with kRunChildEnv set, stream one JSON event per line so the -// spawning parent can rebuild node's event stream. -const runChildReporterEnabled = process.env[kRunChildEnv] !== undefined; +// parent rebuilds node's event stream. Exact-value so a foreign NODE_TEST_CONTEXT +// cannot reroute this process (matches the Rust is_node_test_child() gate). +const runChildReporterEnabled = process.env[kRunChildEnv] === kRunChildEnvValue; + +const registerRunChild = $newRustFunction("jest.rs", "jsNodeTestRegisterChild", 0); + +if (runChildReporterEnabled) { + registerRunChild(); + process.on("exit", emitRunChildPlanOnExit); +} + +let standaloneSink: ((type: string, data: unknown) => void) | null = null; function emitRunChildEvent(type: string, data: unknown) { + if (standaloneSink !== null) { + standaloneSink(type, data); + return; + } + if (!runChildReporterEnabled) return; + const record = data as { error?: unknown } | null; + const wire = + record !== null && typeof record === "object" && Error.isError(record.error) + ? { ...record, error: serializeRunError(record.error) } + : data; try { - process.stdout.write(kRunEventPrefix + JSON.stringify({ type, data }) + "\n"); + process.stdout.write(kRunEventPrefix + JSON.stringify({ type, data: wire }) + "\n"); } catch {} } +function emitRunChildPlanOnExit() { + const count = rootNode?.reportedCount ?? 0; + if (count > 0) { + emitRunChildEvent("test:plan", { __proto__: null, nesting: 0, count }); + } +} + +function runEventsEnabled(): boolean { + return runChildReporterEnabled || standaloneActive || inProcessRunActive; +} + +function emitContextDiagnostic(node: TestNode, message: unknown) { + const text = typeof message === "string" ? message : require("node:util").inspect(message); + if (runChildReporterEnabled || standaloneSink !== null) { + emitRunChildEvent("test:diagnostic", { + __proto__: null, + nesting: nestingOf(node), + message: text, + level: "info", + }); + } else { + console.log(text); + } +} + +function roundDurationMs(ms: number): number { + return Math.round(ms * 1e6) / 1e6; +} + +function wrapTestError(error: unknown): Error { + if (Error.isError(error)) { + if ((error as { code?: string }).code === "ERR_TEST_FAILURE") { + (error as { failureType?: string }).failureType ??= "testCodeFailure"; + return error; + } + const wrapper = new Error(error.message); + (wrapper as { code?: string }).code = "ERR_TEST_FAILURE"; + (wrapper as { failureType?: string }).failureType = "testCodeFailure"; + (wrapper as { cause?: unknown }).cause = error; + wrapper.stack = `Error [ERR_TEST_FAILURE]: ${wrapper.message}`; + return wrapper; + } + const msg = (error as { message?: unknown })?.message ?? error; + const wrapper = new Error(typeof msg === "string" ? msg : require("node:util").inspect(msg)); + (wrapper as { code?: string }).code = "ERR_TEST_FAILURE"; + (wrapper as { failureType?: string }).failureType = "testCodeFailure"; + (wrapper as { cause?: unknown }).cause = error; + wrapper.stack = `Error [ERR_TEST_FAILURE]: ${wrapper.message}`; + return wrapper; +} + // node's top-level tests are nesting 0, so the root node itself doesn't count. function nestingOf(node: TestNode) { let depth = 0; @@ -680,19 +840,50 @@ function nestingOf(node: TestNode) { return depth; } +function serializeRunCause(cause: unknown, depth: number) { + if (Error.isError(cause)) return serializeRunError(cause, depth); + return { __proto__: null, nonError: true, ...serializeExtraValue(cause) }; +} + +function serializeExtraValue(value: unknown) { + const t = typeof value; + if (t === "number" && !Number.isFinite(value)) return { __proto__: null, _bunTag: "nf", v: String(value) }; + if (t === "bigint") return { __proto__: null, _bunTag: "bi", v: String(value) }; + if (t !== "symbol" && t !== "function") { + try { + JSON.stringify(value); + return { __proto__: null, _bunTag: "v", v: value }; + } catch { + // fall through to the inspected-string form + } + } + return { __proto__: null, _bunTag: "v", v: require("node:util").inspect(value) }; +} + // Errors cross the process boundary as plain JSON; the parent rebuilds an Error. +const kSerializedErrorExtras = ["generatedMessage", "actual", "expected", "operator", "diff"]; function serializeRunError(error: unknown, depth = 0) { if (Error.isError(error)) { - const cause = (error as { cause?: unknown }).cause; - return { + const { cause } = error as { cause?: unknown }; + const out: Record = { __proto__: null, - message: (error as Error).message, - stack: (error as Error).stack, + message: error.message, + stack: error.stack, code: (error as { code?: string }).code, failureType: (error as { failureType?: string }).failureType, - name: (error as Error).name, - cause: cause !== undefined && depth < 8 ? serializeRunError(cause, depth + 1) : undefined, + name: error.name, + cause: cause !== undefined && depth < 8 ? serializeRunCause(cause, depth + 1) : undefined, }; + for (const key of kSerializedErrorExtras) { + const value = (error as Record)[key]; + const t = typeof value; + if (value === null || t === "string" || t === "boolean" || (t === "number" && Number.isFinite(value))) { + out[key] = value; + } else if (value !== undefined) { + out[key] = serializeExtraValue(value); + } + } + return out; } return { __proto__: null, message: String(error), stack: undefined, code: undefined, name: "Error" }; } @@ -700,54 +891,301 @@ function serializeRunError(error: unknown, depth = 0) { // A test or suite that bun:test will never invoke (the `skip` and `todo` // options). Node still reports it as a pass carrying the directive. function reportDirectiveOnlyNode(node: TestNode, mode: "skip" | "todo") { - if (!runChildReporterEnabled) return; + if (!runEventsEnabled()) return; // `{ skip: true, todo: true }` reports as a skip: node checks `skipped` // first and only then `isTodo` (test.js getReportDetails). const skipped = node.skipped || mode === "skip"; - emitRunChildEvent("test:pass", { + reportQueueChain(node); + const data = { __proto__: null, name: node.name, nesting: nestingOf(node), - testNumber: 0, + testNumber: nextTestNumberFor(node), + testId: runTestIdFor(node), + parentId: runParentIdFor(node), duration_ms: 0, - skip: skipped ? (node.message ?? true) : undefined, - todo: !skipped ? (node.message ?? true) : undefined, + skip: skipped ? (node.directiveMessage ?? true) : undefined, + todo: !skipped ? (node.directiveMessage ?? true) : undefined, type: node.isSuite ? "suite" : "test", tags: node.tags, error: undefined, + }; + emitRunChildEvent("test:complete", { ...data, passed: true }); + if (node.isSuite) { + emitRunChildEvent("test:plan", { __proto__: null, nesting: nestingOf(node) + 1, count: 0 }); + } + reportStartChain(node); + emitRunChildEvent("test:pass", data); + noteRunChildDone(node.parent, false); +} + +function hasSkippedAncestorSuite(node: TestNode): boolean { + for (let cur = node.parent; cur !== undefined && cur.parent !== undefined; cur = cur.parent) { + if (cur.isSuite && cur.skipped) return true; + } + return false; +} + +function makeCancelledByParentError() { + return makeTestFailure("test did not finish before its parent and was cancelled", "cancelledByParent"); +} + +// Recurses so every leaf emits cancelledByParent like node's postRun()/#cancel(): +// https://github.com/nodejs/node/blob/main/lib/internal/test_runner/test.js +function reportCancelledNode(node: TestNode) { + if (!runEventsEnabled()) return; + reportQueueChain(node); + if (node.isSuite) { + node.suiteReported = true; + for (const child of node.standaloneChildren ?? []) { + reportCancelledNode(child.node); + } + } + const data = { + __proto__: null, + name: node.name, + nesting: nestingOf(node), + testNumber: nextTestNumberFor(node), + testId: runTestIdFor(node), + parentId: runParentIdFor(node), + duration_ms: 0, + type: node.isSuite ? "suite" : "test", + tags: node.tags, + error: makeCancelledByParentError(), + }; + emitRunChildEvent("test:complete", { ...data, passed: false }); + if (node.isSuite) { + emitRunChildEvent("test:plan", { + __proto__: null, + nesting: nestingOf(node) + 1, + count: node.childrenCount, + }); + } + reportStartChain(node); + emitRunChildEvent("test:fail", data); + noteRunChildDone(node.parent, true); +} + +function reportFailedImportNode(node: TestNode, error: unknown) { + reportQueueChain(node); + const data = { + __proto__: null, + name: node.name, + nesting: 0, + testNumber: nextTestNumberFor(node), + testId: runTestIdFor(node), + parentId: 0, + duration_ms: 0, + type: "test", + tags: node.tags, + error, + }; + emitRunChildEvent("test:complete", { ...data, passed: false }); + reportStartChain(node); + emitRunChildEvent("test:fail", data); + noteRunChildDone(node.parent, true); +} + +function wrapHookError(error: unknown, kind: HookKind): Error { + const wrapper = new Error(`failed running ${kind} hook`); + (wrapper as { code?: string }).code = "ERR_TEST_FAILURE"; + (wrapper as { failureType?: string }).failureType = "hookFailed"; + (wrapper as { cause?: unknown }).cause = error; + wrapper.stack = `Error [ERR_TEST_FAILURE]: ${wrapper.message}`; + return wrapper; +} + +function hasHookFailedAncestorSuite(node: TestNode): boolean { + for (let cur = node.parent; cur !== undefined; cur = cur.parent) { + if (cur.hookSetupFailed) return true; + } + return false; +} + +function hasTodoAncestor(node: TestNode): boolean { + for (let cur = node.parent; cur !== undefined; cur = cur.parent) { + if (cur.todoFlag) return true; + } + return false; +} + +let runTestIdCounter = 0; +function runTestIdFor(node: TestNode): number { + if (node.runTestId === 0) node.runTestId = ++runTestIdCounter; + return node.runTestId; +} + +function runParentIdFor(node: TestNode): number { + const parent = node.parent; + return parent !== undefined && parent.parent !== undefined ? runTestIdFor(parent) : 0; +} + +function nextTestNumberFor(node: TestNode): number { + const parent = node.parent; + return parent !== undefined ? ++parent.reportedCount : 0; +} + +function reportQueueChain(node: TestNode) { + if (!runEventsEnabled()) return; + const chain: TestNode[] = []; + for (let cur: TestNode | undefined = node; cur !== undefined && cur.parent !== undefined; cur = cur.parent) { + if (cur.queueReported) break; + chain.push(cur); + } + for (let i = chain.length - 1; i >= 0; i--) { + const entry = chain[i]; + entry.queueReported = true; + const data = { + __proto__: null, + name: entry.name, + nesting: nestingOf(entry), + type: entry.isSuite ? "suite" : "test", + testId: runTestIdFor(entry), + parentId: runParentIdFor(entry), + tags: entry.tags, + }; + emitRunChildEvent("test:enqueue", data); + emitRunChildEvent("test:dequeue", data); + } +} + +function reportStartChain(node: TestNode) { + if (!runEventsEnabled()) return; + const chain: TestNode[] = []; + for (let cur: TestNode | undefined = node; cur !== undefined && cur.parent !== undefined; cur = cur.parent) { + if (cur.startReported) break; + chain.push(cur); + } + for (let i = chain.length - 1; i >= 0; i--) { + const entry = chain[i]; + entry.startReported = true; + entry.startedAtMs ||= performance.now(); + emitRunChildEvent("test:start", { + __proto__: null, + name: entry.name, + nesting: nestingOf(entry), + testId: runTestIdFor(entry), + parentId: runParentIdFor(entry), + tags: entry.tags, + }); + } +} + +function noteRunChildDone(parent: TestNode | undefined, failed: boolean) { + if (!runEventsEnabled()) return; + while (parent !== undefined && parent.parent !== undefined) { + parent.childrenDone++; + if (failed) parent.childrenFailed++; + if (!maybeCompleteSuite(parent)) return; + failed = parent.childrenFailed > 0; + parent = parent.parent; + } +} + +function maybeCompleteSuite(suite: TestNode): boolean { + if (!suite.isSuite || !suite.collectionSettled || suite.suiteReported) return false; + if (suite.childrenDone < suite.childrenCount) return false; + suite.suiteReported = true; + const isTodo = suite.todoFlag || hasTodoAncestor(suite); + if (isTodo) suite.childrenFailed = 0; + if (suite.skipped) suite.childrenFailed = 0; + const cancelledByHookFailure = !isTodo && hasHookFailedAncestorSuite(suite); + if (cancelledByHookFailure && suite.childrenFailed === 0) suite.childrenFailed = 1; + let suiteFailed = suite.childrenFailed > 0; + const failedCount = suite.childrenFailed; + const { expectFailure } = suite; + const xfail = expectFailure ? (expectFailure.label ?? true) : undefined; + let forcedError: Error | undefined; + if (expectFailure && !suiteFailed && !isTodo) { + suiteFailed = true; + suite.childrenFailed = 1; + forcedError = makeTestFailure("test was expected to fail but passed", "expectedFailure"); + } + const data = { + __proto__: null, + name: suite.name, + nesting: nestingOf(suite), + testNumber: nextTestNumberFor(suite), + testId: runTestIdFor(suite), + parentId: runParentIdFor(suite), + type: "suite", + skip: suite.skipped ? (suite.directiveMessage ?? true) : undefined, + todo: isTodo ? (suite.directiveMessage ?? true) : undefined, + expectFailure: xfail, + duration_ms: + !cancelledByHookFailure && suite.startedAtMs > 0 ? roundDurationMs(performance.now() - suite.startedAtMs) : 0, + tags: suite.tags, + error: cancelledByHookFailure + ? makeCancelledByParentError() + : suiteFailed + ? (forcedError ?? + (suite.error != null + ? wrapTestError(suite.error) + : makeTestFailure(`${failedCount} subtest${failedCount > 1 ? "s" : ""} failed`, "subtestsFailed"))) + : undefined, + }; + reportQueueChain(suite); + emitRunChildEvent("test:complete", { ...data, passed: !suiteFailed }); + emitRunChildEvent("test:plan", { + __proto__: null, + nesting: nestingOf(suite) + 1, + count: suite.childrenCount, }); + reportStartChain(suite); + emitRunChildEvent(suiteFailed ? "test:fail" : "test:pass", data); + return true; +} + +function noteSuiteCollectionSettled(suite: TestNode) { + if (!runEventsEnabled()) return; + suite.collectionSettled = true; + if (maybeCompleteSuite(suite)) { + noteRunChildDone(suite.parent, suite.childrenFailed > 0); + } +} + +function noteRunChildRegistered(parent: TestNode) { + if (!runChildReporterEnabled && !inStandaloneMode()) return; + if (parent.parent !== undefined) parent.childrenCount++; } // Called for every test node as its result is finalized, so subtests report // with the same shape as top-level tests. No-op outside a run() child. function reportNodeToRunParent(node: TestNode, startedAt: number) { - if (!runChildReporterEnabled || node.isSuite) return; - const { skipped, todoFlag, expectFailure } = node; + if (!runEventsEnabled() || node.isSuite) return; + const { skipped, expectFailure } = node; + const todoEffective = node.todoFlag || hasTodoAncestor(node); // node reports the xfail label when there is one, otherwise `true`. const xfail = !skipped && expectFailure ? (expectFailure.label ?? true) : undefined; + reportQueueChain(node); // node spreads a `directive` into the event: `skip: true` / `todo: true`, with // the other key absent entirely. - emitRunChildEvent(node.passed ? "test:pass" : "test:fail", { + const data = { __proto__: null, name: node.name, nesting: nestingOf(node), - testNumber: 0, - duration_ms: performance.now() - startedAt, - skip: skipped ? (node.message ?? true) : undefined, - todo: !skipped && todoFlag ? (node.message ?? true) : undefined, + testNumber: nextTestNumberFor(node), + testId: runTestIdFor(node), + parentId: runParentIdFor(node), + duration_ms: roundDurationMs(performance.now() - startedAt), + skip: skipped ? (node.directiveMessage ?? true) : undefined, + todo: !skipped && todoEffective ? (node.directiveMessage ?? true) : undefined, expectFailure: xfail, tags: node.tags, - error: node.passed ? undefined : serializeRunError(node.error), - }); + error: node.passed ? undefined : wrapTestError(node.error), + }; + emitRunChildEvent("test:complete", { ...data, passed: node.passed }); + const { reportedCount } = node; + if (reportedCount > 0) { + emitRunChildEvent("test:plan", { __proto__: null, nesting: nestingOf(node) + 1, count: reportedCount }); + } + reportStartChain(node); + emitRunChildEvent(node.passed ? "test:pass" : "test:fail", data); + noteRunChildDone(node.parent, !node.passed && !skipped && !todoEffective); } -// ----------------------------------------------------------------------------- -// MockTracker -// -// Port of Node.js lib/internal/test_runner/mock/mock.js (v26.3.0): -// https://github.com/nodejs/node/blob/50c35fea9e64d50ab3bb5f359e8523de89d6c798/lib/internal/test_runner/mock/mock.js -// API reference: https://nodejs.org/api/test.html#class-mocktracker -// ----------------------------------------------------------------------------- +// MockTracker — port of lib/internal/test_runner/mock/mock.js (v26.3.0). +// https://github.com/nodejs/node/blob/main/lib/internal/test_runner/mock/mock.js let trackMockCall: (ctx: MockFunctionContext, thisArg: unknown, args: unknown[], target: unknown) => unknown; class MockFunctionContext { @@ -803,11 +1241,6 @@ class MockFunctionContext { } restore() { - // node semantics: a method mock reinstalls the original descriptor but the - // context keeps its implementation (calling the detached mock function - // still uses it); a bare fn mock reverts to calling the original. Queued - // once-implementations survive, and restore() stays re-runnable so a - // still-tracked context can be restored again by reset(). if (this.#restore !== undefined) { this.#restore(); } else { @@ -893,19 +1326,21 @@ class MockPropertyContext { __proto__: null, configurable, enumerable, - get: () => { - const nextValue = this.#getAccessValue(this.#value); - this.#accesses.push({ - type: "get", - value: nextValue, - stack: new Error(), - }); - return nextValue; - }, + get: this.#recordGetAccess.bind(this), set: this.mockImplementation.bind(this), }); } + #recordGetAccess() { + const nextValue = this.#getAccessValue(this.#value); + this.#accesses.push({ + type: "get", + value: nextValue, + stack: new Error(), + }); + return nextValue; + } + get accesses() { return this.#accesses.slice(0); } @@ -1341,7 +1776,7 @@ function buildContextAssert(node: TestNode, ctx: TestContext) { // so `t.assert; t.plan(2); t.assert.ok(1)` counts 0 (nodejs/node // lib/internal/test_runner/test.js:331). Match that. const { plan } = node; - const add = (name: string, method: Function) => { + function add(name: string, method: Function) { const wrapper = function (...args: unknown[]) { plan?.count(); return method.$apply(ctx, args); @@ -1349,7 +1784,7 @@ function buildContextAssert(node: TestNode, ctx: TestContext) { // @ts-ignore Object.defineProperty(wrapper, "name", { __proto__: null, value: name, configurable: true }); result[name] = wrapper; - }; + } for (const key of Object.keys(nodeAssert)) { // CallTracker is also excluded: bun's node:assert still ships it (Node 26 // does not), and copying it would trigger its deprecation accessor. @@ -1383,6 +1818,7 @@ function makeTestFailure(message: string, failureType?: string) { const error = new Error(message); (error as { code?: string }).code = "ERR_TEST_FAILURE"; if (failureType !== undefined) (error as { failureType?: string }).failureType = failureType; + error.stack = `Error [ERR_TEST_FAILURE]: ${message}`; return error; } @@ -1424,22 +1860,42 @@ class TestPlan { return; } if (wait === false || wait === undefined || actual > expected) { - throw makeTestFailure(`plan expected ${expected} assertions but received ${actual}`); + throw makeTestFailure(`plan expected ${expected} assertions but received ${actual}`, "testCodeFailure"); } - return new Promise((resolve, reject) => { + const self = this; + function planWaitExecutor(resolve: () => void, reject: (err: unknown) => void) { let timer: ReturnType | undefined; + function onPlanWaitTimeout() { + self.#pending = undefined; + reject( + makeTestFailure( + `plan timed out after ${wait}ms with ${self.actual} assertions when expecting ${expected}`, + "testCodeFailure", + ), + ); + } if (typeof wait === "number") { - timer = realSetTimeout(() => { - this.#pending = undefined; - reject( - makeTestFailure(`plan timed out after ${wait}ms with ${this.actual} assertions when expecting ${expected}`), - ); - }, wait); + timer = realSetTimeout(onPlanWaitTimeout, wait); // Not unref'd: count()/cancel()/the timer callback always clear it, and // on Windows an unref'd timer alone under bun:test busy-spins (8664279d). + } else { + // wait === true: keep a ref'd handle so an .unref()'d user timer still + // fires on Windows while this await is the only work (see above). + timer = realSetTimeout(kDefaultFunction, kTimeoutMax); } - this.#pending = { resolve, reject, timer }; - }); + self.#pending = { resolve, reject, timer }; + } + return new Promise(planWaitExecutor); + } + + failPending(err: Error) { + const pending = this.#pending; + if (pending === undefined) return false; + this.#pending = undefined; + const { timer } = pending; + if (timer !== undefined) realClearTimeout(timer); + pending.reject(err); + return true; } // Mirrors count()'s cleanup for the stop-wins-race path: if the test-level @@ -1535,9 +1991,23 @@ class TestNode { mockTracker: MockTracker | null = null; skipped = false; todoFlag = false; - message: string | undefined = undefined; + onlyFlag = false; + directiveMessage: string | null = null; + abortController: AbortController | undefined; expectFailure: ExpectFailure = false; started = false; + childrenCount = 0; + childrenDone = 0; + childrenFailed = 0; + collectionSettled = false; + suiteReported = false; + queueReported = false; + startReported = false; + startedAtMs = 0; + hookSetupFailed = false; + reportedCount = 0; + runTestId = 0; + standaloneChildren: StandaloneEntry[] | undefined; finished = false; passed = false; error: unknown = null; @@ -1567,12 +2037,13 @@ class TestNode { // Direct children of the root capture the entry file at declaration time // (under `bun test` with multiple files, Bun.main is the file currently // being collected); nested tests inherit their parent's file. - this.filePath = parent !== undefined && parent.parent !== undefined ? parent.filePath : Bun.main; + this.filePath = + parent !== undefined && parent.parent !== undefined ? parent.filePath : (currentImportFile ?? Bun.main); const { skip, todo } = options; - this.skipped = !!skip; - this.todoFlag = !!todo || (parent?.todoFlag ?? false); - if (typeof skip === "string") this.message = skip; - else if (typeof todo === "string") this.message = todo; + this.skipped = skip !== undefined && skip !== false; + this.todoFlag = (todo !== undefined && todo !== false) || (parent?.todoFlag ?? false); + this.onlyFlag = !!options.only; + this.directiveMessage = typeof skip === "string" ? skip : typeof todo === "string" ? todo : null; this.expectFailure = parseExpectFailure(options.expectFailure) || parent?.expectFailure || false; } @@ -1644,10 +2115,6 @@ function getRootNode(): TestNode { // by a mock's restore) see an up-to-date root and don't reset again. rootNode = new TestNode(kRootName, undefined, kDefaultOptions, true, false); if (oldRoot !== undefined) { - // Node also scopes these per process: drop the previous file's - // module-level mocks and assert.register() additions with its root. - // The root's own mockTracker (reachable via a file-level before hook's - // `t.mock`) is distinct from the module-level `mock` export. oldRoot.mockTracker?.reset(); mock.reset(); customAssertions = { __proto__: null } as unknown as Record; @@ -1666,7 +2133,6 @@ function getRootNode(): TestNode { */ class TestContext { #node: TestNode; - #abortController?: AbortController; #assert: Record | undefined; constructor(node: TestNode) { @@ -1674,10 +2140,9 @@ class TestContext { } get signal(): AbortSignal { - if (this.#abortController === undefined) { - this.#abortController = new AbortController(); - } - return this.#abortController.signal; + const node = this.#node; + node.abortController ??= new AbortController(); + return node.abortController.signal; } get name(): string { @@ -1713,7 +2178,7 @@ class TestContext { } diagnostic(message: string) { - console.log(message); + emitContextDiagnostic(this.#node, message); } plan(count: number, options: { wait?: boolean | number } = kEmptyObject) { @@ -1741,12 +2206,12 @@ class TestContext { skip(message?: string) { this.#node.skipped = true; - if (typeof message === "string") this.#node.message = message; + if (typeof message === "string") this.#node.directiveMessage = message; } todo(message?: string) { this.#node.todoFlag = true; - if (typeof message === "string") this.#node.message = message; + if (typeof message === "string") this.#node.directiveMessage = message; } before(arg0: unknown, arg1: unknown) { @@ -1778,12 +2243,12 @@ class TestContext { validateNumber(interval, "options.interval", 0, kTimeoutMax); validateNumber(timeout, "options.timeout", 0, kTimeoutMax); - return new Promise((resolve, reject) => { + function waitForExecutor(resolve: (v: unknown) => void, reject: (err: unknown) => void) { let cause: unknown; let hasCause = false; let timedOut = false; let retry: ReturnType | undefined; - const timer = realSetTimeout(() => { + function onWaitForTimeout() { timedOut = true; // Cancel a pending retry so condition() is not invoked again after // reject (Node clears its pollerId in done()). @@ -1793,9 +2258,10 @@ class TestContext { (error as { cause?: unknown }).cause = cause; } reject(error); - }, timeout); + } + const timer = realSetTimeout(onWaitForTimeout, timeout); - const poll = async () => { + async function poll() { try { const result = await (condition as Function)(); if (timedOut) return; @@ -1807,9 +2273,10 @@ class TestContext { hasCause = true; retry = realSetTimeout(poll, interval); } - }; + } poll(); - }); + } + return new Promise(waitForExecutor); } test(arg0: unknown, arg1: unknown, arg2: unknown) { @@ -1862,7 +2329,7 @@ class SuiteContext { } diagnostic(message: string) { - console.log(message); + emitContextDiagnostic(this.#node, message); } } @@ -1967,7 +2434,7 @@ function parseExpectFailure(expectFailure: unknown): ExpectFailure { if (keys.length === 0) { throw $ERR_INVALID_ARG_VALUE("options.expectFailure", expectFailure, "must not be an empty object"); } - if (keys.every(k => k === "match" || k === "label")) { + if (keys.every(isExpectFailureShorthandKey)) { return { __proto__: null, label: (expectFailure as { label?: string }).label, @@ -1995,10 +2462,11 @@ function applyExpectFailure(node: TestNode, failure: unknown): unknown { wrapped.failureType === "testCodeFailure" && wrapped.cause !== undefined; const errorToCheck = unwrap ? wrapped.cause : failure; + function rethrowErrorToCheck() { + throw errorToCheck; + } try { - nodeAssert.throws(() => { - throw errorToCheck; - }, validation); + nodeAssert.throws(rethrowErrorToCheck, validation); } catch (e) { const error = makeTestFailure( "The test failed, but the error did not match the expected validation", @@ -2083,15 +2551,15 @@ function ancestorChain(node: TestNode): TestNode[] { } function invokeWithDoneCallback(fn: Function, arg: unknown) { - return new Promise((resolve, reject) => { + function doneCallbackExecutor(resolve: () => void, reject: (err: unknown) => void) { let returned = false; let returnedPromise = false; let doneCalled = false; let doneError: unknown; - const done = (err?: unknown) => { + function done(err?: unknown) { if (doneCalled) { // Node throws into the caller when the callback is invoked again. - throw makeTestFailure("callback invoked multiple times"); + throw makeTestFailure("callback invoked multiple times", "multipleCallbackInvocations"); } doneCalled = true; // A done() call made before the function returned is deferred, and one @@ -2106,14 +2574,15 @@ function invokeWithDoneCallback(fn: Function, arg: unknown) { } if (err) reject(err); else resolve(); - }; - const result = fn(arg, done); + } + const result = fn.$call(arg, arg, done); returned = true; if ($isPromise(result)) { // Node fails the test but still awaits the returned promise, so hooks // and later tests never race a still-running body. returnedPromise = true; - const fail = () => reject(makeTestFailure("passed a callback but also returned a Promise")); + const fail = () => + reject(makeTestFailure("passed a callback but also returned a Promise", "callbackAndPromisePresent")); (result as Promise).then(fail, fail); return; } @@ -2121,16 +2590,21 @@ function invokeWithDoneCallback(fn: Function, arg: unknown) { if (doneError) reject(doneError); else resolve(); } - }); + } + return new Promise(doneCallbackExecutor); } // Node passes a `done` callback when a test or hook function declares exactly // two parameters; completion is then done()'s call, not the returned value. +function invokeSuiteFn(fn: Function, ctx: unknown) { + return fn.$call(ctx, ctx); +} + function invokeTestFn(fn: Function, arg: unknown) { if (fn.length === 2) { return invokeWithDoneCallback(fn, arg); } - return fn(arg); + return fn.$call(arg, arg); } // A single timeout armed once per test and raced against both the body and @@ -2141,14 +2615,21 @@ function createStopController(timeout: number | undefined) { return undefined; } let timer: ReturnType; - const promise = new Promise((_, reject) => { + function stopExecutor(_: unknown, reject: (err: unknown) => void) { + function onStopTimeout() { + reject(makeTestFailure(`test timed out after ${timeout}ms`, "testTimeoutFailure")); + } // Not unref'd: dispose() always clears it, and on Windows an unref'd timer // alone under bun:test leaves the uws loop inactive so auto_tick busy-spins. - timer = realSetTimeout(() => reject(makeTestFailure(`test timed out after ${timeout}ms`)), timeout); - }); + timer = realSetTimeout(onStopTimeout, timeout); + } + const promise = new Promise(stopExecutor); // Swallow the rejection when nothing is racing it anymore. - promise.catch(() => {}); - return { promise, dispose: () => realClearTimeout(timer) }; + promise.catch(kDefaultFunction); + function dispose() { + realClearTimeout(timer); + } + return { promise, dispose }; } // Runs `run` racing Node's test timeout; the timer starts before the body so a @@ -2173,8 +2654,11 @@ async function raceWithTimeoutAndSignal( const racers: unknown[] = []; if (typeof timeout === "number" && Number.isFinite(timeout)) { racers.push( - new Promise((_, reject) => { - timer = realSetTimeout(() => reject(makeTestFailure(`test timed out after ${timeout}ms`)), timeout); + new Promise(function raceTimeoutExecutor(_, reject) { + function onRaceTimeout() { + reject(makeTestFailure(`test timed out after ${timeout}ms`, "testTimeoutFailure")); + } + timer = realSetTimeout(onRaceTimeout, timeout); }), ); } @@ -2184,8 +2668,11 @@ async function raceWithTimeoutAndSignal( } addAbortListener ??= require("internal/abort_listener").addAbortListener; racers.push( - new Promise((_, reject) => { - abortListener = addAbortListener(signal, () => reject(signal.reason)); + new Promise(function raceAbortExecutor(_, reject) { + function onRaceAbort() { + reject(signal.reason); + } + abortListener = addAbortListener(signal, onRaceAbort); }), ); } @@ -2199,9 +2686,16 @@ async function raceWithTimeoutAndSignal( } } -async function runHook(hook: Hook, owner: TestNode, arg: unknown) { +type HookKind = "before" | "after" | "beforeEach" | "afterEach"; + +async function runHook(hook: Hook, owner: TestNode, arg: unknown, kind: HookKind) { const { timeout, signal } = hook; - const run = () => runWithNode(owner, () => invokeTestFn(hook.fn as Function, arg)); + function invokeHookFn() { + return invokeTestFn(hook.fn as Function, arg); + } + function run() { + return runWithNode(owner, invokeHookFn); + } try { if (signal === undefined) { await awaitWithTimeout(run, timeout); @@ -2209,27 +2703,29 @@ async function runHook(hook: Hook, owner: TestNode, arg: unknown) { await raceWithTimeoutAndSignal(run, timeout, signal); } } catch (err) { - // A hook that throws a nullish value must still fail the owning test. - throw err ?? makeTestFailure("hook failed"); + // node wraps every hook failure once at the layer that ran it (Test#runHook): + // https://github.com/nodejs/node/blob/main/lib/internal/test_runner/test.js + throw wrapHookError(err ?? makeTestFailure("hook failed"), kind); } } // Node runs each before hook at most once (runOnce) and memoizes the outcome: // after a failure, every later subtest observes the same rejection. function runBeforeHookOnce(hook: Hook, owner: TestNode, arg: unknown): Promise { - return (hook.result ??= runHook(hook, owner, arg)); + return (hook.result ??= runHook(hook, owner, arg, "before")); } // Failures fail the owning test (Node: hook.error -> test.fail) instead of // poisoning the subtest chain, so they are reported even when nothing awaits. function scheduleImmediateBeforeHook(node: TestNode, hook: Hook, arg: unknown) { - node.subtestChain = node.subtestChain.then(async () => { + async function runImmediateBeforeHook() { try { await runBeforeHookOnce(hook, node, arg); } catch (err) { node.hookFailure ??= err; } - }); + } + node.subtestChain = node.subtestChain.then(runImmediateBeforeHook); } async function runOwnBeforeHooks(node: TestNode) { @@ -2253,12 +2749,86 @@ async function runOwnBeforeHooks(node: TestNode) { } } +type ExecutionEntry = { node: TestNode; fail: (err: Error) => void }; +const executionStack: ExecutionEntry[] = []; +let processErrorAttributionInstalled = false; +let testContextStorage: + | { run: (store: TestNode, fn: () => unknown) => unknown; getStore: () => TestNode | undefined } + | undefined; + +function getTestContextStorage() { + testContextStorage ??= new (require("node:async_hooks").AsyncLocalStorage)(); + return testContextStorage!; +} + +function attributeProcessError(err: unknown, failureType: string): void { + const store = testContextStorage?.getStore(); + let entry: ExecutionEntry | undefined; + if (store !== undefined && store.finished) { + console.error((err as Error)?.stack ?? err); + process.exitCode = 1; + return; + } + if (store !== undefined) { + function matchesStore(e: ExecutionEntry) { + return e.node === store; + } + entry = executionStack.find(matchesStore); + } + entry ??= executionStack[executionStack.length - 1]; + if (entry !== undefined) { + if (!entry.node.finished) { + const wrapper = wrapTestError(err) as { failureType?: string }; + wrapper.failureType = failureType; + entry.fail(wrapper as Error); + return; + } + console.error((err as Error)?.stack ?? err); + process.exitCode = 1; + return; + } + console.error((err as Error)?.stack ?? err); + process.exit(1); +} + +function attributeUncaught(err: unknown) { + return attributeProcessError(err, "uncaughtException"); +} +function attributeUnhandled(err: unknown) { + return attributeProcessError(err, "unhandledRejection"); +} + +function installProcessErrorAttribution() { + if (processErrorAttributionInstalled) return; + processErrorAttributionInstalled = true; + getTestContextStorage(); + process.on("uncaughtException", attributeUncaught); + process.on("unhandledRejection", attributeUnhandled); +} + +function uninstallProcessErrorAttribution() { + if (!processErrorAttributionInstalled) return; + processErrorAttributionInstalled = false; + process.off("uncaughtException", attributeUncaught); + process.off("unhandledRejection", attributeUnhandled); +} + async function executeTestNode(node: TestNode, fn: TestFn): Promise { // Runs a single test (top-level or subtest): inherited beforeEach hooks, the // body, pending subtests, the plan check, inherited afterEach hooks, and the // test's own after hooks. Returns the failure (if any) instead of throwing. + if (runEventsEnabled() && hasHookFailedAncestorSuite(node)) { + reportCancelledNode(node); + return undefined; + } node.started = true; - const started = runChildReporterEnabled ? performance.now() : 0; + const started = runEventsEnabled() ? performance.now() : 0; + if (started > 0) { + for (let cur = node.parent; cur !== undefined && cur.parent !== undefined; cur = cur.parent) { + if (cur.startedAtMs > 0) break; + cur.startedAtMs = started; + } + } const ctx = node.getCtx(); const ancestors = ancestorChain(node); let failure: unknown; @@ -2271,31 +2841,69 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise { node.plan = new TestPlan(planOption); } + let execEntry: ExecutionEntry | undefined; + let interrupt: { promise: Promise; reject: (err: Error) => void } | undefined; + if (runEventsEnabled()) { + installProcessErrorAttribution(); + let rejectInterrupt!: (err: Error) => void; + function interruptExecutor(_: unknown, reject: (err: Error) => void) { + rejectInterrupt = reject; + } + const interruptPromise = new Promise(interruptExecutor); + interruptPromise.catch(kDefaultFunction); + interrupt = { promise: interruptPromise, reject: rejectInterrupt }; + function failExecEntry(err: Error) { + if (node.finished) return; + node.hookFailure ??= err; + node.plan?.failPending(err); + interrupt!.reject(err); + } + execEntry = { node, fail: failExecEntry }; + executionStack.push(execEntry); + } + try { for (const ancestor of ancestors) { for (const hook of ancestor.hooks.beforeEach) { - await runHook(hook, ancestor, ctx); + await runHook(hook, ancestor, ctx, "beforeEach"); } } } catch (err) { failure = err; } + failure ??= node.hookFailure; + if (failure === undefined) { // Node arms one stopPromise (timeout + signal) and races both the body // AND the plan wait against it. Arm timeout once here so plan({wait:true}) // is bounded by the same test timeout, not left unbounded. const stop = createStopController(node.options.timeout); try { - const runBody = async () => { - await runWithNode(node, () => invokeTestFn(fn, ctx)); + function invokeBodyFn() { + return invokeTestFn(fn, ctx); + } + function invoke() { + return runWithNode(node, invokeBodyFn); + } + async function runBody() { + await (execEntry !== undefined ? getTestContextStorage().run(node, invoke) : invoke()); // Wait for inline subtests created during the body (awaited or not), // including ones scheduled while earlier subtests were running. await drainSubtestChain(node); - }; + } + + function raceExternal(p: unknown) { + const racers: unknown[] = []; + if (stop !== undefined) racers.push(stop.promise); + if (interrupt !== undefined) racers.push(interrupt.promise); + if (racers.length === 0) return p; + racers.push(p); + return Promise.race(racers as Promise[]); + } try { - await (stop === undefined ? runBody() : Promise.race([stop.promise, runBody()])); + await raceExternal(runBody()); } catch (err) { // A body that throws or rejects with a nullish value must still fail. failure = err ?? makeTestFailure("test failed"); @@ -2312,13 +2920,13 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise { if (pending !== undefined) { // Defuse: if stop wins the race, plan's own wait-timeout may still // reject `pending` afterward with no one listening. - pending.catch(() => {}); - await (stop === undefined ? pending : Promise.race([stop.promise, pending])); + pending.catch(kDefaultFunction); + await raceExternal(pending); // A t.test() that fulfilled the plan from an async callback was // scheduled onto subtestChain during the wait; drain again so its // failure reaches failedSubtests below (Node fails the parent). const drain = drainSubtestChain(node); - await (stop === undefined ? drain : Promise.race([stop.promise, drain])); + await raceExternal(drain); } } catch (err) { failure = err; @@ -2329,6 +2937,8 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise { node.plan?.cancel(); } + failure ??= node.hookFailure; + const { failedSubtests, firstSubtestError } = node; if (failure === undefined && failedSubtests > 0) { const error = makeTestFailure( @@ -2342,6 +2952,10 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise { } } + if ((failure as { failureType?: string } | undefined)?.failureType === "testTimeoutFailure") { + (node.abortController ??= new AbortController()).abort(); + } + const bodyFailure = failure; failure = applyExpectFailure(node, failure); const acceptedXfail = bodyFailure !== undefined && failure === undefined; @@ -2359,7 +2973,7 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise { const ancestor = ancestors[i]; for (const hook of ancestor.hooks.afterEach) { try { - await runHook(hook, ancestor, ctx); + await runHook(hook, ancestor, ctx, "afterEach"); } catch (err) { if (!acceptedXfail) failure ??= err; } @@ -2368,12 +2982,17 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise { for (const hook of node.hooks.after) { try { - await runHook(hook, node, ctx); + await runHook(hook, node, ctx, "after"); } catch (err) { if (!acceptedXfail) failure ??= err; } } + if (execEntry !== undefined) { + const at = executionStack.lastIndexOf(execEntry); + if (at !== -1) executionStack.splice(at, 1); + } + try { node.mockTracker?.reset(); } catch (err) { @@ -2387,8 +3006,9 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise { } function scheduleSubtest(parent: TestNode, child: TestNode, fn: TestFn, ownTodo: boolean): Promise { - const run = async () => { - if (child.options.skip) { + async function run() { + // Presence-based like the constructor: {skip: ''} is a directive too. + if (child.skipped) { child.finished = true; child.passed = true; return; @@ -2407,9 +3027,9 @@ function scheduleSubtest(parent: TestNode, child: TestNode, fn: TestFn, ownTodo: parent.failedSubtests++; parent.firstSubtestError ??= failure; } - }; + } const result = (parent.subtestChain = parent.subtestChain.then(run)); - return result.then(() => undefined); + return result.then(returnUndefined); } function recordSuiteFailure(suite: TestNode, err: unknown) { @@ -2434,7 +3054,7 @@ function scheduleSuiteSubtest(parent: TestNode, suite: TestNode, build: unknown, // A describe()/suite() created while a test is running becomes a suite // subtest: its children were collected eagerly when the callback ran and are // already chained on the suite's own subtestChain; failures roll up here. - const run = async () => { + async function run() { if (build !== undefined) { try { // An async describe() callback that rejects fails the suite (Node @@ -2454,41 +3074,27 @@ function scheduleSuiteSubtest(parent: TestNode, suite: TestNode, build: unknown, await drainSubtestChain(suite); for (const hook of suite.hooks.after) { try { - await runHook(hook, suite, suite.getSuiteCtx()); + await runHook(hook, suite, suite.getSuiteCtx(), "after"); } catch (err) { recordSuiteFailure(suite, err); } } suite.finished = true; suite.passed = suite.failedSubtests === 0; - if (runChildReporterEnabled) { - emitRunChildEvent(suite.passed ? "test:pass" : "test:fail", { - __proto__: null, - name: suite.name, - nesting: nestingOf(suite), - testNumber: 0, - duration_ms: 0, - type: "suite", - tags: suite.tags, - todo: suite.todoFlag ? (suite.message ?? true) : undefined, - error: suite.passed - ? undefined - : serializeRunError( - makeTestFailure( - `${suite.failedSubtests} subtest${suite.failedSubtests > 1 ? "s" : ""} failed`, - "subtestsFailed", - ), - ), - }); - } // A todo suite's failures do not fail the owning test (Node). if (suite.failedSubtests > 0 && !ownTodo) { parent.failedSubtests++; parent.firstSubtestError ??= suite.firstSubtestError; } - }; + if (runEventsEnabled()) { + suite.childrenCount = suite.reportedCount; + suite.childrenDone = suite.reportedCount; + if (!suite.passed) suite.childrenFailed ||= suite.failedSubtests; + noteSuiteCollectionSettled(suite); + } + } const result = (parent.subtestChain = parent.subtestChain.then(run)); - return result.then(() => undefined); + return result.then(returnUndefined); } // ----------------------------------------------------------------------------- @@ -2499,11 +3105,496 @@ function bunTest() { return jest(Bun.main); } +// https://github.com/nodejs/node/blob/main/lib/internal/test_runner/harness.js +type StandaloneEntry = { + node: TestNode; + fn: TestFn; + isSuite: boolean; + mode?: "skip"; + build?: Promise; + importError?: unknown; +}; + +const kImportFailedFn: TestFn = function importFailedNoop() {}; + +let standaloneActive = false; +let standaloneScheduled = false; +let inProcessRunActive = false; +let currentImportFile: string | null = null; +let activeRunFile: string | null = null; +const standaloneQueue: StandaloneEntry[] = []; + +function inStandaloneMode(): boolean { + if (inProcessRunActive) return true; + if (standaloneActive) return true; + if (runChildReporterEnabled) return false; + return fileGeneration() === 0; +} + +function standaloneRegister(entry: StandaloneEntry) { + standaloneActive = true; + if (inProcessRunActive) { + standaloneScheduled = true; + } + const parent = entry.node.parent; + if (parent !== undefined && parent.parent !== undefined) { + (parent.standaloneChildren ??= []).push(entry); + } else { + standaloneQueue.push(entry); + } + if (!standaloneScheduled) { + standaloneScheduled = true; + process.once("beforeExit", runStandalone); + } +} + +async function executeStandaloneQueue(root: TestNode): Promise { + let hookError: unknown; + const rootArg = hookArgFor(root); + for (const hook of root.hooks.before) { + try { + await runBeforeHookOnce(hook, root, rootArg); + } catch (err) { + hookError = err; + break; + } + } + if (hookError === undefined) { + for (let i = 0; i < standaloneQueue.length; i++) { + await runStandaloneEntry(standaloneQueue[i]); + } + } else { + for (const entry of standaloneQueue) { + const { node, importError } = entry; + activeRunFile = node.filePath ?? null; + if (importError !== undefined) reportFailedImportNode(node, importError); + else reportCancelledNode(node); + } + } + standaloneQueue.length = 0; + for (const hook of root.hooks.after) { + try { + await runHook(hook, root, rootArg, "after"); + } catch (err) { + hookError ??= err; + } + } + return hookError; +} + +async function awaitSuiteBuilds(entries: StandaloneEntry[]): Promise { + for (const entry of entries) { + const { build } = entry; + if (build !== undefined) { + try { + await build; + } catch {} + } + const children = entry.node.standaloneChildren; + if (children !== undefined) await awaitSuiteBuilds(children); + } +} + +function entryHasOnly(entry: StandaloneEntry): boolean { + if (entry.node.onlyFlag) return true; + for (const child of entry.node.standaloneChildren ?? []) { + if (entryHasOnly(child)) return true; + } + return false; +} + +function standaloneQueueHasOnly(entries: StandaloneEntry[]): boolean { + return entries.some(entryHasOnly); +} + +function pruneToOnly(entries: StandaloneEntry[]): StandaloneEntry[] { + const kept: StandaloneEntry[] = []; + for (const entry of entries) { + if (entry.importError !== undefined) { + kept.push(entry); + continue; + } + const children = entry.node.standaloneChildren ?? []; + if (entry.node.onlyFlag) { + if (entry.isSuite && children.some(entryHasOnly)) { + const keptChildren = pruneToOnly(children); + entry.node.standaloneChildren = keptChildren; + entry.node.childrenCount = keptChildren.length; + } + kept.push(entry); + continue; + } + if (!entry.isSuite) continue; + if (!children.some(entryHasOnly)) continue; + const keptChildren = pruneToOnly(children); + entry.node.standaloneChildren = keptChildren; + entry.node.childrenCount = keptChildren.length; + kept.push(entry); + } + return kept; +} + +function tagsMatchFilters(tags: string[], filters: string[]): boolean { + for (const tag of tags) { + if (filters.includes(tag)) return true; + } + return false; +} + +function pruneStandaloneEntries(entries: StandaloneEntry[], filters: string[]): StandaloneEntry[] { + const kept: StandaloneEntry[] = []; + for (const entry of entries) { + if (!entry.isSuite) { + if (entry.importError !== undefined || tagsMatchFilters(entry.node.tags, filters)) kept.push(entry); + continue; + } + const keptChildren = pruneStandaloneEntries(entry.node.standaloneChildren ?? [], filters); + entry.node.standaloneChildren = keptChildren; + entry.node.childrenCount = keptChildren.length; + if (keptChildren.length > 0) kept.push(entry); + } + return kept; +} + +async function runFilesInProcess(opts: ReturnType, reporter: TestsStream) { + const started = performance.now(); + const counts = makeRunCounts(); + const callerEntries = standaloneQueue.splice(0, standaloneQueue.length); + const wasStandaloneActive = standaloneActive; + const wasScheduled = standaloneScheduled; + const hadAttribution = processErrorAttributionInstalled; + const callerRoot = getRootNode(); + const savedRootHooks = callerRoot.hooks; + const savedRootReportedCount = callerRoot.reportedCount; + const savedSink = standaloneSink; + callerRoot.hooks = { before: [], after: [], beforeEach: [], afterEach: [] }; + callerRoot.reportedCount = 0; + + // Callers attach listeners synchronously on the returned stream; yield first. + await Promise.resolve(); + + try { + if (typeof opts.setup === "function") await opts.setup(reporter); + + const files = discoverRunFiles(opts); + const numbering = { verdictNumber: 0 }; + standaloneSink = inProcessSinkImpl.bind(undefined, reporter, counts, numbering); + callerRoot.started = true; + try { + for (const file of files) { + if (file === Bun.main) { + process.emitWarning( + "node:test run() is being called recursively within a test file. skipping running files.", + ); + continue; + } + currentImportFile = file; + try { + await import(file); + } catch (err) { + const fileNode = new TestNode(file, callerRoot, kDefaultOptions, false, false); + fileNode.filePath = file; + standaloneQueue.push({ + node: fileNode, + fn: kImportFailedFn, + isSuite: false, + importError: wrapTestError(err), + }); + } + } + } finally { + currentImportFile = null; + } + + await awaitSuiteBuilds(standaloneQueue); + const filters = opts.testTagFilterExpressions as string[] | null; + if (filters !== null && filters.length > 0) { + const pruned = pruneStandaloneEntries(standaloneQueue, filters); + standaloneQueue.length = 0; + standaloneQueue.push(...pruned); + } + + if (standaloneQueueHasOnly(standaloneQueue)) { + const pruned = pruneToOnly(standaloneQueue); + standaloneQueue.length = 0; + standaloneQueue.push(...pruned); + } + + const hookError = await executeStandaloneQueue(callerRoot); + if (hookError !== undefined) { + console.error(hookError); + counts.failed++; + } + const durationMs = roundDurationMs(performance.now() - started); + reporter.emitMessage("test:plan", { __proto__: null, nesting: 0, count: counts.topLevel }); + emitRunDiagnostics(reporter, counts, durationMs); + reporter.emitMessage("test:summary", { + __proto__: null, + success: runSucceeded(counts), + counts: publicRunCounts(counts), + duration_ms: durationMs, + file: undefined, + }); + } catch (err) { + restoreAfterInProcessRun(); + reporter.destroy(err as Error); + return; + } + restoreAfterInProcessRun(); + reporter.endStream(); + + function restoreAfterInProcessRun() { + inProcessRunActive = false; + standaloneSink = savedSink; + activeRunFile = null; + callerRoot.started = false; + callerRoot.hooks = savedRootHooks; + callerRoot.reportedCount = savedRootReportedCount; + standaloneQueue.push(...callerEntries); + standaloneActive = wasStandaloneActive || callerEntries.length > 0; + standaloneScheduled = wasScheduled; + if (!hadAttribution) uninstallProcessErrorAttribution(); + } +} + +function inProcessSinkImpl( + reporter: TestsStream, + counts: Record, + numbering: { verdictNumber: number }, + type: string, + data: unknown, +) { + republishChildEvent({ type, data }, activeRunFile ?? currentImportFile ?? Bun.main, reporter, counts, numbering); +} + +async function runStandalone() { + const stream = createTestsStream(); + const counts = makeRunCounts(); + const startedAt = performance.now(); + + standaloneSink = standaloneSinkImpl.bind(undefined, stream, counts, { verdictNumber: 0 }); + + const reporterFlush: Promise[] = []; + await attachStandaloneReporters(stream, reporterFlush); + const reporterDone = Promise.all(reporterFlush); + const root = getRootNode(); + + await awaitSuiteBuilds(standaloneQueue); + if (standaloneQueueHasOnly(standaloneQueue)) { + const pruned = pruneToOnly(standaloneQueue); + standaloneQueue.length = 0; + standaloneQueue.push(...pruned); + } + + try { + const hookError = await executeStandaloneQueue(root); + if (hookError !== undefined) { + console.error(hookError); + counts.failed++; + } + } catch (err) { + console.error(err); + counts.failed++; + } finally { + const durationMs = roundDurationMs(performance.now() - startedAt); + stream.emitMessage("test:plan", { __proto__: null, nesting: 0, count: root.reportedCount }); + emitRunDiagnostics(stream, counts, durationMs); + stream.emitMessage("test:summary", { + __proto__: null, + success: runSucceeded(counts), + counts: publicRunCounts(counts), + duration_ms: durationMs, + file: undefined, + }); + stream.endStream(); + standaloneSink = null; + await reporterDone; + if (!runSucceeded(counts)) process.exitCode = 1; + if (process.execArgv.includes("--test-force-exit")) { + process.exit(process.exitCode ?? 0); + } + } +} + +function standaloneSinkImpl( + stream: TestsStream, + counts: Record, + numbering: { verdictNumber: number }, + type: string, + data: unknown, +) { + republishChildEvent({ type, data }, Bun.main, stream, counts, numbering); +} + +async function runStandaloneEntry(entry: StandaloneEntry) { + const { node, fn, isSuite, mode, importError } = entry; + activeRunFile = node.filePath ?? null; + if (importError !== undefined) { + reportFailedImportNode(node, importError); + return; + } + if (mode === "skip") { + if (isSuite) node.suiteReported = true; + reportDirectiveOnlyNode(node, "skip"); + return; + } + if (!isSuite) { + await executeTestNode(node, fn); + return; + } + if (node.isSuite && node.skipped) { + for (const child of node.standaloneChildren ?? []) { + reportCancelledNode(child.node); + } + noteSuiteCollectionSettled(node); + return; + } + node.startedAtMs = performance.now(); + const isTodoSuite = node.todoFlag || hasTodoAncestor(node); + let setupFailed = !isTodoSuite && node.error != null; + const { build } = entry; + if (build !== undefined) { + try { + await build; + } catch (err) { + if (!isTodoSuite) { + node.childrenFailed++; + node.error ??= err; + setupFailed = true; + } + } + } + if (!setupFailed) { + for (const hook of node.hooks.before) { + try { + await runHook(hook, node, node.getSuiteCtx(), "before"); + } catch (err) { + if (!isTodoSuite) { + node.childrenFailed++; + node.error ??= err; + setupFailed = true; + break; + } + } + } + } + if (setupFailed) { + for (const child of node.standaloneChildren ?? []) { + reportCancelledNode(child.node); + } + } else { + for (const child of node.standaloneChildren ?? []) { + await runStandaloneEntry(child); + } + } + for (const hook of node.hooks.after) { + try { + await runHook(hook, node, node.getSuiteCtx(), "after"); + } catch (err) { + if (!isTodoSuite) { + node.childrenFailed++; + node.error ??= err; + } + } + } + noteSuiteCollectionSettled(node); +} + +async function attachStandaloneReporters(stream: TestsStream, promises: Promise[]): Promise { + const reporters = require("node:test/reporters"); + const names: string[] = []; + const destinationNames: string[] = []; + const argv = process.execArgv; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg.startsWith("--test-reporter=")) names.push(arg.slice("--test-reporter=".length)); + else if (arg === "--test-reporter" && i + 1 < argv.length) names.push(argv[++i]); + else if (arg.startsWith("--test-reporter-destination=")) + destinationNames.push(arg.slice("--test-reporter-destination=".length)); + else if (arg === "--test-reporter-destination" && i + 1 < argv.length) destinationNames.push(argv[++i]); + } + if (names.length === 0 && destinationNames.length === 0) { + names.push("spec"); + destinationNames.push("stdout"); + } else if (names.length === 1 && destinationNames.length === 0) { + destinationNames.push("stdout"); + } else if (names.length !== destinationNames.length) { + console.error( + $ERR_INVALID_ARG_VALUE( + "--test-reporter", + names, + "must match the number of specified '--test-reporter-destination'", + ), + ); + process.exit(1); + } + + const { PassThrough, compose } = require("node:stream"); + const { createWriteStream } = require("node:fs"); + const path = require("node:path"); + for (let i = 0; i < names.length; i++) { + const name = names[i]; + let reporter = Object.hasOwn(reporters, name) ? (reporters as Record)[name] : undefined; + if (reporter === undefined) { + try { + const mod = await import(name.startsWith(".") ? path.resolve(process.cwd(), name) : name); + reporter = mod.default ?? mod; + } catch (err) { + console.error(err); + process.exitCode = 1; + continue; + } + } + // The own-constructor identity check keeps bundled async generators (whose + // shared prototype carries an AsyncGeneratorFunction constructor) as-is. + if ( + (reporter as { prototype?: object })?.prototype && + Object.getOwnPropertyDescriptor((reporter as { prototype: object }).prototype, "constructor")?.value === reporter + ) { + try { + reporter = new (reporter as new () => unknown)(); + } catch (err) { + console.error(err); + process.exitCode = 1; + continue; + } + } + if (typeof reporter !== "function" && !(reporter && typeof (reporter as { pipe?: unknown }).pipe === "function")) { + console.error($ERR_INVALID_ARG_TYPE("Reporter", ["function", "stream"], reporter)); + process.exitCode = 1; + continue; + } + const destinationName = destinationNames[i]; + const destination = + destinationName === "stdout" + ? process.stdout + : destinationName === "stderr" + ? process.stderr + : createWriteStream(path.resolve(process.cwd(), destinationName)); + const endDestination = destination !== process.stdout && destination !== process.stderr; + const copy = new PassThrough({ objectMode: true }); + stream.pipe(copy); + function reporterPipeExecutor(resolvePromise: () => void) { + function surfaceReporterError(err: Error) { + console.error(err?.stack ?? err); + process.exitCode = 1; + resolvePromise(); + } + const composed = compose(copy, reporter); + composed.on("error", surfaceReporterError); + composed.pipe(destination, { end: endDestination }); + if (endDestination) { + destination.on("finish", resolvePromise); + destination.on("error", surfaceReporterError); + } else { + composed.on("end", resolvePromise); + } + } + promises.push(new Promise(reporterPipeExecutor)); + } +} + function bunTestOptions(options: TestOptions) { - // The node-style timeout is enforced by executeTestNode itself so that a - // tiny timeout (e.g. 1ms) with a synchronous body still passes like in Node. - // bun:test's own watchdog measures the whole wrapper, so it is only told - // about timeouts that extend past its 5s default. const { timeout } = options; if (timeout === Infinity) { // Node's "no timeout" must override bun:test's default (bun saturates it). @@ -2528,30 +3619,25 @@ function currentCollectionParent(): TestNode { function createTopLevelTestRunner(node: TestNode, fn: TestFn, declaredTodo = false) { // bun:test invokes this with a `done` callback because the function declares // one parameter. - return (done: (error?: unknown) => void) => { - // Under plain bun:test a describe.todo scope already handles its children's - // todo verdict (FailBecauseTodoPassed under --todo), so don't override when - // the flag was only inherited; under a run() child the suite registers as a - // plain describe so bun:test has no todo scope to consult. - const todoBefore = node.todoFlag; - executeTestNode(node, fn).then( - failure => { - // A runtime t.skip()/t.todo() overrides bun:test's pass/fail accounting - // (Node counts these as skip/todo even when the body threw); a declared - // todo body's failure must reach bun:test's own todo accounting instead. - if (node.skipped) { - markCurrentResult(false, done); - } else if (node.todoFlag && !declaredTodo && (runChildReporterEnabled || !todoBefore)) { - markCurrentResult(true, done); - } else { - done(failure); - return; - } - done(undefined); - }, - err => done(err), - ); - }; + function onTopLevelSettled(failure: unknown, todoBefore: boolean, done: (error?: unknown) => void) { + if (node.skipped) { + markCurrentResult(false, done); + } else if ((node.todoFlag || hasTodoAncestor(node)) && !declaredTodo && (runChildReporterEnabled || !todoBefore)) { + markCurrentResult(true, done); + } else { + done(failure); + return; + } + done(undefined); + } + function topLevelRunner(done: (error?: unknown) => void) { + const todoBefore = node.todoFlag || hasTodoAncestor(node); + function onFulfilled(failure: unknown) { + onTopLevelSettled(failure, todoBefore, done); + } + executeTestNode(node, fn).then(onFulfilled, done); + } + return topLevelRunner; } function addTest( @@ -2559,7 +3645,7 @@ function addTest( arg1: unknown, arg2: unknown, executionParent: TestNode | undefined, - mode?: "skip" | "todo", + mode?: "skip" | "todo" | "only", ): Promise { const { name, options, fn } = parseTestArgs(arg0, arg1, arg2); const { ownTags } = validateTestOptions(options); @@ -2575,14 +3661,13 @@ function addTest( // Subtest of a running test (or of an inline suite created inside one). const child = new TestNode(name, runningNode, options, false, true); child.ownTags = ownTags; - if (mode === "skip" || options.skip) { - // Chain onto subtestChain so the directive lands after earlier siblings. - const chained = (runningNode.subtestChain = runningNode.subtestChain.then(() => - reportDirectiveOnlyNode(child, "skip"), + if (mode === "skip" || child.skipped) { + const chained = (runningNode.subtestChain = runningNode.subtestChain.then( + reportDirectiveOnlyNode.bind(undefined, child, "skip"), )); - return chained.then(() => undefined); + return chained.then(returnUndefined); } - const ownTodo = mode === "todo" || !!options.todo; + const ownTodo = mode === "todo" || (options.todo !== undefined && options.todo !== false); if (ownTodo) child.todoFlag = true; return scheduleSubtest(runningNode, child, fn, ownTodo); } @@ -2592,31 +3677,38 @@ function addTest( const parent = currentCollectionParent(); const node = new TestNode(name, parent, options, false, false); node.ownTags = ownTags; + if (mode === "only") node.onlyFlag = true; + + // https://github.com/nodejs/node/blob/main/lib/internal/test_runner/test.js + // node.skipped is presence-based ({skip: ''} is a directive), so gate on it + // rather than re-deriving truthily from options.skip. + const effectiveMode = mode === "skip" || node.skipped ? "skip" : mode === "todo" || options.todo ? "todo" : undefined; + + if (inStandaloneMode()) { + noteRunChildRegistered(parent); + if (effectiveMode === "skip") { + standaloneRegister({ node, fn, isSuite: false, mode: "skip" }); + } else { + if (effectiveMode === "todo") node.todoFlag = true; + standaloneRegister({ node, fn, isSuite: false }); + } + return Promise.resolve(undefined); + } + noteRunChildRegistered(parent); const { test } = bunTest(); const passOptions = bunTestOptions(options); - // Node merges .todo()/.skip() into the options and checks skip first, so - // test.todo(name, { skip: true }, fn) is a skip. - const effectiveMode = mode === "skip" || options.skip ? "skip" : mode === "todo" || options.todo ? "todo" : undefined; + if (hasSkippedAncestorSuite(node)) { + function cancelledRunner(done: (error?: unknown) => void) { + reportCancelledNode(node); + done(makeCancelledByParentError()); + } + test(name, cancelledRunner); + return Promise.resolve(undefined); + } if (effectiveMode === "todo" || effectiveMode === "skip") { - // Under a run() child, register skip as an ordinary test so its directive - // event fires in execution order (not at collection time). - if (runChildReporterEnabled && effectiveMode === "skip") { - const runner = function (done: (err?: unknown) => void) { - reportDirectiveOnlyNode(node, "skip"); - markCurrentResult(false, done); - done(undefined); - }; - if (passOptions !== undefined) test(name, runner, passOptions); - else test(name, runner); - return Promise.resolve(undefined); - } - // Node runs a todo body, so `t.skip()` inside one still changes the - // directive it reports. bun:test only runs todo bodies under --todo, so a - // run() child registers them as ordinary tests and marks the result at the - // end (what createTopLevelTestRunner already does for a runtime t.todo()). if (runChildReporterEnabled && effectiveMode === "todo") { // The test.todo() spelling carries the directive in `mode`, not in the // options, so the node has to be marked for the runner to report it. @@ -2626,6 +3718,16 @@ function addTest( else test(name, runner); return Promise.resolve(undefined); } + if (runChildReporterEnabled) { + function directiveRunner(done: (error?: unknown) => void) { + reportDirectiveOnlyNode(node, effectiveMode); + markCurrentResult(false, done); + done(undefined); + } + if (passOptions !== undefined) test(name, directiveRunner, passOptions); + else test(name, directiveRunner); + return Promise.resolve(undefined); + } const register = effectiveMode === "todo" ? test.todo : test.skip; // Node runs todo bodies; bun:test only does so under --todo. const body = effectiveMode === "todo" ? createTopLevelTestRunner(node, fn, true) : kDefaultFunction; @@ -2647,10 +3749,6 @@ function addTest( test(name, runner); } - // Resolved eagerly rather than when the runner settles: bun:test never invokes - // the runner for a test `--test-name-pattern` filters out, so a deferred tied - // to it would hang an awaiting caller forever. Node resolves those too, and - // the timing is unobservable under bun:test's collect-then-execute model. return Promise.resolve(undefined); } @@ -2659,7 +3757,7 @@ function addSuite( arg1: unknown, arg2: unknown, executionParent?: TestNode, - mode?: "skip" | "todo", + mode?: "skip" | "todo" | "only", ): Promise { const { name, options, fn } = parseTestArgs(arg0, arg1, arg2); const { ownTags } = validateTestOptions(options); @@ -2671,27 +3769,27 @@ function addSuite( if (runningNode !== undefined && runningNode.isRunning()) { const suite = new TestNode(name, runningNode, options, true, true); suite.ownTags = ownTags; - if (mode === "skip" || options.skip) { - // Chain onto subtestChain so the directive lands after earlier siblings. - const chained = (runningNode.subtestChain = runningNode.subtestChain.then(() => - reportDirectiveOnlyNode(suite, "skip"), + if (mode === "skip" || suite.skipped) { + const chained = (runningNode.subtestChain = runningNode.subtestChain.then( + reportDirectiveOnlyNode.bind(undefined, suite, "skip"), )); - return chained.then(() => undefined); + return chained.then(returnUndefined); } - const ownTodo = mode === "todo" || !!options.todo; + const ownTodo = mode === "todo" || (options.todo !== undefined && options.todo !== false); if (ownTodo) suite.todoFlag = true; - // The suite's children must run after the parent's previously scheduled - // subtests AND after the describe callback's own returned promise settles - // (Node's Suite.run awaits buildPromise before iterating subtests). The - // callback has not returned yet so its promise does not exist; seed the - // chain through a gate the callback's settlement opens. const gate = Promise.withResolvers(); - suite.subtestChain = runningNode.subtestChain.then(() => gate.promise); + function awaitSuiteGate() { + return gate.promise; + } + suite.subtestChain = runningNode.subtestChain.then(awaitSuiteGate); // Build the suite eagerly (Node also runs describe callbacks immediately), // collecting children onto the suite's own subtest chain. let build: unknown; try { - build = runWithNode(suite, () => fn(suite.getSuiteCtx())); + function buildSuiteFn() { + return invokeSuiteFn(fn, suite.getSuiteCtx()); + } + build = runWithNode(suite, buildSuiteFn); } catch (err) { // The callback threw after possibly registering children: fail the suite // but still schedule it so those children are awaited and rolled up. @@ -2711,20 +3809,114 @@ function addSuite( const parent = currentCollectionParent(); const suiteNode = new TestNode(name, parent, options, true, false); suiteNode.ownTags = ownTags; + if (mode === "only") suiteNode.onlyFlag = true; + noteRunChildRegistered(parent); - const { describe } = bunTest(); + // https://github.com/nodejs/node/blob/main/lib/internal/test_runner/test.js + // Presence-based like addTest: {skip: ''} means the callback never runs. + const effectiveMode = + mode === "skip" || suiteNode.skipped ? "skip" : mode === "todo" || options.todo ? "todo" : undefined; + + if (inStandaloneMode()) { + if (effectiveMode === "skip") { + standaloneRegister({ node: suiteNode, fn, isSuite: true, mode: "skip" }); + return Promise.resolve(undefined); + } + if (effectiveMode === "todo") suiteNode.todoFlag = true; + let build: unknown; + try { + function buildSuiteNodeFn() { + return invokeSuiteFn(fn, suiteNode.getSuiteCtx()); + } + build = runWithNode(suiteNode, buildSuiteNodeFn); + } catch (err) { + suiteNode.childrenFailed++; + suiteNode.error = err; + } + const entry: StandaloneEntry = { node: suiteNode, fn, isSuite: true }; + if (build != null && typeof (build as PromiseLike).then === "function") { + const pending = build as Promise; + pending.catch(kDefaultFunction); + entry.build = pending; + } + standaloneRegister(entry); + return Promise.resolve(undefined); + } - // Node merges .todo()/.skip() into the options and checks skip first, so - // describe.todo(name, { skip: true }, fn) is a skip. - const effectiveMode = mode === "skip" || options.skip ? "skip" : mode === "todo" || options.todo ? "todo" : undefined; + const { describe } = bunTest(); // Node never invokes a skipped suite's callback (it does run a todo one), so // the children are never declared and side effects in the body never happen. const wrapped = effectiveMode === "skip" ? kDefaultFunction - : () => { - return runWithNode(suiteNode, () => fn(suiteNode.getSuiteCtx())); + : function wrappedSuiteBuilder() { + const isTodoAdvisory = runChildReporterEnabled && (suiteNode.todoFlag || hasTodoAncestor(suiteNode)); + function buildWrappedSuiteFn() { + return invokeSuiteFn(fn, suiteNode.getSuiteCtx()); + } + function settleSuiteAfterHooks() { + if (!runEventsEnabled()) { + noteSuiteCollectionSettled(suiteNode); + return; + } + const { afterAll } = bunTest(); + afterAll(function settleSuite(done: (error?: unknown) => void) { + // Settle asynchronously so bun:test's native hook driver is not + // re-entered from its own callback (on Windows a sync nested + // describe's last afterAll does not advance to the outer's). + function settleAndDone() { + noteSuiteCollectionSettled(suiteNode); + Promise.resolve(undefined).then(done, done); + } + const hooks = suiteNode.hooks.after; + if (hooks.length === 0 || hasHookFailedAncestorSuite(suiteNode)) { + settleAndDone(); + return; + } + const isTodo = suiteNode.todoFlag || hasTodoAncestor(suiteNode); + async function runSuiteAfterHooks() { + for (const hook of hooks) { + try { + await runHook(hook, suiteNode, suiteNode.getSuiteCtx(), "after"); + } catch (err) { + if (!isTodo) { + suiteNode.childrenFailed++; + suiteNode.error ??= err; + } + } + } + } + runSuiteAfterHooks().then(settleAndDone, settleAndDone); + }); + } + function recordSuiteBodyFailed(err: unknown) { + suiteNode.childrenFailed++; + suiteNode.error = err; + if (!isTodoAdvisory) suiteNode.hookSetupFailed = true; + } + function onWrappedSuiteFailed(err: unknown) { + recordSuiteBodyFailed(err); + if (isTodoAdvisory || runChildReporterEnabled) return undefined; + throw err; + } + let built: unknown; + try { + built = runWithNode(suiteNode, buildWrappedSuiteFn); + } catch (err) { + recordSuiteBodyFailed(err); + if (isTodoAdvisory || runChildReporterEnabled) { + settleSuiteAfterHooks(); + return undefined; + } + noteSuiteCollectionSettled(suiteNode); + throw err; + } + settleSuiteAfterHooks(); + if (built != null && typeof (built as PromiseLike).then === "function") { + return (built as Promise).then(undefined, onWrappedSuiteFailed); + } + return built; }; const passOptions = bunTestOptions(options); @@ -2733,10 +3925,20 @@ function addSuite( if (effectiveMode === "skip") register = describe.skip; else if (effectiveMode === "todo") { suiteNode.todoFlag = true; - register = runChildReporterEnabled ? describe : describe.todo; + if (!runChildReporterEnabled) register = describe.todo; + } + if (effectiveMode === "skip" && runChildReporterEnabled) { + suiteNode.suiteReported = true; + const { test } = bunTest(); + function directiveRunner(done: (error?: unknown) => void) { + reportDirectiveOnlyNode(suiteNode, "skip"); + markCurrentResult(false, done); + done(undefined); + } + if (passOptions !== undefined) test(name, directiveRunner, passOptions); + else test(name, directiveRunner); + return Promise.resolve(undefined); } - if (effectiveMode !== undefined) reportDirectiveOnlyNode(suiteNode, effectiveMode); - if (passOptions !== undefined) { register(name, wrapped, passOptions); } else { @@ -2762,7 +3964,7 @@ test.todo = function (arg0: unknown, arg1: unknown, arg2: unknown) { }; test.only = function (arg0: unknown, arg1: unknown, arg2: unknown) { - return addTest(arg0, arg1, arg2, undefined); + return addTest(arg0, arg1, arg2, undefined, "only"); }; function describe(arg0: unknown, arg1: unknown, arg2: unknown) { @@ -2778,7 +3980,7 @@ describe.todo = function (arg0: unknown, arg1: unknown, arg2: unknown) { }; describe.only = function (arg0: unknown, arg1: unknown, arg2: unknown) { - return addSuite(arg0, arg1, arg2, undefined); + return addSuite(arg0, arg1, arg2, undefined, "only"); }; function hookOwner(): TestNode { @@ -2796,6 +3998,13 @@ function hookArgFor(node: TestNode) { function before(arg0: unknown, arg1: unknown) { const hook = createHook(arg0, arg1); const owner = hookOwner(); + if (inStandaloneMode() && owner.parent === undefined) { + owner.hooks.before.push(hook); + if (owner.started && !owner.finished) { + runBeforeHookOnce(hook, owner, hookArgFor(owner)).catch(kDefaultFunction); + } + return; + } if (owner.isRunning()) { owner.hooks.before.push(hook); if (owner.started && !owner.finished) { @@ -2803,13 +4012,37 @@ function before(arg0: unknown, arg1: unknown) { } return; } + if (inStandaloneMode()) { + owner.hooks.before.push(hook); + return; + } + if (runChildReporterEnabled && (owner.skipped || hasSkippedAncestorSuite(owner))) return; const { beforeAll } = bunTest(); - beforeAll((done: (error?: unknown) => void) => { - Promise.resolve(runHook(hook, owner, hookArgFor(owner))).then( - () => done(), - err => done(err ?? new Error("before hook failed")), - ); - }); + function runBeforeAllHook(done: (error?: unknown) => void) { + if (runChildReporterEnabled && (owner.hookSetupFailed || hasHookFailedAncestorSuite(owner))) { + Promise.resolve(undefined).then(done, done); + return; + } + function onHookDone() { + done(); + } + function onHookFailed(err: unknown) { + if (runChildReporterEnabled && (owner.todoFlag || hasTodoAncestor(owner))) { + done(); + return; + } + if (runChildReporterEnabled && owner.parent !== undefined) { + owner.childrenFailed++; + owner.error ??= err as Error; + owner.hookSetupFailed = true; + done(); + return; + } + done(err ?? new Error("before hook failed")); + } + Promise.resolve(runHook(hook, owner, hookArgFor(owner), "before")).then(onHookDone, onHookFailed); + } + beforeAll(runBeforeAllHook); } function after(arg0: unknown, arg1: unknown) { @@ -2819,13 +4052,26 @@ function after(arg0: unknown, arg1: unknown) { owner.hooks.after.push(hook); return; } + if (inStandaloneMode()) { + owner.hooks.after.push(hook); + return; + } + if (runChildReporterEnabled && (owner.skipped || hasSkippedAncestorSuite(owner))) return; + if (runChildReporterEnabled && owner.isSuite && owner.parent !== undefined) { + owner.hooks.after.push(hook); + return; + } const { afterAll } = bunTest(); - afterAll((done: (error?: unknown) => void) => { - Promise.resolve(runHook(hook, owner, hookArgFor(owner))).then( - () => done(), - err => done(err ?? new Error("after hook failed")), - ); - }); + function runAfterAllHook(done: (error?: unknown) => void) { + function onHookDone() { + done(); + } + function onHookFailed(err: unknown) { + done(err ?? new Error("after hook failed")); + } + Promise.resolve(runHook(hook, owner, hookArgFor(owner), "after")).then(onHookDone, onHookFailed); + } + afterAll(runAfterAllHook); } function beforeEach(arg0: unknown, arg1: unknown) { diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index cd04a0a67544..ddb92ccd8c6e 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -36,6 +36,8 @@ static has_bun_garbage_collector_flag_enabled: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); #[unsafe(no_mangle)] pub static isBunTest: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); +pub static IS_NODE_TEST_RUN_CHILD: core::sync::atomic::AtomicBool = + core::sync::atomic::AtomicBool::new(false); #[unsafe(no_mangle)] pub(crate) static Bun__defaultRemainingRunsUntilSkipReleaseAccess: core::sync::atomic::AtomicI32 = core::sync::atomic::AtomicI32::new(10); @@ -1391,7 +1393,8 @@ impl VirtualMachine { return true; } - if isBunTest.load(core::sync::atomic::Ordering::Relaxed) { + let is_node_test_child = IS_NODE_TEST_RUN_CHILD.load(core::sync::atomic::Ordering::Relaxed); + if isBunTest.load(core::sync::atomic::Ordering::Relaxed) && !is_node_test_child { self.unhandled_error_counter += 1; (self.on_unhandled_rejection)(self, global_object, err); return true; @@ -3272,7 +3275,9 @@ impl VirtualMachine { return; } - if isBunTest.load(core::sync::atomic::Ordering::Relaxed) { + if isBunTest.load(core::sync::atomic::Ordering::Relaxed) + && !IS_NODE_TEST_RUN_CHILD.load(core::sync::atomic::Ordering::Relaxed) + { self.unhandled_error_counter += 1; (self.on_unhandled_rejection)(self, global_object, reason); return; diff --git a/src/jsc/bindings/isBuiltinModule.cpp b/src/jsc/bindings/isBuiltinModule.cpp index 409219f91ced..6e4c53505b19 100644 --- a/src/jsc/bindings/isBuiltinModule.cpp +++ b/src/jsc/bindings/isBuiltinModule.cpp @@ -79,6 +79,7 @@ static constexpr ASCIILiteral builtinModuleNamesSortedLength[] = { "_stream_transform"_s, "readline/promises"_s, "inspector/promises"_s, + "node:test/reporters"_s, "_stream_passthrough"_s, "diagnostics_channel"_s, }; diff --git a/src/resolve_builtins/HardcodedModule.rs b/src/resolve_builtins/HardcodedModule.rs index 074d920cef4b..371c647d0acc 100644 --- a/src/resolve_builtins/HardcodedModule.rs +++ b/src/resolve_builtins/HardcodedModule.rs @@ -95,6 +95,8 @@ pub enum HardcodedModule { NodeStringDecoder, #[strum(serialize = "node:test")] NodeTest, + #[strum(serialize = "node:test/reporters")] + NodeTestReporters, #[strum(serialize = "node:timers")] NodeTimers, #[strum(serialize = "node:timers/promises")] @@ -246,6 +248,7 @@ bun_core::comptime_string_map! { b"node:net" => HardcodedModule::NodeNet, b"node:readline" => HardcodedModule::NodeReadline, b"node:test" => HardcodedModule::NodeTest, + b"node:test/reporters" => HardcodedModule::NodeTestReporters, b"node:os" => HardcodedModule::NodeOs, b"node:path" => HardcodedModule::NodePath, b"node:path/posix" => HardcodedModule::NodePathPosix, @@ -468,6 +471,7 @@ const COMMON_ALIAS_KVS: &[AliasKv] = &[ node_entry_only_prefix!("node:sqlite"), node_entry_only_prefix!("node:test"), node_entry_only_prefix!("node:quic"), + node_entry_only_prefix!("node:test/reporters"), // node_entry!("assert"), node_entry!("assert/strict"), diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 0ace6e3b893f..90c89aaec974 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -348,6 +348,34 @@ const AUTO_OR_RUN_PARAMS: &[ParamType] = &[ parse_param!( "--no-exit-on-error Continue running other scripts when one fails (with --parallel/--sequential)" ), + // Value-taking ones must be declared (else the value parses as the + // entrypoint); kept out of RUNTIME_PARAMS_ to avoid TEST_PARAMS's `-t`. + parse_param!("--test"), + parse_param!("--test-only"), + parse_param!("--test-force-exit"), + parse_param!("--test-randomize"), + parse_param!("--test-update-snapshots"), + parse_param!("--experimental-test-coverage"), + parse_param!("--experimental-test-module-mocks"), + parse_param!("--experimental-test-snapshots"), + parse_param!("--test-reporter ..."), + parse_param!("--test-reporter-destination ..."), + parse_param!("--test-name-pattern ..."), + parse_param!("--test-skip-pattern ..."), + parse_param!("--experimental-test-tag-filter ..."), + parse_param!("--test-coverage-include ..."), + parse_param!("--test-coverage-exclude ..."), + parse_param!("--test-timeout "), + parse_param!("--test-concurrency "), + parse_param!("--test-shard "), + parse_param!("--test-isolation "), + parse_param!("--experimental-test-isolation "), + parse_param!("--test-global-setup "), + parse_param!("--test-random-seed "), + parse_param!("--test-rerun-failures "), + parse_param!("--test-coverage-branches "), + parse_param!("--test-coverage-functions "), + parse_param!("--test-coverage-lines "), ]; const AUTO_ONLY_PARAMS: &[ParamType] = concat_params!( @@ -1206,6 +1234,14 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result = entry_point_buf[..cwd_len + EVAL_TRIGGER.len()] - .to_vec() - .into_boxed_slice(); - return Self::boot(ctx, entry, None); + return Self::exec_eval(ctx); } if ctx.positionals.is_empty() { diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index be81fc85e8b6..78bb6e87adae 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -912,12 +912,18 @@ impl JunitReporter { } } -/// Drain the event loop after a file's tests finish, like a node process -/// would before exiting; the vendored-node-test runner opts in via -/// `BUN_TEST_DRAIN_EVENT_LOOP=1` so mustCall()-style exit checks see -/// completed async work. Off by default: bun suites keep exit-after-tests. +/// Drain the event loop after a file's tests finish, like a node process would +/// before exiting: on for node:test run() children, or via BUN_TEST_DRAIN_EVENT_LOOP=1 +/// (vendored-node-test sets it) so mustCall()-style exit checks see completed work. fn should_drain_event_loop() -> bool { - env_var::BUN_TEST_DRAIN_EVENT_LOOP.get().unwrap_or(false) + is_node_test_child() || env_var::BUN_TEST_DRAIN_EVENT_LOOP.get().unwrap_or(false) +} + +/// Matches node:test's exact value so a foreign env var can't silence us. +pub(crate) fn is_node_test_child() -> bool { + env_var::NODE_TEST_CONTEXT + .get() + .is_some_and(|value| value == b"child-v8") } pub struct CommandLineReporter { @@ -1465,7 +1471,7 @@ impl CommandLineReporter { .and_then(|p| unsafe { (*p.as_ptr()).worker_ipc_file_idx }); if let Some(idx) = worker_idx { ParallelRunner::worker_emit_test_done(idx, formatted_line); - } else { + } else if !is_node_test_child() { let _ = Output::error_writer().write_all(formatted_line); } @@ -1513,12 +1519,14 @@ impl CommandLineReporter { if this.summary().fail == this.jest.bail { this.print_summary(); - pretty_error!( - "\nBailed out after {} failure{}\n", - this.jest.bail, - if this.jest.bail == 1 { "" } else { "s" } - ); - Output::flush(); + if !is_node_test_child() { + pretty_error!( + "\nBailed out after {} failure{}\n", + this.jest.bail, + if this.jest.bail == 1 { "" } else { "s" } + ); + Output::flush(); + } this.write_junit_report_if_needed(); this.write_timings_if_needed(); Global::exit(1); @@ -1532,6 +1540,9 @@ impl CommandLineReporter { } pub(crate) fn print_summary(&mut self) { + if is_node_test_child() { + return; + } let summary_ = self.summary(); let tests = summary_.fail + summary_.pass + summary_.skip + summary_.todo; let files = summary_.files; @@ -2091,7 +2102,7 @@ impl TestCommand { core::sync::atomic::Ordering::Relaxed, ); - if !ctx.test_options.test_worker { + if !ctx.test_options.test_worker && !is_node_test_child() { // print the version so you know its doing stuff if it takes a sec let w = Output::writer(); let colors = Output::enable_ansi_colors_stdout(); @@ -2754,6 +2765,7 @@ impl TestCommand { && !Output::is_ai_agent() && !reporter.reporters.dots && !reporter.reporters.only_failures + && !is_node_test_child() { if reporter.summary().skip > 0 { pretty_error!("\n{} tests skipped:\n", reporter.summary().skip); @@ -2898,7 +2910,9 @@ impl TestCommand { let did_label_filter_out_all_tests = summary.did_label_filter_out_all_tests() && reporter.jest.unhandled_errors_between_tests == 0; - if !did_label_filter_out_all_tests { + if is_node_test_child() { + // Counts still feed the exit-code logic below; nothing prints. + } else if !did_label_filter_out_all_tests { struct DotIndenter { indent: bool, } diff --git a/src/runtime/test_runner/bun_test.rs b/src/runtime/test_runner/bun_test.rs index f87c8a66687c..da2916bb824d 100644 --- a/src/runtime/test_runner/bun_test.rs +++ b/src/runtime/test_runner/bun_test.rs @@ -10,7 +10,7 @@ use bun_jsc::virtual_machine::VirtualMachine; use bun_jsc::js_promise::Status as PromiseStatus; use super::jest::{Jest, FileId, FileColumns as _}; use crate::timer::{EventLoopTimer, EventLoopTimerState, EventLoopTimerTag, ElTimespec}; -use crate::cli::test_command::CommandLineReporter; +use crate::cli::test_command::{CommandLineReporter, is_node_test_child}; use super::execution::TimespecExt as _; bun_core::declare_scope!(bun_test_group, hidden); @@ -568,6 +568,9 @@ impl BunTestRoot { } pub(crate) fn on_before_print(&self) { + if is_node_test_child() { + return; + } if let Some(active_file) = &self.active_file { // Do NOT go through `` here. Two of the three // callers (`on_uncaught_exception`, test_command.rs report-status) @@ -826,9 +829,15 @@ impl BunTest { // in Jest, this is "Expected done to be called once, but it was called multiple times." // Vitest does not support done callbacks } else { - // error is only reported for the first done() call + // Error is only reported for the first done() call. Routed through + // bun:test's collector, not `uncaught_exception`: in a node:test + // child the latter dispatches genuine uncaughts to process listeners. if was_error { - let _ = global_this.bun_vm().as_mut().uncaught_exception(global_this, value, false); + let vm = global_this.bun_vm().as_mut(); + if !vm.is_shutting_down() { + vm.unhandled_error_counter += 1; + (vm.on_unhandled_rejection)(vm, global_this, value); + } } } // SAFETY: see above — `this` is a live `*mut DoneCallback`. @@ -1308,6 +1317,15 @@ impl BunTest { if handle_status == HandleUncaughtExceptionResult::HideError { return; // do not print error, it was already consumed } + if is_node_test_child() + && !matches!( + handle_status, + HandleUncaughtExceptionResult::ShowUnhandledErrorBetweenTests + | HandleUncaughtExceptionResult::ShowUnhandledErrorInDescribe + ) + { + return; + } let Some(exception) = exception else { return; // the exception should not be visible (eg m_terminationException) }; diff --git a/src/runtime/test_runner/jest.rs b/src/runtime/test_runner/jest.rs index 999c7749cbec..31493d063987 100644 --- a/src/runtime/test_runner/jest.rs +++ b/src/runtime/test_runner/jest.rs @@ -49,6 +49,10 @@ impl CurrentFile { self.has_printed_filename = true; return; } + if crate::cli::test_command::is_node_test_child() { + self.has_printed_filename = true; + return; + } if reporter.reporters.dots || reporter.reporters.only_failures { // Assigning into the Box<[u8]> fields below drops the previous values. self.title = Box::<[u8]>::from(title); @@ -527,6 +531,14 @@ pub(crate) fn js_file_generation( Ok(JSValue::from(generation)) } +pub(crate) fn js_node_test_register_child( + _global: &JSGlobalObject, + _callframe: &CallFrame, +) -> JsResult { + jsc::virtual_machine::IS_NODE_TEST_RUN_CHILD.store(true, core::sync::atomic::Ordering::Relaxed); + Ok(JSValue::UNDEFINED) +} + /// Reached only from `node:test` (`t.skip()` / `t.todo()` at runtime): overrides /// the running sequence's result so bun:test reports skip/todo instead of pass. /// `done`'s bound `DoneCallback.r#ref.phase` names the intended sequence so a diff --git a/test/js/node/test/.gitignore b/test/js/node/test/.gitignore index 790c5e93c0ec..5312bb1d9d32 100644 --- a/test/js/node/test/.gitignore +++ b/test/js/node/test/.gitignore @@ -15,3 +15,24 @@ fixtures/snapshot/* fixtures/repl* .tmp.* **/fails.txt +!fixtures/test-runner/default-behavior +!fixtures/test-runner/default-behavior/** +!fixtures/test-runner/todo-suite-failing-hook.mjs +!fixtures/test-runner/mock-timers-with-timeout.js +!fixtures/test-runner/root-duration.mjs +!fixtures/test-runner/coverage +fixtures/test-runner/coverage/* +!fixtures/test-runner/coverage/stdin.test.js +!fixtures/test-runner/no-isolation +!fixtures/test-runner/no-isolation/** +!fixtures/test-runner/test-id-fixture.js +!fixtures/test-runner/describe_error.js +!fixtures/test-runner/never_ending_async.js +!fixtures/test-runner/never_ending_sync.js +!fixtures/test-runner/todo_exit_code.js +!fixtures/test-runner/throws_sync_and_async.js +!fixtures/test-runner/reporters.js +!fixtures/test-runner/plan +!fixtures/test-runner/plan/** +!fixtures/test-runner/error-reporter-fail-fast +!fixtures/test-runner/error-reporter-fail-fast/** diff --git a/test/js/node/test/common/test-error-reporter.js b/test/js/node/test/common/test-error-reporter.js new file mode 100644 index 000000000000..d6db28e675ad --- /dev/null +++ b/test/js/node/test/common/test-error-reporter.js @@ -0,0 +1,41 @@ +'use strict'; +const { relative } = require('node:path'); +const { inspect } = require('node:util'); +const cwd = process.cwd(); + +module.exports = async function* errorReporter(source) { + for await (const event of source) { + if (event.type === 'test:fail') { + const { name, details, line, column, file } = event.data; + let { error } = details; + + if (error?.failureType === 'subtestsFailed') { + // In the interest of keeping things concise, skip failures that are + // only due to nested failures. + continue; + } + + if (error?.code === 'ERR_TEST_FAILURE') { + error = error.cause; + } + + const output = [ + `Test failure: '${name}'`, + ]; + + if (file) { + output.push(`Location: ${relative(cwd, file)}:${line}:${column}`); + } + + output.push(inspect(error)); + output.push('\n'); + yield output.join('\n'); + + if (process.env.FAIL_FAST) { + yield `\nBailing on failed test: ${event.data.name}\n`; + process.exitCode = 1; + process.emit('SIGINT'); + } + } + } +}; diff --git a/test/js/node/test/fixtures/test-runner/coverage/stdin.test.js b/test/js/node/test/fixtures/test-runner/coverage/stdin.test.js new file mode 100644 index 000000000000..4a98eca0b0bb --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/coverage/stdin.test.js @@ -0,0 +1,5 @@ +// stdin.test.ts +var import_node_test = require("node:test"); +(0, import_node_test.test)("ok", () => { +}); +//# sourceMappingURL=stdin.test.js.map diff --git a/test/js/node/test/fixtures/test-runner/default-behavior/index.test.js b/test/js/node/test/fixtures/test-runner/default-behavior/index.test.js new file mode 100644 index 000000000000..2a722c504b9f --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/default-behavior/index.test.js @@ -0,0 +1,4 @@ +'use strict'; +const test = require('node:test'); + +test('this should pass'); diff --git a/test/js/node/test/fixtures/test-runner/default-behavior/node_modules/test-nm.js b/test/js/node/test/fixtures/test-runner/default-behavior/node_modules/test-nm.js new file mode 100644 index 000000000000..30024eab1f17 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/default-behavior/node_modules/test-nm.js @@ -0,0 +1,2 @@ +'use strict'; +throw new Error('thrown from node_modules'); diff --git a/test/js/node/test/fixtures/test-runner/default-behavior/random.test.mjs b/test/js/node/test/fixtures/test-runner/default-behavior/random.test.mjs new file mode 100644 index 000000000000..a87a671d006a --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/default-behavior/random.test.mjs @@ -0,0 +1,5 @@ +import test from 'node:test'; + +test('this should fail', () => { + throw new Error('this is a failing test'); +}); diff --git a/test/js/node/test/fixtures/test-runner/default-behavior/subdir/subdir_test.js b/test/js/node/test/fixtures/test-runner/default-behavior/subdir/subdir_test.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/test/js/node/test/fixtures/test-runner/default-behavior/test/random.cjs b/test/js/node/test/fixtures/test-runner/default-behavior/test/random.cjs new file mode 100644 index 000000000000..2a722c504b9f --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/default-behavior/test/random.cjs @@ -0,0 +1,4 @@ +'use strict'; +const test = require('node:test'); + +test('this should pass'); diff --git a/test/js/node/test/fixtures/test-runner/default-behavior/test/skip_by_name.cjs b/test/js/node/test/fixtures/test-runner/default-behavior/test/skip_by_name.cjs new file mode 100644 index 000000000000..14856df43e50 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/default-behavior/test/skip_by_name.cjs @@ -0,0 +1,5 @@ +'use strict'; +const test = require('node:test'); + +test('this should be skipped'); +test('this should be executed'); diff --git a/test/js/node/test/fixtures/test-runner/default-behavior/test/suite_and_test.cjs b/test/js/node/test/fixtures/test-runner/default-behavior/test/suite_and_test.cjs new file mode 100644 index 000000000000..0418d4676b2c --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/default-behavior/test/suite_and_test.cjs @@ -0,0 +1,5 @@ +'use strict'; +const {test, suite} = require('node:test'); + +suite('this is a suite'); +test('this is a test'); diff --git a/test/js/node/test/fixtures/test-runner/describe_error.js b/test/js/node/test/fixtures/test-runner/describe_error.js new file mode 100644 index 000000000000..04e9d1faa042 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/describe_error.js @@ -0,0 +1,10 @@ +'use strict'; +const { describe, it } = require('node:test'); + +describe('should fail', () => { + throw new Error('error in describe'); +}); + +describe('should pass', () => { + it('ok', () => {}); +}); diff --git a/test/js/node/test/fixtures/test-runner/error-reporter-fail-fast/a.mjs b/test/js/node/test/fixtures/test-runner/error-reporter-fail-fast/a.mjs new file mode 100644 index 000000000000..6508394bbc68 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/error-reporter-fail-fast/a.mjs @@ -0,0 +1,6 @@ +const assert = require('node:assert'); +const { test } = require('node:test'); + +test('fail', () => { + assert.fail('a.mjs fail'); +}); diff --git a/test/js/node/test/fixtures/test-runner/error-reporter-fail-fast/b.mjs b/test/js/node/test/fixtures/test-runner/error-reporter-fail-fast/b.mjs new file mode 100644 index 000000000000..87abd62db277 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/error-reporter-fail-fast/b.mjs @@ -0,0 +1,6 @@ +const assert = require('node:assert'); +const { test } = require('node:test'); + +test('fail', () => { + assert.fail('b.mjs fail'); +}); diff --git a/test/js/node/test/fixtures/test-runner/mock-timers-with-timeout.js b/test/js/node/test/fixtures/test-runner/mock-timers-with-timeout.js new file mode 100644 index 000000000000..4eb94ec5d6d8 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/mock-timers-with-timeout.js @@ -0,0 +1,43 @@ +'use strict'; + +// Simulate @sinonjs/fake-timers: patch the timers module BEFORE +// the test runner is loaded, so the test runner captures the patched +// versions at import time. +const nodeTimers = require('node:timers'); +const originalSetTimeout = nodeTimers.setTimeout; +const originalClearTimeout = nodeTimers.clearTimeout; + +const fakeTimers = new Map(); +let nextId = 1; + +nodeTimers.setTimeout = (fn, delay, ...args) => { + const id = nextId++; + const timer = originalSetTimeout(fn, delay, ...args); + fakeTimers.set(id, timer); + // Sinon fake timers return an object with unref/ref but without + // Symbol.dispose, which would cause the test runner to throw. + return { id, unref() {}, ref() {} }; +}; + +nodeTimers.clearTimeout = (id) => { + if (id != null && typeof id === 'object') id = id.id; + const timer = fakeTimers.get(id); + if (timer) { + originalClearTimeout(timer); + fakeTimers.delete(id); + } +}; + +// Now load the test runner - it will capture our patched setTimeout/clearTimeout +const { test } = require('node:test'); + +test('test with fake timers and timeout', { timeout: 10_000 }, () => { + // This test verifies that the test runner works when setTimeout returns + // an object without Symbol.dispose (like sinon fake timers). + // Previously, the test runner called timer[Symbol.dispose]() which would + // throw TypeError on objects returned by fake timer implementations. +}); + +// Restore +nodeTimers.setTimeout = originalSetTimeout; +nodeTimers.clearTimeout = originalClearTimeout; diff --git a/test/js/node/test/fixtures/test-runner/never_ending_async.js b/test/js/node/test/fixtures/test-runner/never_ending_async.js new file mode 100644 index 000000000000..0f26ea9291fd --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/never_ending_async.js @@ -0,0 +1,6 @@ +const test = require('node:test'); +const { setTimeout } = require('timers/promises'); + +// We are using a very large timeout value to ensure that the parent process +// will have time to send a SIGINT signal to cancel the test. +test('never ending test', () => setTimeout(100_000_000)); diff --git a/test/js/node/test/fixtures/test-runner/never_ending_sync.js b/test/js/node/test/fixtures/test-runner/never_ending_sync.js new file mode 100644 index 000000000000..efc78757b188 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/never_ending_sync.js @@ -0,0 +1,5 @@ +const test = require('node:test'); + +test('never ending test', () => { + while (true); +}); diff --git a/test/js/node/test/fixtures/test-runner/no-isolation/global-hooks.cjs b/test/js/node/test/fixtures/test-runner/no-isolation/global-hooks.cjs new file mode 100644 index 000000000000..9a2c7f9950ef --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/no-isolation/global-hooks.cjs @@ -0,0 +1,6 @@ +const test = require('node:test'); + +test.before(() => console.log('before(): global')); +test.beforeEach(() => console.log('beforeEach(): global')); +test.after(() => console.log('after(): global')); +test.afterEach(() => console.log('afterEach(): global')); diff --git a/test/js/node/test/fixtures/test-runner/no-isolation/global-hooks.mjs b/test/js/node/test/fixtures/test-runner/no-isolation/global-hooks.mjs new file mode 100644 index 000000000000..962c6fecb397 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/no-isolation/global-hooks.mjs @@ -0,0 +1,6 @@ +import test from 'node:test'; + +test.before(() => console.log('before(): global')); +test.beforeEach(() => console.log('beforeEach(): global')); +test.after(() => console.log('after(): global')); +test.afterEach(() => console.log('afterEach(): global')); diff --git a/test/js/node/test/fixtures/test-runner/no-isolation/one.test.js b/test/js/node/test/fixtures/test-runner/no-isolation/one.test.js new file mode 100644 index 000000000000..5b5cc2b025cf --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/no-isolation/one.test.js @@ -0,0 +1,37 @@ +'use strict'; +const { before, beforeEach, after, afterEach, test, suite } = require('node:test'); + +globalThis.GLOBAL_ORDER = []; + +function record(data) { + globalThis.GLOBAL_ORDER.push(data); + console.log(data); +} + +before(function() { + record(`before one: ${this.name}`); +}); + +beforeEach(function() { + record(`beforeEach one: ${this.name}`); +}); + +after(function() { + record(`after one: ${this.name}`); +}); + +afterEach(function() { + record(`afterEach one: ${this.name}`); +}); + +suite('suite one', function() { + record(this.name); + + test('suite one - test', { only: true }, function() { + record(this.name); + }); +}); + +test('test one', function() { + record(this.name); +}); diff --git a/test/js/node/test/fixtures/test-runner/no-isolation/two.test.js b/test/js/node/test/fixtures/test-runner/no-isolation/two.test.js new file mode 100644 index 000000000000..4fbc7dfc840e --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/no-isolation/two.test.js @@ -0,0 +1,35 @@ +'use strict'; +const { before, beforeEach, after, afterEach, test, suite } = require('node:test'); + +function record(data) { + globalThis.GLOBAL_ORDER.push(data); + console.log(data); +} + +before(function() { + record(`before two: ${this.name}`); +}); + +beforeEach(function() { + record(`beforeEach two: ${this.name}`); +}); + +after(function() { + record(`after two: ${this.name}`); +}); + +afterEach(function() { + record(`afterEach two: ${this.name}`); +}); + +suite('suite two', function() { + record(this.name); + + before(function() { + record(`before suite two: ${this.name}`); + }); + + test('suite two - test', { only: true }, function() { + record(this.name); + }); +}); diff --git a/test/js/node/test/fixtures/test-runner/plan/less.mjs b/test/js/node/test/fixtures/test-runner/plan/less.mjs new file mode 100644 index 000000000000..5f482d019428 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/plan/less.mjs @@ -0,0 +1,7 @@ +import test from 'node:test'; + +test('less assertions than planned', (t) => { + t.plan(2); + t.assert.ok(true, 'only one assertion'); + // Missing second assertion +}); diff --git a/test/js/node/test/fixtures/test-runner/plan/match.mjs b/test/js/node/test/fixtures/test-runner/plan/match.mjs new file mode 100644 index 000000000000..eb7e64fa68be --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/plan/match.mjs @@ -0,0 +1,7 @@ +import test from 'node:test'; + +test('matching assertions', (t) => { + t.plan(2); + t.assert.ok(true, 'first assertion'); + t.assert.ok(true, 'second assertion'); +}); diff --git a/test/js/node/test/fixtures/test-runner/plan/more.mjs b/test/js/node/test/fixtures/test-runner/plan/more.mjs new file mode 100644 index 000000000000..deb4fb7f9ec4 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/plan/more.mjs @@ -0,0 +1,7 @@ +import test from 'node:test'; + +test('more assertions than planned', (t) => { + t.plan(1); + t.assert.ok(true, 'first assertion'); + t.assert.ok(true, 'extra assertion'); // This should cause failure +}); diff --git a/test/js/node/test/fixtures/test-runner/plan/nested-subtests.mjs b/test/js/node/test/fixtures/test-runner/plan/nested-subtests.mjs new file mode 100644 index 000000000000..61fc11b62782 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/plan/nested-subtests.mjs @@ -0,0 +1,14 @@ +import test from 'node:test'; + +test('deeply nested tests', async (t) => { + t.plan(1); + + await t.test('level 1', async (t) => { + t.plan(1); + + await t.test('level 2', (t) => { + t.plan(1); + t.assert.ok(true, 'deepest assertion'); + }); + }); +}); diff --git a/test/js/node/test/fixtures/test-runner/plan/plan-via-options.mjs b/test/js/node/test/fixtures/test-runner/plan/plan-via-options.mjs new file mode 100644 index 000000000000..fd27f75bd166 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/plan/plan-via-options.mjs @@ -0,0 +1,9 @@ +import test from 'node:test'; + +test('failing planning by options', { plan: 1 }, () => { + // Should fail - no assertions +}); + +test('passing planning by options', { plan: 1 }, (t) => { + t.assert.ok(true); +}); diff --git a/test/js/node/test/fixtures/test-runner/plan/streaming.mjs b/test/js/node/test/fixtures/test-runner/plan/streaming.mjs new file mode 100644 index 000000000000..4a6625af8afc --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/plan/streaming.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import { Readable } from 'node:stream'; + +test('planning with streams', (t, done) => { + function* generate() { + yield 'a'; + yield 'b'; + yield 'c'; + } + const expected = ['a', 'b', 'c']; + t.plan(expected.length); + const stream = Readable.from(generate()); + stream.on('data', (chunk) => { + t.assert.strictEqual(chunk, expected.shift()); + }); + + stream.on('end', () => { + done(); + }); +}); diff --git a/test/js/node/test/fixtures/test-runner/plan/subtest.mjs b/test/js/node/test/fixtures/test-runner/plan/subtest.mjs new file mode 100644 index 000000000000..e87757029829 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/plan/subtest.mjs @@ -0,0 +1,9 @@ +import test from 'node:test'; + +test('parent test', async (t) => { + t.plan(1); + await t.test('child test', (t) => { + t.plan(1); + t.assert.ok(true, 'child assertion'); + }); +}); diff --git a/test/js/node/test/fixtures/test-runner/plan/timeout-basic.mjs b/test/js/node/test/fixtures/test-runner/plan/timeout-basic.mjs new file mode 100644 index 000000000000..ab9c5cf92d1c --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/plan/timeout-basic.mjs @@ -0,0 +1,15 @@ +import test from 'node:test'; + +test('planning with wait should PASS within timeout', async (t) => { + t.plan(1, { wait: 5000 }); + setTimeout(() => { + t.assert.ok(true); + }, 250); +}); + +test('planning with wait should FAIL within timeout', async (t) => { + t.plan(1, { wait: 5000 }); + setTimeout(() => { + t.assert.ok(false); + }, 250); +}); diff --git a/test/js/node/test/fixtures/test-runner/plan/timeout-expired.mjs b/test/js/node/test/fixtures/test-runner/plan/timeout-expired.mjs new file mode 100644 index 000000000000..3d96df794984 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/plan/timeout-expired.mjs @@ -0,0 +1,8 @@ +import test from 'node:test'; + +test('planning should FAIL when wait time expires before plan is met', (t) => { + t.plan(2, { wait: 500 }); + setTimeout(() => { + t.assert.ok(true); + }, 30_000).unref(); +}); diff --git a/test/js/node/test/fixtures/test-runner/plan/timeout-wait-false.mjs b/test/js/node/test/fixtures/test-runner/plan/timeout-wait-false.mjs new file mode 100644 index 000000000000..b9830ca8286d --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/plan/timeout-wait-false.mjs @@ -0,0 +1,11 @@ +import test from 'node:test'; + +test('should not wait for assertions and fail immediately', async (t) => { + t.plan(1, { wait: false }); + + // Set up an async operation that won't complete before the test finishes + // Since wait:false, the test should fail immediately without waiting + setTimeout(() => { + t.assert.ok(true); + }, 1000).unref(); +}); diff --git a/test/js/node/test/fixtures/test-runner/plan/timeout-wait-true.mjs b/test/js/node/test/fixtures/test-runner/plan/timeout-wait-true.mjs new file mode 100644 index 000000000000..cc0dd8d8ab0d --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/plan/timeout-wait-true.mjs @@ -0,0 +1,17 @@ +import test from 'node:test'; + +test('should pass when assertions are eventually met', async (t) => { + t.plan(1, { wait: true }); + + setTimeout(() => { + t.assert.ok(true); + }, 250); +}); + +test('should fail when assertions fail', async (t) => { + t.plan(1, { wait: true }); + + setTimeout(() => { + t.assert.ok(false); + }, 250).unref(); +}); diff --git a/test/js/node/test/fixtures/test-runner/reporters.js b/test/js/node/test/fixtures/test-runner/reporters.js new file mode 100644 index 000000000000..ed7066023d12 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/reporters.js @@ -0,0 +1,11 @@ +'use strict'; +const test = require('node:test'); + +test('nested', { concurrency: 4 }, async (t) => { + t.test('ok', () => {}); + t.test('failing', () => { + throw new Error('error'); + }); +}); + +test('top level', () => {}); diff --git a/test/js/node/test/fixtures/test-runner/root-duration.mjs b/test/js/node/test/fixtures/test-runner/root-duration.mjs new file mode 100644 index 000000000000..b9bdf1d34273 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/root-duration.mjs @@ -0,0 +1,7 @@ +import { test, after } from 'node:test'; + +after(() => {}); + +test('a test with some delay', (t, done) => { + setTimeout(done, 50); +}); diff --git a/test/js/node/test/fixtures/test-runner/test-id-fixture.js b/test/js/node/test/fixtures/test-runner/test-id-fixture.js new file mode 100644 index 000000000000..d3a6548207b2 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/test-id-fixture.js @@ -0,0 +1,21 @@ +'use strict'; +const { describe, it } = require('node:test'); +const assert = require('node:assert'); + +// Factory that creates subtests at the SAME source location. +// Multiple concurrent `it` blocks calling this will have subtests +// sharing file:line:column — but each should get a distinct testId. +function makeSubtest(shouldFail) { + return async function(t) { + await t.test('e2e', async () => { + if (shouldFail) assert.fail('intentional'); + }); + }; +} + +describe('suite', { concurrency: 10_000 }, () => { + it('test-A (passes)', makeSubtest(false)); + it('test-B (passes)', makeSubtest(false)); + it('test-C (fails)', makeSubtest(true)); + it('test-D (passes)', makeSubtest(false)); +}); diff --git a/test/js/node/test/fixtures/test-runner/throws_sync_and_async.js b/test/js/node/test/fixtures/test-runner/throws_sync_and_async.js new file mode 100644 index 000000000000..50ed81b4acf5 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/throws_sync_and_async.js @@ -0,0 +1,10 @@ +'use strict'; +const { test } = require('node:test'); + +test('fails and schedules more work', () => { + setTimeout(() => { + throw new Error('this should not have a chance to be thrown'); + }, 1000); + + throw new Error('fails'); +}); diff --git a/test/js/node/test/fixtures/test-runner/todo-suite-failing-hook.mjs b/test/js/node/test/fixtures/test-runner/todo-suite-failing-hook.mjs new file mode 100644 index 000000000000..6c3291253cdc --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/todo-suite-failing-hook.mjs @@ -0,0 +1,10 @@ +import { before, describe, it } from 'node:test'; + +describe('todo suite with failing before hook', { todo: 'evaluating' }, () => { + before(() => { + throw new Error('simulated cleanup failure'); + }); + + it('child 1', () => {}); + it('child 2', () => {}); +}); diff --git a/test/js/node/test/fixtures/test-runner/todo_exit_code.js b/test/js/node/test/fixtures/test-runner/todo_exit_code.js new file mode 100644 index 000000000000..77f519058e97 --- /dev/null +++ b/test/js/node/test/fixtures/test-runner/todo_exit_code.js @@ -0,0 +1,21 @@ +const { describe, test } = require('node:test'); + +describe('suite should pass', () => { + test.todo('should fail without harming suite', () => { + throw new Error('Fail but not badly') + }); +}); + +test.todo('should fail without effecting exit code', () => { + throw new Error('Fail but not badly') +}); + +test('empty string todo', { todo: '' }, () => { + throw new Error('Fail but not badly') +}); + +describe.todo('should inherit todo', () => { + test('should fail without harming suite', () => { + throw new Error('Fail but not badly'); + }); +}); diff --git a/test/js/node/test/parallel/test-runner-cli-concurrency.js b/test/js/node/test/parallel/test-runner-cli-concurrency.js new file mode 100644 index 000000000000..ac522d7861c1 --- /dev/null +++ b/test/js/node/test/parallel/test-runner-cli-concurrency.js @@ -0,0 +1,40 @@ +'use strict'; +require('../common'); +const fixtures = require('../common/fixtures'); +const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); +const cwd = fixtures.path('test-runner', 'default-behavior'); +const env = { ...process.env, 'NODE_DEBUG': 'test_runner' }; + +test('default concurrency', async () => { + const args = ['--test']; + const cp = spawnSync(process.execPath, args, { cwd, env }); + assert.match(cp.stderr.toString(), /concurrency: true,/); +}); + +test('concurrency of one', async () => { + const args = ['--test', '--test-concurrency=1']; + const cp = spawnSync(process.execPath, args, { cwd, env }); + assert.match(cp.stderr.toString(), /concurrency: 1,/); +}); + +test('concurrency of two', async () => { + const args = ['--test', '--test-concurrency=2']; + const cp = spawnSync(process.execPath, args, { cwd, env }); + assert.match(cp.stderr.toString(), /concurrency: 2,/); +}); + +test('isolation=none uses a concurrency of one', async () => { + const args = ['--test', '--test-isolation=none']; + const cp = spawnSync(process.execPath, args, { cwd, env }); + assert.match(cp.stderr.toString(), /concurrency: 1,/); +}); + +test('isolation=none overrides --test-concurrency', async () => { + const args = [ + '--test', '--test-isolation=none', '--test-concurrency=2', + ]; + const cp = spawnSync(process.execPath, args, { cwd, env }); + assert.match(cp.stderr.toString(), /concurrency: 1,/); +}); diff --git a/test/js/node/test/parallel/test-runner-cli-timeout.js b/test/js/node/test/parallel/test-runner-cli-timeout.js new file mode 100644 index 000000000000..c8534a56b62e --- /dev/null +++ b/test/js/node/test/parallel/test-runner-cli-timeout.js @@ -0,0 +1,28 @@ +'use strict'; +require('../common'); +const fixtures = require('../common/fixtures'); +const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); +const cwd = fixtures.path('test-runner', 'default-behavior'); +const env = { ...process.env, 'NODE_DEBUG': 'test_runner' }; + +test('default timeout -- Infinity', async () => { + const args = ['--test']; + const cp = spawnSync(process.execPath, args, { cwd, env }); + assert.match(cp.stderr.toString(), /timeout: Infinity,/); +}); + +test('timeout of 10ms', async () => { + const args = ['--test', '--test-timeout', 10]; + const cp = spawnSync(process.execPath, args, { cwd, env }); + assert.match(cp.stderr.toString(), /timeout: 10,/); +}); + +test('isolation=none uses the --test-timeout flag', async () => { + const args = [ + '--test', '--test-isolation=none', '--test-timeout=10', + ]; + const cp = spawnSync(process.execPath, args, { cwd, env }); + assert.match(cp.stderr.toString(), /timeout: 10,/); +}); diff --git a/test/js/node/test/parallel/test-runner-enable-source-maps-issue.js b/test/js/node/test/parallel/test-runner-enable-source-maps-issue.js new file mode 100644 index 000000000000..95112ca4471c --- /dev/null +++ b/test/js/node/test/parallel/test-runner-enable-source-maps-issue.js @@ -0,0 +1,16 @@ +'use strict'; +require('../common'); +const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); +const fixtures = require('../common/fixtures'); + +test('ensures --enable-source-maps does not throw an error', () => { + const fixture = fixtures.path('test-runner', 'coverage', 'stdin.test.js'); + const args = ['--enable-source-maps', fixture]; + + const result = spawnSync(process.execPath, args); + + assert.strictEqual(result.stderr.toString(), ''); + assert.strictEqual(result.status, 0); +}); diff --git a/test/js/node/test/parallel/test-runner-error-reporter.js b/test/js/node/test/parallel/test-runner-error-reporter.js new file mode 100644 index 000000000000..9d77d584689a --- /dev/null +++ b/test/js/node/test/parallel/test-runner-error-reporter.js @@ -0,0 +1,32 @@ +'use strict'; + +require('../common'); +const fixtures = require('../common/fixtures'); +const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); +const cwd = fixtures.path('test-runner', 'error-reporter-fail-fast'); + +test('all tests failures reported without FAIL_FAST flag', async () => { + const args = [ + `--test-reporter=${require.resolve('../common/test-error-reporter.js')}`, + '--test-concurrency=1', + '--test', + `${cwd}/*.mjs`, + ]; + const cp = spawnSync(process.execPath, args); + const failureCount = (cp.stdout.toString().match(/Test failure:/g) || []).length; + assert.strictEqual(failureCount, 2); +}); + +test('FAIL_FAST stops test execution after first failure', async () => { + const args = [ + `--test-reporter=${require.resolve('../common/test-error-reporter.js')}`, + '--test-concurrency=1', + '--test', + `${cwd}/*.mjs`, + ]; + const cp = spawnSync(process.execPath, args, { env: { ...process.env, FAIL_FAST: 'true' } }); + const failureCount = (cp.stdout.toString().match(/Test failure:/g) || []).length; + assert.strictEqual(failureCount, 1); +}); diff --git a/test/js/node/test/parallel/test-runner-exit-code.js b/test/js/node/test/parallel/test-runner-exit-code.js new file mode 100644 index 000000000000..c25becee3f70 --- /dev/null +++ b/test/js/node/test/parallel/test-runner-exit-code.js @@ -0,0 +1,87 @@ +'use strict'; +const common = require('../common'); +const fixtures = require('../common/fixtures'); +const assert = require('assert'); +const { spawnSync, spawn } = require('child_process'); +const { once } = require('events'); +const { finished } = require('stream/promises'); + +async function runAndKill(file, expectedTestName) { + if (common.isWindows) { + common.printSkipMessage(`signals are not supported in windows, skipping ${file}`); + return; + } + let stdout = ''; + const child = spawn(process.execPath, ['--test', '--test-reporter=tap', file]); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + if (!stdout.length) child.kill('SIGINT'); + stdout += chunk; + }); + const [code, signal] = await once(child, 'exit'); + await finished(child.stdout); + assert(stdout.startsWith('TAP version 13\n')); + // Verify interrupted test message + assert(stdout.includes(`Interrupted while running: ${expectedTestName}`), + `Expected output to contain interrupted test name`); + assert.strictEqual(signal, null); + assert.strictEqual(code, 1); +} + +if (process.argv[2] === 'child') { + const test = require('node:test'); + + if (process.argv[3] === 'pass') { + test('passing test', () => { + assert.strictEqual(true, true); + }); + } else if (process.argv[3] === 'fail') { + assert.strictEqual(process.argv[3], 'fail'); + test('failing test', () => { + assert.strictEqual(true, false); + }); + } else assert.fail('unreachable'); +} else { + let child = spawnSync(process.execPath, [__filename, 'child', 'pass']); + assert.strictEqual(child.status, 0); + assert.strictEqual(child.signal, null); + + child = spawnSync(process.execPath, [ + '--test', + fixtures.path('test-runner', 'default-behavior', 'subdir', 'subdir_test.js'), + ]); + assert.strictEqual(child.status, 0); + assert.strictEqual(child.signal, null); + + + child = spawnSync(process.execPath, [ + '--test', + fixtures.path('test-runner', 'todo_exit_code.js'), + ]); + assert.strictEqual(child.status, 0); + assert.strictEqual(child.signal, null); + const stdout = child.stdout.toString(); + assert.match(stdout, /tests 4/); + assert.match(stdout, /pass 0/); + assert.match(stdout, /fail 0/); + assert.match(stdout, /todo 4/); + + child = spawnSync(process.execPath, [__filename, 'child', 'fail']); + assert.strictEqual(child.status, 1); + assert.strictEqual(child.signal, null); + + // An error thrown inside describe() should cause a non-zero exit code. + child = spawnSync(process.execPath, [ + '--test', + fixtures.path('test-runner', 'describe_error.js'), + ]); + assert.strictEqual(child.status, 1); + assert.strictEqual(child.signal, null); + + // With process isolation (default), the test name shown is the file path + // because the parent runner only knows about file-level tests + const neverEndingSync = fixtures.path('test-runner', 'never_ending_sync.js'); + const neverEndingAsync = fixtures.path('test-runner', 'never_ending_async.js'); + runAndKill(neverEndingSync, neverEndingSync).then(common.mustCall()); + runAndKill(neverEndingAsync, neverEndingAsync).then(common.mustCall()); +} diff --git a/test/js/node/test/parallel/test-runner-force-exit-failure.js b/test/js/node/test/parallel/test-runner-force-exit-failure.js new file mode 100644 index 000000000000..52032372405e --- /dev/null +++ b/test/js/node/test/parallel/test-runner-force-exit-failure.js @@ -0,0 +1,25 @@ +'use strict'; +require('../common'); +const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); +const fixtures = require('../common/fixtures'); +const fixture = fixtures.path('test-runner/throws_sync_and_async.js'); + +for (const isolation of ['none', 'process']) { + const args = [ + '--test', + '--test-reporter=spec', + '--test-force-exit', + `--test-isolation=${isolation}`, + fixture, + ]; + const r = spawnSync(process.execPath, args); + + assert.strictEqual(r.status, 1); + assert.strictEqual(r.signal, null); + assert.strictEqual(r.stderr.toString(), ''); + + const stdout = r.stdout.toString(); + assert.match(stdout, /Error: fails/); + assert.doesNotMatch(stdout, /this should not have a chance to be thrown/); +} diff --git a/test/js/node/test/parallel/test-runner-force-exit-flush.js b/test/js/node/test/parallel/test-runner-force-exit-flush.js new file mode 100644 index 000000000000..f3b3a7fc26cd --- /dev/null +++ b/test/js/node/test/parallel/test-runner-force-exit-flush.js @@ -0,0 +1,49 @@ +'use strict'; +require('../common'); +const fixtures = require('../common/fixtures'); +const tmpdir = require('../common/tmpdir'); +const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); +const { readFileSync } = require('node:fs'); +const { test } = require('node:test'); + +function runWithReporter(reporter) { + const destination = tmpdir.resolve(`${reporter}.out`); + const args = [ + '--test-force-exit', + `--test-reporter=${reporter}`, + `--test-reporter-destination=${destination}`, + fixtures.path('test-runner', 'reporters.js'), + ]; + const child = spawnSync(process.execPath, args); + assert.strictEqual(child.stdout.toString(), ''); + assert.strictEqual(child.stderr.toString(), ''); + assert.strictEqual(child.status, 1); + return destination; +} + +tmpdir.refresh(); + +test('junit reporter', () => { + const output = readFileSync(runWithReporter('junit'), 'utf8'); + assert.match(output, //); + assert.match(output, //); + assert.match(output, //); + assert.match(output, /