diff --git a/src/bun.js/ModuleLoader.zig b/src/bun.js/ModuleLoader.zig
index 09921adc0a9b..096e5385ed28 100644
--- a/src/bun.js/ModuleLoader.zig
+++ b/src/bun.js/ModuleLoader.zig
@@ -2678,6 +2678,7 @@ pub const HardcodedModule = enum {
@"node:stream/web",
@"node:string_decoder",
@"node:test",
+ @"node:test/reporters",
@"node:timers",
@"node:timers/promises",
@"node:tls",
@@ -2760,6 +2761,7 @@ pub const HardcodedModule = enum {
.{ "node:net", .@"node:net" },
.{ "node:readline", .@"node:readline" },
.{ "node:test", .@"node:test" },
+ .{ "node:test/reporters", .@"node:test/reporters" },
.{ "node:os", .@"node:os" },
.{ "node:path", .@"node:path" },
.{ "node:path/posix", .@"node:path/posix" },
@@ -2899,6 +2901,7 @@ pub const HardcodedModule = enum {
nodeEntry("node:zlib"),
// New Node.js builtins only resolve from the prefixed one.
nodeEntryOnlyPrefix("node:test"),
+ nodeEntryOnlyPrefix("node:test/reporters"),
nodeEntry("assert"),
nodeEntry("assert/strict"),
diff --git a/src/bun.js/bindings/ProcessBindingNatives.cpp b/src/bun.js/bindings/ProcessBindingNatives.cpp
index 1eaaaa45ff33..15e0962806fb 100644
--- a/src/bun.js/bindings/ProcessBindingNatives.cpp
+++ b/src/bun.js/bindings/ProcessBindingNatives.cpp
@@ -151,6 +151,12 @@ static JSValue processBindingNativesReturnUndefined(VM& vm, JSObject* bindingObj
internal/streams/transform processBindingNativesGetter PropertyCallback
internal/streams/utils processBindingNativesGetter PropertyCallback
internal/streams/writable processBindingNativesGetter PropertyCallback
+ internal/test/reporter/dot processBindingNativesGetter PropertyCallback
+ internal/test/reporter/junit processBindingNativesGetter PropertyCallback
+ internal/test/reporter/lcov processBindingNativesGetter PropertyCallback
+ internal/test/reporter/spec processBindingNativesGetter PropertyCallback
+ internal/test/reporter/tap processBindingNativesGetter PropertyCallback
+ internal/test/reporter/utils processBindingNativesGetter PropertyCallback
internal/timers processBindingNativesGetter PropertyCallback
internal/tls processBindingNativesGetter PropertyCallback
internal/tty processBindingNativesGetter PropertyCallback
diff --git a/src/bun.js/bindings/isBuiltinModule.cpp b/src/bun.js/bindings/isBuiltinModule.cpp
index 1bfd6ad23f32..8eb7f4f1cd29 100644
--- a/src/bun.js/bindings/isBuiltinModule.cpp
+++ b/src/bun.js/bindings/isBuiltinModule.cpp
@@ -79,6 +79,7 @@ static constexpr ASCIILiteral builtinModuleNamesSortedLength[] = {
"inspector/promises"_s,
"_stream_passthrough"_s,
"diagnostics_channel"_s,
+ "node:test/reporters"_s,
};
namespace Bun {
diff --git a/src/js/internal/primordials.js b/src/js/internal/primordials.js
index f0b3f598ecad..39abe767136e 100644
--- a/src/js/internal/primordials.js
+++ b/src/js/internal/primordials.js
@@ -84,6 +84,27 @@ const SafeArrayIterator = createSafeIterator(ArrayPrototypeSymbolIterator, Array
const ArrayPrototypeMap = Array.prototype.map;
const PromisePrototypeThen = $Promise.prototype.$then;
+const RegExpPrototype = RegExp.prototype;
+const SymbolMatch = Symbol.match;
+const SymbolMatchAll = Symbol.matchAll;
+const SymbolReplace = Symbol.replace;
+const SymbolSearch = Symbol.search;
+const SymbolSplit = Symbol.split;
+const RegExpPrototypeSymbolMatch = RegExpPrototype[SymbolMatch];
+const RegExpPrototypeSymbolMatchAll = RegExpPrototype[SymbolMatchAll];
+const RegExpPrototypeSymbolReplace = RegExpPrototype[SymbolReplace];
+const RegExpPrototypeSymbolSearch = RegExpPrototype[SymbolSearch];
+const RegExpPrototypeSymbolSplit = RegExpPrototype[SymbolSplit];
+const RegExpPrototypeExec = RegExpPrototype.exec;
+const RegExpPrototypeGetDotAll = getGetter(RegExp, "dotAll");
+const RegExpPrototypeGetGlobal = getGetter(RegExp, "global");
+const RegExpPrototypeGetHasIndices = getGetter(RegExp, "hasIndices");
+const RegExpPrototypeGetIgnoreCase = getGetter(RegExp, "ignoreCase");
+const RegExpPrototypeGetMultiline = getGetter(RegExp, "multiline");
+const RegExpPrototypeGetSource = getGetter(RegExp, "source");
+const RegExpPrototypeGetSticky = getGetter(RegExp, "sticky");
+const RegExpPrototypeGetUnicode = getGetter(RegExp, "unicode");
+const RegExpPrototypeGetFlags = getGetter(RegExp, "flags");
const arrayToSafePromiseIterable = (promises, mapFn) =>
new SafeArrayIterator(
@@ -118,6 +139,113 @@ const SafePromiseAllReturnArrayLike = (promises, mapFn) =>
}
});
+class RegExpLikeForStringSplitting {
+ #regex;
+ constructor() {
+ this.#regex = ReflectConstruct(RegExp, arguments);
+ }
+
+ get lastIndex() {
+ return ReflectGet(this.#regex, "lastIndex");
+ }
+ set lastIndex(value) {
+ ReflectSet(this.#regex, "lastIndex", value);
+ }
+
+ exec() {
+ return ReflectApply(RegExpPrototypeExec, this.#regex, arguments);
+ }
+}
+ObjectSetPrototypeOf(RegExpLikeForStringSplitting.prototype, null);
+
+function hardenRegExp(pattern) {
+ ObjectDefineProperties(pattern, {
+ [SymbolMatch]: {
+ __proto__: null,
+ configurable: true,
+ value: RegExpPrototypeSymbolMatch,
+ },
+ [SymbolMatchAll]: {
+ __proto__: null,
+ configurable: true,
+ value: RegExpPrototypeSymbolMatchAll,
+ },
+ [SymbolReplace]: {
+ __proto__: null,
+ configurable: true,
+ value: RegExpPrototypeSymbolReplace,
+ },
+ [SymbolSearch]: {
+ __proto__: null,
+ configurable: true,
+ value: RegExpPrototypeSymbolSearch,
+ },
+ [SymbolSplit]: {
+ __proto__: null,
+ configurable: true,
+ value: RegExpPrototypeSymbolSplit,
+ },
+ constructor: {
+ __proto__: null,
+ configurable: true,
+ value: {
+ [SymbolSpecies]: RegExpLikeForStringSplitting,
+ },
+ },
+ dotAll: {
+ __proto__: null,
+ configurable: true,
+ value: RegExpPrototypeGetDotAll(pattern),
+ },
+ exec: {
+ __proto__: null,
+ configurable: true,
+ value: OriginalRegExpPrototypeExec,
+ },
+ global: {
+ __proto__: null,
+ configurable: true,
+ value: RegExpPrototypeGetGlobal(pattern),
+ },
+ hasIndices: {
+ __proto__: null,
+ configurable: true,
+ value: RegExpPrototypeGetHasIndices(pattern),
+ },
+ ignoreCase: {
+ __proto__: null,
+ configurable: true,
+ value: RegExpPrototypeGetIgnoreCase(pattern),
+ },
+ multiline: {
+ __proto__: null,
+ configurable: true,
+ value: RegExpPrototypeGetMultiline(pattern),
+ },
+ source: {
+ __proto__: null,
+ configurable: true,
+ value: RegExpPrototypeGetSource(pattern),
+ },
+ sticky: {
+ __proto__: null,
+ configurable: true,
+ value: RegExpPrototypeGetSticky(pattern),
+ },
+ unicode: {
+ __proto__: null,
+ configurable: true,
+ value: RegExpPrototypeGetUnicode(pattern),
+ },
+ });
+ ObjectDefineProperty(pattern, "flags", {
+ __proto__: null,
+ configurable: true,
+ value: RegExpPrototypeGetFlags(pattern),
+ });
+ return pattern;
+}
+
export default {
Array,
SafeArrayIterator,
@@ -177,4 +305,5 @@ export default {
BigUint64Array,
BigInt64Array,
uncurryThis,
+ hardenRegExp,
};
diff --git a/src/js/internal/test/reporter/dot.ts b/src/js/internal/test/reporter/dot.ts
new file mode 100644
index 000000000000..e420a53918ea
--- /dev/null
+++ b/src/js/internal/test/reporter/dot.ts
@@ -0,0 +1,40 @@
+const colors = require("internal/util/colors");
+const { formatTestReport } = require("internal/test/reporter/utils");
+
+const ArrayPrototypePush = Array.prototype.push;
+const MathMax = Math.max;
+
+async function* dot(source) {
+ let count = 0;
+ let columns = getLineLength();
+ const failedTests = [];
+ 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}`;
+ ArrayPrototypePush.$apply(failedTests, data);
+ }
+ if ((type === "test:fail" || type === "test:pass") && ++count === columns) {
+ yield "\n";
+
+ // Getting again in case the terminal was resized.
+ 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 MathMax(process.stdout.columns ?? 20, 20);
+}
+
+export default dot;
diff --git a/src/js/internal/test/reporter/junit.ts b/src/js/internal/test/reporter/junit.ts
new file mode 100644
index 000000000000..16f65022a016
--- /dev/null
+++ b/src/js/internal/test/reporter/junit.ts
@@ -0,0 +1,170 @@
+const { inspectWithNoCustomRetry } = require("internal/test/reporter/utils");
+const { hostname } = require("node:os");
+
+const ArrayPrototypeFilter = Array.prototype.filter;
+const ArrayPrototypeJoin = Array.prototype.join;
+const ArrayPrototypeMap = Array.prototype.map;
+const ArrayPrototypePush = Array.prototype.push;
+const ArrayPrototypeSome = Array.prototype.some;
+const NumberPrototypeToFixed = Number.prototype.toFixed;
+const ObjectEntries = Object.entries;
+const RegExpPrototypeSymbolReplace = RegExp.prototype[Symbol.replace];
+const StringPrototypeRepeat = String.prototype.repeat;
+
+const inspectOptions = { __proto__: null, colors: false, breakLength: Infinity };
+const HOSTNAME = hostname();
+
+function escapeAttribute(s = "") {
+ return escapeContent(
+ RegExpPrototypeSymbolReplace.$apply(/"/g, RegExpPrototypeSymbolReplace.$apply(/\n/g, s, ""), """),
+ );
+}
+
+function escapeContent(s = "") {
+ return RegExpPrototypeSymbolReplace.$apply(/\n`;
+ }
+ const attrsString = ArrayPrototypeJoin.$apply(
+ ArrayPrototypeMap.$apply(
+ ObjectEntries(attrs),
+ ({ 0: key, 1: value }) => `${key}="${escapeAttribute(String(value))}"`,
+ ),
+ " ",
+ );
+ if (!children?.length) {
+ return `${indent}<${tag} ${attrsString}/>\n`;
+ }
+ const childrenString = ArrayPrototypeJoin.$apply(ArrayPrototypeMap.$apply(children ?? [], treeToXML), "");
+ return `${indent}<${tag} ${attrsString}>\n${childrenString}${indent}${tag}>\n`;
+}
+
+function isFailure(node) {
+ return (
+ (node?.children && ArrayPrototypeSome.$apply(node.children, c => c.tag === "failure")) || node?.attrs?.failures
+ );
+}
+
+function isSkipped(node) {
+ return (
+ (node?.children && ArrayPrototypeSome.$apply(node.children, c => c.tag === "skipped")) || node?.attrs?.failures
+ );
+}
+
+async function* junitReporter(source) {
+ yield '\n';
+ yield "\n";
+ let currentSuite = null;
+ const roots = [];
+
+ 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) {
+ ArrayPrototypePush.$apply(originalSuite.children, currentSuite);
+ }
+ if (!currentSuite.parent) {
+ ArrayPrototypePush.$apply(roots, 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 = NumberPrototypeToFixed.$apply(event.data.details.duration_ms / 1000, 6);
+ const nonCommentChildren = ArrayPrototypeFilter.$apply(currentTest.children, c => c.comment == null);
+ if (nonCommentChildren.length > 0) {
+ currentTest.tag = "testsuite";
+ currentTest.attrs.disabled = 0;
+ currentTest.attrs.errors = 0;
+ currentTest.attrs.tests = nonCommentChildren.length;
+ currentTest.attrs.failures = ArrayPrototypeFilter.$apply(currentTest.children, isFailure).length;
+ currentTest.attrs.skipped = ArrayPrototypeFilter.$apply(currentTest.children, isSkipped).length;
+ currentTest.attrs.hostname = HOSTNAME;
+ } else {
+ currentTest.tag = "testcase";
+ currentTest.attrs.classname = event.data.classname ?? "test";
+ if (event.data.skip) {
+ ArrayPrototypePush.$apply(currentTest.children, {
+ __proto__: null,
+ nesting: event.data.nesting + 1,
+ tag: "skipped",
+ attrs: { __proto__: null, type: "skipped", message: event.data.skip },
+ });
+ }
+ if (event.data.todo) {
+ ArrayPrototypePush.$apply(currentTest.children, {
+ __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 ?? "" },
+ children: [inspectWithNoCustomRetry(error, inspectOptions)],
+ });
+ currentTest.failures = 1;
+ currentTest.attrs.failure = error?.message ?? "";
+ }
+ }
+ break;
+ }
+ case "test:diagnostic": {
+ const parent = currentSuite?.children ?? roots;
+ ArrayPrototypePush.$apply(parent, {
+ __proto__: null,
+ nesting: event.data.nesting,
+ comment: event.data.message,
+ });
+ break;
+ }
+ default:
+ break;
+ }
+ }
+ for (const suite of roots) {
+ yield treeToXML(suite);
+ }
+ yield "\n";
+}
+
+export default junitReporter;
diff --git a/src/js/internal/test/reporter/lcov.ts b/src/js/internal/test/reporter/lcov.ts
new file mode 100644
index 000000000000..8560a919ce5d
--- /dev/null
+++ b/src/js/internal/test/reporter/lcov.ts
@@ -0,0 +1,105 @@
+const { relative } = require("node:path");
+const Transform = require("internal/streams/transform");
+
+// This reporter is based on the LCOV format, as described here:
+// https://ltp.sourceforge.net/coverage/lcov/geninfo.1.php
+// Excerpts from this documentation are included in the comments that make up
+// the _transform function below.
+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 = "";
+ // A tracefile is made up of several human-readable lines of text, divided
+ // into sections. If available, a tracefile begins with the testname which
+ // is stored in the following format:
+ // ## TN:\
+ lcov += "TN:\n";
+ const {
+ data: {
+ summary: { workingDirectory },
+ },
+ } = event;
+ try {
+ for (let i = 0; i < event.data.summary.files.length; i++) {
+ const file = event.data.summary.files[i];
+ // For each source file referenced in the .da file, there is a section
+ // containing filename and coverage data:
+ // ## SF:\
+ lcov += `SF:${relative(workingDirectory, file.path)}\n`;
+
+ // Following is a list of line numbers for each function name found in
+ // the source file:
+ // ## FN:\,\
+ //
+ // After, there is a list of execution counts for each instrumented
+ // function:
+ // ## FNDA:\,\
+ //
+ // This loop adds the FN lines to the lcov variable as it goes and
+ // gathers the FNDA lines to be added later. This way we only loop
+ // through the list of functions once.
+ let fnda = "";
+ for (let j = 0; j < file.functions.length; j++) {
+ const func = file.functions[j];
+ const name = func.name || `anonymous_${j}`;
+ lcov += `FN:${func.line},${name}\n`;
+ fnda += `FNDA:${func.count},${name}\n`;
+ }
+ lcov += fnda;
+
+ // This list is followed by two lines containing the number of
+ // functions found and hit:
+ // ## FNF:\
+ // ## FNH:\
+ lcov += `FNF:${file.totalFunctionCount}\n`;
+ lcov += `FNH:${file.coveredFunctionCount}\n`;
+
+ // Branch coverage information is stored which one line per branch:
+ // ## BRDA:\,\,\,\
+ // Block number and branch number are gcc internal IDs for the branch.
+ // Taken is either '-' if the basic block containing the branch was
+ // never executed or a number indicating how often that branch was
+ // taken.
+ for (let j = 0; j < file.branches.length; j++) {
+ lcov += `BRDA:${file.branches[j].line},${j},0,${file.branches[j].count}\n`;
+ }
+
+ // Branch coverage summaries are stored in two lines:
+ // ## BRF:\
+ // ## BRH:\
+ lcov += `BRF:${file.totalBranchCount}\n`;
+ lcov += `BRH:${file.coveredBranchCount}\n`;
+
+ // Then there is a list of execution counts for each instrumented line
+ // (i.e. a line which resulted in executable code):
+ // ## DA:\,\[,\]
+ const sortedLines = file.lines.toSorted((a, b) => a.line - b.line);
+ for (let j = 0; j < sortedLines.length; j++) {
+ lcov += `DA:${sortedLines[j].line},${sortedLines[j].count}\n`;
+ }
+
+ // At the end of a section, there is a summary about how many lines
+ // were found and how many were actually instrumented:
+ // ## LH:\
+ // ## LF:\
+ lcov += `LH:${file.coveredLineCount}\n`;
+ lcov += `LF:${file.totalLineCount}\n`;
+
+ // Each sections ends with:
+ // end_of_record
+ lcov += "end_of_record\n";
+ }
+ } catch (error) {
+ return callback(error);
+ }
+ return callback(null, lcov);
+ }
+}
+
+export default LcovReporter;
diff --git a/src/js/internal/test/reporter/spec.ts b/src/js/internal/test/reporter/spec.ts
new file mode 100644
index 000000000000..c7fdf45535e2
--- /dev/null
+++ b/src/js/internal/test/reporter/spec.ts
@@ -0,0 +1,119 @@
+const Transform = require("internal/streams/transform");
+const colors = require("internal/util/colors");
+const { relative } = require("node:path");
+const {
+ formatTestReport,
+ indent,
+ reporterColorMap,
+ reporterUnicodeSymbolMap,
+} = require("internal/test/reporter/utils");
+
+const ArrayPrototypeJoin = Array.prototype.join;
+const ArrayPrototypePop = Array.prototype.pop;
+const ArrayPrototypePush = Array.prototype.push;
+const ArrayPrototypeShift = Array.prototype.shift;
+const ArrayPrototypeUnshift = Array.prototype.unshift;
+
+class SpecReporter extends Transform {
+ #stack = [];
+ #reported = [];
+ #failedTests = [];
+ #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);
+
+ if (test.file) {
+ const relPath = relative(this.#cwd, test.file);
+ const location = `test at ${relPath}:${test.line}:${test.column}`;
+ ArrayPrototypePush.$apply(results, location);
+ }
+
+ ArrayPrototypePush.$apply(results, formattedErr);
+ }
+
+ this.#failedTests = []; // Clean up the failed tests
+ return ArrayPrototypeJoin.$apply(results, "\n");
+ }
+ #handleTestReportEvent(type, data) {
+ const subtest = ArrayPrototypeShift.$apply(this.#stack); // This is the matching `test:start` event
+ if (subtest) {
+ $assert(subtest.type === "test:start");
+ $assert(subtest.data.nesting === data.nesting);
+ $assert(subtest.data.name === data.name);
+ }
+ let prefix = "";
+ while (this.#stack.length) {
+ // Report all the parent `test:start` events
+ const parent = ArrayPrototypePop.$apply(this.#stack);
+ $assert(parent.type === "test:start");
+ const msg = parent.data;
+ ArrayPrototypeUnshift.$apply(this.#reported, msg);
+ prefix += `${indent(msg.nesting)}${reporterUnicodeSymbolMap["arrow:right"]}${msg.name}\n`;
+ }
+ let hasChildren = false;
+ if (this.#reported[0] && this.#reported[0].nesting === data.nesting && this.#reported[0].name === data.name) {
+ ArrayPrototypeShift.$apply(this.#reported);
+ hasChildren = true;
+ }
+ const indentation = indent(data.nesting);
+ return `${formatTestReport(type, data, prefix, indentation, hasChildren, false)}\n`;
+ }
+ #handleEvent({ type, data }) {
+ switch (type) {
+ case "test:fail":
+ if (data.details?.error?.failureType !== kSubtestsFailed) {
+ ArrayPrototypePush.$apply(this.#failedTests, data);
+ }
+ return this.#handleTestReportEvent(type, data);
+ case "test:pass":
+ return this.#handleTestReportEvent(type, data);
+ case "test:start":
+ ArrayPrototypeUnshift.$apply(this.#stack, { __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:coverage":
+ return getCoverageReport(
+ indent(data.nesting),
+ data.summary,
+ reporterUnicodeSymbolMap["test:coverage"],
+ colors.blue,
+ true,
+ );
+ case "test:summary":
+ // We report only the root test summary
+ if (data.file === undefined) {
+ return this.#formatFailedTestResults();
+ }
+ }
+ }
+ _transform({ type, data }, encoding, callback) {
+ callback(null, this.#handleEvent({ __proto__: null, type, data }));
+ }
+ _flush(callback) {
+ callback(null, this.#formatFailedTestResults());
+ }
+}
+
+export default SpecReporter;
diff --git a/src/js/internal/test/reporter/tap.ts b/src/js/internal/test/reporter/tap.ts
new file mode 100644
index 000000000000..4d9cc0a4e00f
--- /dev/null
+++ b/src/js/internal/test/reporter/tap.ts
@@ -0,0 +1,262 @@
+const { kEmptyObject } = require("internal/shared");
+const { inspectWithNoCustomRetry } = require("internal/test/reporter/utils");
+const { SafeMap, SafeSet } = require("internal/primordials");
+const { isDate } = require("node:util/types");
+
+const ArrayPrototypeForEach = Array.prototype.forEach;
+const ArrayPrototypeJoin = Array.prototype.join;
+const ArrayPrototypePush = Array.prototype.push;
+const DatePrototypeToISOString = Date.prototype.toISOString;
+const ObjectEntries = Object.entries;
+const RegExpPrototypeSymbolReplace = RegExp.prototype[Symbol.replace];
+const RegExpPrototypeSymbolSplit = RegExp.prototype[Symbol.split];
+const StringPrototypeRepeat = String.prototype.repeat;
+const StringPrototypeReplaceAll = String.prototype.replaceAll;
+
+const kDefaultIndent = " "; // 4 spaces
+const kFrameStartRegExp = /^ {4}at /;
+const kLineBreakRegExp = /\n|\r\n/;
+const kDefaultTAPVersion = 13;
+const inspectOptions = { __proto__: null, colors: false, breakLength: Infinity };
+
+let testModule = undefined; // Lazy loaded due to circular dependency.
+function lazyLoadTest() {
+ // testModule ??= require("internal/test_runner/test");
+ return testModule;
+}
+
+async function* tapReporter(source) {
+ yield `TAP version ${kDefaultTAPVersion}\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);
+ const location = data.file ? `${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);
+ yield reportDetails(data.nesting, data.details, null);
+ break;
+ case "test:plan":
+ yield `${indent(data.nesting)}1..${data.count}\n`;
+ break;
+ case "test:start":
+ yield `${indent(data.nesting)}# Subtest: ${tapEscape(data.name)}\n`;
+ break;
+ case "test:stderr":
+ case "test:stdout": {
+ const lines = RegExpPrototypeSymbolSplit.$apply(kLineBreakRegExp, data.message);
+ for (let i = 0; i < lines.length; i++) {
+ if (lines[i].length === 0) continue;
+ yield `# ${tapEscape(lines[i])}\n`;
+ }
+ break;
+ }
+ case "test:diagnostic":
+ yield `${indent(data.nesting)}# ${tapEscape(data.message)}\n`;
+ break;
+ case "test:coverage":
+ yield getCoverageReport(indent(data.nesting), data.summary, "# ", "", true);
+ break;
+ }
+ }
+}
+
+function reportTest(nesting, testNumber, status, name, skip, todo) {
+ let line = `${indent(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 += ` # TODO${typeof todo === "string" && todo.length ? ` ${tapEscape(todo)}` : ""}`;
+ }
+
+ line += "\n";
+
+ return line;
+}
+
+function reportDetails(nesting, data = kEmptyObject, location) {
+ const { error, duration_ms } = data;
+ const _indent = indent(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 SafeSet());
+ details += `${_indent} ...\n`;
+ return details;
+}
+
+const memo = new SafeMap();
+function indent(nesting) {
+ let value = memo.get(nesting);
+ if (value === undefined) {
+ value = StringPrototypeRepeat.$apply(kDefaultIndent, nesting);
+ memo.set(nesting, value);
+ }
+
+ return value;
+}
+
+// In certain places, # and \ need to be escaped as \# and \\.
+function tapEscape(input) {
+ let result = StringPrototypeReplaceAll.$apply(input, "\b", "\\b");
+ result = StringPrototypeReplaceAll.$apply(result, "\f", "\\f");
+ result = StringPrototypeReplaceAll.$apply(result, "\t", "\\t");
+ result = StringPrototypeReplaceAll.$apply(result, "\n", "\\n");
+ result = StringPrototypeReplaceAll.$apply(result, "\r", "\\r");
+ result = StringPrototypeReplaceAll.$apply(result, "\v", "\\v");
+ result = StringPrototypeReplaceAll.$apply(result, "\\", "\\\\");
+ result = StringPrototypeReplaceAll.$apply(result, "#", "\\#");
+ return result;
+}
+
+function jsToYaml(indent, name, value, seen) {
+ if (value === undefined) {
+ return "";
+ }
+
+ const prefix = `${indent} ${name}:`;
+
+ if (value === null) {
+ return `${prefix} ~\n`;
+ }
+
+ if (typeof value !== "object") {
+ if (typeof value !== "string") {
+ return `${prefix} ${inspectWithNoCustomRetry(value, inspectOptions)}\n`;
+ }
+
+ const lines = RegExpPrototypeSymbolSplit.$apply(kLineBreakRegExp, value);
+
+ if (lines.length === 1) {
+ return `${prefix} ${inspectWithNoCustomRetry(value, inspectOptions)}\n`;
+ }
+
+ let str = `${prefix} |-\n`;
+
+ for (let i = 0; i < lines.length; i++) {
+ str += `${indent} ${lines[i]}\n`;
+ }
+
+ return str;
+ }
+
+ seen.add(value);
+ const entries = ObjectEntries(value);
+ const isErrorObj = Error.isError(value);
+ let propsIndent = indent;
+ let result = "";
+
+ if (name != null) {
+ result += prefix;
+ if (isDate(value)) {
+ // YAML uses the ISO-8601 standard to express dates.
+ result += " " + DatePrototypeToISOString.$apply(value);
+ }
+ result += "\n";
+ propsIndent += " ";
+ }
+
+ for (let i = 0; i < entries.length; i++) {
+ const { 0: key, 1: value } = entries[i];
+
+ if (isErrorObj && (key === "cause" || key === "code")) {
+ continue;
+ }
+ if (seen.has(value)) {
+ result += `${propsIndent} ${key}: \n`;
+ continue;
+ }
+
+ result += jsToYaml(propsIndent, key, value, seen);
+ }
+
+ if (isErrorObj) {
+ const { kUnwrapErrors } = lazyLoadTest();
+ const { cause, code, failureType, message, expected, actual, operator, stack, name } = value;
+ let errMsg = message ?? "";
+ let errName = name;
+ let errStack = stack;
+ let errCode = code;
+ let errExpected = expected;
+ let errActual = actual;
+ let errOperator = operator;
+ let errIsAssertion = isAssertionLike(value);
+
+ // If the ERR_TEST_FAILURE came from an error provided by user code,
+ // then try to unwrap the original error message and stack.
+ 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(indent, "error", errMsg, seen);
+
+ if (errCode) {
+ result += jsToYaml(indent, "code", errCode, seen);
+ }
+ if (errName && errName !== "Error") {
+ result += jsToYaml(indent, "name", errName, seen);
+ }
+
+ if (errIsAssertion) {
+ // Note that we're deliberately creating shallow copies of the `seen`
+ // set here in order to isolate the discovery of circular references
+ // within the expected and actual properties respectively.
+ result += jsToYaml(indent, "expected", errExpected, new SafeSet(seen));
+ result += jsToYaml(indent, "actual", errActual, new SafeSet(seen));
+ if (errOperator) {
+ result += jsToYaml(indent, "operator", errOperator, seen);
+ }
+ }
+
+ if (typeof errStack === "string") {
+ const frames = [];
+
+ ArrayPrototypeForEach.$apply(RegExpPrototypeSymbolSplit.$apply(kLineBreakRegExp, errStack), frame => {
+ const processed = RegExpPrototypeSymbolReplace.$apply(kFrameStartRegExp, frame, "");
+
+ if (processed.length > 0 && processed.length !== frame.length) {
+ ArrayPrototypePush.$apply(frames, processed);
+ }
+ });
+
+ if (frames.length > 0) {
+ const frameDelimiter = `\n${indent} `;
+
+ result += `${indent} stack: |-${frameDelimiter}`;
+ result += `${ArrayPrototypeJoin.$apply(frames, frameDelimiter)}\n`;
+ }
+ }
+ }
+
+ return result;
+}
+
+function isAssertionLike(value) {
+ return value && typeof value === "object" && "expected" in value && "actual" in value;
+}
+
+export default tapReporter;
diff --git a/src/js/internal/test/reporter/utils.ts b/src/js/internal/test/reporter/utils.ts
new file mode 100644
index 000000000000..b55c1caf81f6
--- /dev/null
+++ b/src/js/internal/test/reporter/utils.ts
@@ -0,0 +1,112 @@
+const colors = require("internal/util/colors");
+const { SafeMap, hardenRegExp } = require("internal/primordials");
+
+const ArrayPrototypeJoin = Array.prototype.join;
+const RegExpPrototypeSymbolSplit = RegExp.prototype[Symbol.split];
+const StringPrototypeRepeat = String.prototype.repeat;
+
+const indentMemo = new SafeMap();
+
+const inspectOptions = {
+ __proto__: null,
+ colors: colors.shouldColorize(process.stdout),
+ breakLength: Infinity,
+};
+
+const reporterUnicodeSymbolMap = {
+ "__proto__": null,
+ "test:fail": "\u2716 ",
+ "test:pass": "\u2714 ",
+ "test:diagnostic": "\u2139 ",
+ "test:coverage": "\u2139 ",
+ "arrow:right": "\u25B6 ",
+ "hyphen:minus": "\uFE63 ",
+};
+
+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;
+ },
+};
+
+function indent(nesting) {
+ let value = indentMemo.get(nesting);
+ if (value === undefined) {
+ value = StringPrototypeRepeat.$apply(" ", nesting);
+ indentMemo.set(nesting, value);
+ }
+ return value;
+}
+
+function formatError(error, indent) {
+ if (!error) return "";
+ const err = error.code === "ERR_TEST_FAILURE" ? error.cause : error;
+ const message = ArrayPrototypeJoin.$apply(
+ RegExpPrototypeSymbolSplit.$apply(hardenRegExp(/\r?\n/), inspectWithNoCustomRetry(err, inspectOptions)),
+ `\n${indent} `,
+ );
+ return `\n${indent} ${message}\n`;
+}
+
+function formatTestReport(type, data, prefix = "", indent = "", hasChildren = false, showErrorDetails = true) {
+ let color = reporterColorMap[type] ?? colors.white;
+ let symbol = reporterUnicodeSymbolMap[type] ?? " ";
+ const { skip, todo } = data;
+ const duration_ms = data.details?.duration_ms ? ` ${colors.gray}(${data.details.duration_ms}ms)${colors.white}` : "";
+ let title = `${data.name}${duration_ms}`;
+
+ if (skip !== undefined) {
+ title += ` # ${typeof skip === "string" && skip.length ? skip : "SKIP"}`;
+ } else if (todo !== undefined) {
+ title += ` # ${typeof todo === "string" && todo.length ? todo : "TODO"}`;
+ }
+
+ const error = showErrorDetails ? formatError(data.details?.error, indent) : "";
+ const err = hasChildren
+ ? !error || data.details?.error?.failureType === "subtestsFailed"
+ ? ""
+ : `\n${error}`
+ : error;
+
+ if (skip !== undefined) {
+ color = colors.gray;
+ symbol = reporterUnicodeSymbolMap["hyphen:minus"];
+ }
+ return `${prefix}${indent}${color}${symbol}${title}${colors.white}${err}`;
+}
+
+let utilInspect;
+function inspectWithNoCustomRetry(obj, options) {
+ utilInspect ??= require("internal/util/inspect");
+ const { inspect } = utilInspect;
+
+ try {
+ return inspect(obj, options);
+ } catch {
+ return inspect(obj, { ...options, customInspect: false });
+ }
+}
+
+export default {
+ reporterUnicodeSymbolMap,
+ reporterColorMap,
+ formatTestReport,
+ indent,
+ inspectWithNoCustomRetry,
+};
diff --git a/src/js/node/test.reporters.ts b/src/js/node/test.reporters.ts
new file mode 100644
index 000000000000..4c294abd9859
--- /dev/null
+++ b/src/js/node/test.reporters.ts
@@ -0,0 +1,60 @@
+// Hardcoded module "node:test/reporters"
+
+const ObjectDefineProperties = Object.defineProperties;
+
+let dot;
+let junit;
+let spec;
+let tap;
+let lcov;
+
+const default_exports = {};
+ObjectDefineProperties(default_exports, {
+ dot: {
+ __proto__: null,
+ configurable: true,
+ enumerable: true,
+ get() {
+ dot ??= require("internal/test/reporter/dot");
+ return dot;
+ },
+ },
+ junit: {
+ __proto__: null,
+ configurable: true,
+ enumerable: true,
+ get() {
+ junit ??= require("internal/test/reporter/junit");
+ return junit;
+ },
+ },
+ spec: {
+ __proto__: null,
+ configurable: true,
+ enumerable: true,
+ value: function value() {
+ spec ??= require("internal/test/reporter/spec");
+ return new spec(...arguments);
+ },
+ },
+ tap: {
+ __proto__: null,
+ configurable: true,
+ enumerable: true,
+ get() {
+ tap ??= require("internal/test/reporter/tap");
+ return tap;
+ },
+ },
+ lcov: {
+ __proto__: null,
+ configurable: true,
+ enumerable: true,
+ value: function value() {
+ lcov ??= require("internal/test/reporter/lcov");
+ return new lcov(...arguments);
+ },
+ },
+});
+
+export default default_exports;
diff --git a/test/cli/test/claudecode-flag.test.ts b/test/cli/test/claudecode-flag.test.ts
index 17c36ea9aaa4..80c082e6f9b5 100644
--- a/test/cli/test/claudecode-flag.test.ts
+++ b/test/cli/test/claudecode-flag.test.ts
@@ -6,7 +6,7 @@ let testEnv: NodeJS.Dict;
beforeAll(() => {
testEnv = { ...bunEnv };
- delete testEnv.AGENTS;
+ delete testEnv.AGENT;
});
test("CLAUDECODE=1 shows quiet test output (only failures)", async () => {