/** The run summary's printing half: pure data in, console lines out. The * TAP reporter produces a `CompactTapSummary`; this module decides what a * person reads from a pass, a failure, or a silent exit. Kept apart from * the reporter so tests can pin the wording without a TAP stream. */ import { stripAnsi } from "./ansi.ts"; export type Location = { file: string; line?: number | undefined; column?: number | undefined; }; export type CompactFailure = { name: string; message: string; location?: Location | undefined; }; /** The message the summary reports when a failed TAP result carried no * diagnostic block: the marker that says deno gave no cause for the * failure, which is what the JUnit cancellation attribution requires. */ export const NO_TAP_DIAGNOSTIC = "No TAP diagnostic was emitted for this failure."; export type CompactTapSummary = { passed: number; failed: number; /** Results deno reported as skipped (an ignored test), which ran nothing. */ skipped: number; failures: CompactFailure[]; sawTap: boolean; /** Parent results the reporter deliberately dropped because a child step * failure already carries the real diagnostic. */ suppressedResults: number; /** The pre-run estimate from the test files' declarations, before any * growth the progress bar applied. 0 when the run gave no estimate. */ fileEstimate: number; /** The name of the last result line the output carried. */ lastResultName?: string | undefined; /** Stdout lines the TAP stream carried beside the results — the only place * a dying child's own error text can appear. */ droppedLines: string[]; }; export const formatLocation = (location?: Location): string => location ? `${location.file}${location.line ? `:${location.line}` : ""}${ location.column ? `:${location.column}` : "" }` : "unknown location"; /** One errored deno's JUnit report recorded: the suite the * runner cancelled after an uncaught error, at the file and declare site * the suite was written at. Deno's TAP output drops an uncaught error's * text entirely, so this record is the only attribution the run carries. */ export type JunitErroredTest = { column?: number | undefined; file: string; line?: number | undefined; message: string; name: string; }; const XML_ENTITIES: Record = { amp: "&", apos: "'", gt: ">", lt: "<", quot: '"', }; /** XML permits code points up to U+10FFFF; anything outside that range * stays as the entity text it came from rather than crashing the summary * a failing run reads. */ const decodedCodePoint = (code: number, text: string): string => Number.isInteger(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : text; const decodeXmlEntities = (value: string): string => value.replace( /&(?:#(?\d+)|#x(?[0-9a-fA-F]+)|(?[A-Za-z][A-Za-z0-9]*));/g, (text, decimal, hexadecimal, word) => { if (decimal !== undefined) { return decodedCodePoint(Number(decimal), text); } if (hexadecimal !== undefined) { return decodedCodePoint(Number.parseInt(hexadecimal, 16), text); } return XML_ENTITIES[word.toLowerCase()] ?? text; }, ); const ATTRIBUTE_PATTERNS = { classname: /\bclassname="([^"]*)"/, col: /\bcol="([^"]*)"/, line: /\bline="([^"]*)"/, message: /\bmessage="([^"]*)"/, name: /\bname="([^"]*)"/, } as const; type XmlAttribute = keyof typeof ATTRIBUTE_PATTERNS; /** The decoded attribute value, or "" when the record carries none. */ const xmlAttribute = (tag: string, attribute: XmlAttribute): string => decodeXmlEntities(ATTRIBUTE_PATTERNS[attribute].exec(tag)?.[1] ?? ""); const xmlNumber = ( tag: string, attribute: XmlAttribute, ): number | undefined => { const raw = ATTRIBUTE_PATTERNS[attribute].exec(tag)?.[1]; if (raw === undefined) return; const parsed = Number(decodeXmlEntities(raw)); return Number.isFinite(parsed) ? parsed : undefined; }; /** The errored entries in deno's JUnit report, in report order. * quick-junit writes a passed testcase as a self-closing element, so the * opening tag must not accept one: a self-closing element has no body, * and matching it would run the body on to the next errored record's * closing tag, reporting the passed test's declare site and dropping the * errored record. */ export const junitErroredTests = (junit: string): JunitErroredTest[] => { const errored: JunitErroredTest[] = []; const testcase = /[^>]*[^/])?>(?[\s\S]*?)<\/testcase>/g; for (const { groups } of junit.matchAll(testcase)) { const { tag, body } = groups as { tag: string; body: string }; const error = body.match(/[^>]*)>/); const errorTag = error?.groups?.errorTag; if (errorTag === undefined) continue; errored.push({ column: xmlNumber(tag, "col"), file: xmlAttribute(tag, "classname"), line: xmlNumber(tag, "line"), message: xmlAttribute(errorTag, "message"), name: xmlAttribute(tag, "name"), }); } return errored; }; /** Strip the escape sequences, then keep every line Deno's own failure line * does not already cover. */ const usefulStderr = (stderr: string): string => stripAnsi(stderr) .split(/\r?\n/) .filter((line) => line.trim() !== "error: Test failed") .join("\n") .trim(); /** The test files deno's JUnit report marks with error entries. Deno's TAP * reporter drops the text of an uncaught error, but its JUnit report still * names the file it aborted, so the summary can point at the entry that was * running. */ export const junitErrorFiles = (junit: string): string[] => { const names = new Set(); const suite = /]*>/g; for (const tag of junit.matchAll(suite)) { const name = tag[0].match(/\bname="([^"]+)"/)?.[1]; const errors = tag[0].match(/\berrors="([1-9]\d*)"/)?.[1]; if (name && errors) names.add(name); } return [...names]; }; const describeStatus = (status: { code: number; signal?: string | null; }): string => status.signal ? `deno exited with code ${status.code}, killed by ${status.signal}` : `deno exited with code ${status.code}`; /** The facts about a run that exited non-zero without a failed test: the * exit code and signal, the files deno's JUnit report blames, the stdout * lines the TAP results carried, and the last result shown. The block reads * the child's exit status, the JUnit-blamed files, and the filtered stderr. */ type ExitFacts = { summary: CompactTapSummary; status: { code: number; signal?: string | null }; junitErrorFiles: string[]; extra: string; }; /** The files deno's JUnit report blames for uncaught errors — the closest * marker for a worker death and the failing run's own evidence. */ const printJunitErrorFiles = (junitErrorFiles: string[]): void => { if (junitErrorFiles.length === 0) return; console.error( "\ndeno's JUnit report marks these files with uncaught errors:", ); for (const file of junitErrorFiles) console.error(` ${file}`); }; /** The stdout lines deno printed beside the TAP results — a worker or * module that aborted prints its only cause there. */ const printRetainedStdout = (droppedLines: readonly string[]): void => { if (droppedLines.length === 0) return; console.error("\nStdout lines deno printed beside the TAP results:"); for (const line of droppedLines) console.error(line); }; const printNoFailedTestFacts = ({ summary, status, junitErrorFiles, extra, }: ExitFacts): void => { console.error(describeStatus(status)); printJunitErrorFiles(junitErrorFiles); if ( extra === "" && summary.droppedLines.length === 0 && junitErrorFiles.length === 0 ) { console.error("\ndeno printed no cause for this exit on either stream."); } const last = summary.lastResultName === undefined ? "(none)" : summary.lastResultName; console.error(`The last result shown was: ${last}`); }; /** Deno's TAP output escapes backslashes and hashes in test names; the * JUnit record stores the raw name. The comparison accepts both forms. */ const unescapeTapName = (name: string): string => name.replace(/\\([\\#])/g, "$1"); /** The JUnit record for one failure, if it is the attribution the summary * may print: the failure must carry no TAP diagnostic (a diagnostic that * only lacks a location already says what happened), its name must * identify exactly one errored record, and that record must name a file. */ const junitAttribution = ( failure: CompactFailure, errored: JunitErroredTest[], ): JunitErroredTest | undefined => { if (failure.location !== undefined || failure.message !== NO_TAP_DIAGNOSTIC) { return; } const matches = errored.filter( (test) => test.name === failure.name || test.name === unescapeTapName(failure.name), ); const [match] = matches; if (matches.length !== 1 || match === undefined || match.file === "") return; return match; }; const printFailures = ( summary: CompactTapSummary, errored: JunitErroredTest[], ): void => { if (summary.failures.length === 0) return; console.error("\nFailed tests:"); for (const failure of summary.failures) { const recorded = junitAttribution(failure, errored); const location: Location | undefined = recorded ? { column: recorded.column, file: recorded.file, line: recorded.line, } : failure.location; console.error(` ${formatLocation(location)} - ${failure.name}`); if (recorded) { console.error( ` deno's runner cancelled this suite after an uncaught error. Its JUnit record says "${recorded.message}"; deno's TAP output carries no rejection text.`, ); } } }; export const printCompactSummary = ( summary: CompactTapSummary, status: { code: number; signal?: string | null }, stderrText: string, junitErrorFiles: string[] = [], junitErrored: JunitErroredTest[] = [], ): void => { const extra = usefulStderr(stderrText); // The shortfall counts results that never arrived. A dropped parent // summary did arrive — a child step failure already carries the real // diagnostic — so it is subtracted like any other reported result. const missing = summary.fileEstimate - summary.passed - summary.failed - summary.suppressedResults; // The declaration count runs both sides of the real result count (it reads // fixture strings as declarations and misses loop-generated tests), so it // cannot prove a loss on a run the test runner itself called successful. if (summary.failed === 0 && status.code === 0) { console.log(`\nPASS ${summary.passed} passed`); return; } console.error(`\nFAILED ${summary.passed} passed, ${summary.failed} failed`); // A worker or module that aborted beside counted failures prints its only // cause as plain stdout, so the retained lines carry evidence on every // failing run, not only when no test failure was counted. printRetainedStdout(summary.droppedLines); if (summary.failed === 0) { printNoFailedTestFacts({ extra, junitErrorFiles, status, summary }); } if (missing > 0 && !(summary.failed === 0 && extra !== "")) { console.error( `The declaration estimate is ${missing} above the results the output reported.`, ); } if (summary.failed > 0) printJunitErrorFiles(junitErrorFiles); printFailures(summary, junitErrored); // Always surface stderr on a failing run: an uncaught error in a test // module (which aborts that module's remaining tests) is only reported // here, even when unrelated test failures were also counted. if (extra) { console.error("\nDeno output:"); console.error(extra); } };