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}\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 () => {