import { isAbsolute, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { nullIfNotFound, rethrowUnlessNotFound } from "./not-found.ts"; import { toDisplayPath } from "./project-root.ts"; import { readStream } from "./stream-lines.ts"; import { isStepFailureDiagnostic, parseTapDiagnosticBlock, type TapDiagnostic, } from "./tap-diagnostics.ts"; import { type CompactFailure, type CompactTapSummary, formatLocation, junitErroredTests, junitErrorFiles, type Location, NO_TAP_DIAGNOSTIC, printCompactSummary, } from "./tap-summary.ts"; type PendingFailure = { name: string; }; type CompactTapReporterOptions = { cwd: string; estimatedTotal?: number | undefined; hideProgress?: boolean; stdout?: (line: string) => void; stderr?: (line: string) => void; }; const PROGRESS_WIDTH = 24; /** The dying child's own lines land at the end of its output, so the last * ones are the cause; the cap keeps a chatty run from flooding the summary. */ const DROPPED_LINE_CAP = 50; const TEST_RESULT_RE = /^\s*(not\s+)?ok\s+\d+(?:\s+-\s+(.*))?$/; const PLAN_RE = /^\s*(\d+)\.\.(\d+)(?:\s+#.*)?$/; const stripTapDirective = (name: string): string => name.replace(/\s+#\s+(?:SKIP|TODO)\b.*$/i, "").trim(); const locationFromDiagnostic = ( cwd: string, diagnostic: TapDiagnostic, ): Location | undefined => { if (!diagnostic.at?.file) return; return { column: diagnostic.at.column, file: toDisplayPath(cwd, diagnostic.at.file), line: diagnostic.at.line, }; }; const locationFromStack = ( cwd: string, message: string, ): Location | undefined => { const matches = message.match(/file:\/\/[^\s)]+:\d+:\d+/g) ?? []; for (const match of matches) { // The pattern ends in :line:column, so both colons are always there. const columnSplit = match.lastIndexOf(":"); const lineSplit = match.lastIndexOf(":", columnSplit - 1); const url = match.slice(0, lineSplit); let file: string; try { file = fileURLToPath(url); } catch { continue; } const rel = relative(cwd, file); if (rel.startsWith("..")) continue; return { column: Number(match.slice(columnSplit + 1)), file: rel || ".", line: Number(match.slice(lineSplit + 1, columnSplit)), }; } return; }; export class CompactTapReporter { #cwd: string; #estimatedTotal: number; #fileEstimate: number; #hideProgress: boolean; #stdout: (line: string) => void; #stderr: (line: string) => void; #passed = 0; #failed = 0; #skipped = 0; #pendingFailure?: PendingFailure | undefined; #diagnosticLines?: string[] | undefined; #failures: CompactFailure[] = []; #sawTap = false; #lastResultName?: string | undefined; #consumedResults = 0; #dropped: string[] = []; constructor(options: CompactTapReporterOptions) { this.#cwd = options.cwd; this.#estimatedTotal = options.estimatedTotal ?? 0; this.#fileEstimate = this.#estimatedTotal; this.#hideProgress = options.hideProgress ?? false; this.#stdout = options.stdout ?? console.log; this.#stderr = options.stderr ?? console.error; } consumeLine(line: string): void { const trimmed = line.trim(); if (this.#diagnosticLines) { if (trimmed === "...") { this.#consumeDiagnosticBlock(this.#diagnosticLines); return; } this.#diagnosticLines.push(line); return; } if (!trimmed) return; if (trimmed === "TAP version 14") { this.#sawTap = true; return; } const plan = trimmed.match(PLAN_RE); if (plan) { this.#sawTap = true; this.#growEstimatedTotal(Number(plan[2])); return; } if (this.#readDiagnosticBlockStart(trimmed)) return; // A TAP comment is stream structure — file names, subtest echoes — not // the child's own voice; keeping it would bury the real cause. if (trimmed.startsWith("#")) return; const result = line.match(TEST_RESULT_RE); if (!result) { this.#drop(line); return; } this.#consumeResult(result); } /** A `---` marker under a failed result opens its diagnostic block, which * the diagnostic collector ends at `...`. Outside a failure both markers * are stream structure and nothing reads them. */ #readDiagnosticBlockStart(trimmed: string): boolean { if (trimmed !== "---" && trimmed !== "...") return false; if (trimmed === "---" && this.#pendingFailure) this.#diagnosticLines = []; return true; } #consumeResult(result: RegExpMatchArray): void { this.#sawTap = true; this.#consumedResults++; this.#flushPendingFailure(); const failed = Boolean(result[1]); const rawName = result[2] ?? ""; const name = stripTapDirective(result[2] ?? "(unnamed test)"); this.#lastResultName = name; if (failed) { this.#pendingFailure = { name }; return; } if (/#\s*SKIP\b/i.test(rawName)) this.#skipped++; this.#passed++; this.#stdout(this.#formatResultLine("ok ", name)); } /** Keep a line the TAP grammar does not define for the summary: it is the * child's own voice — an error, a panic, or a leak report. */ #drop(line: string): void { this.#dropped.push(line); if (this.#dropped.length > DROPPED_LINE_CAP) this.#dropped.shift(); } finish(): CompactTapSummary { this.#flushPendingFailure(); return { droppedLines: [...this.#dropped], failed: this.#failed, failures: [...this.#failures], fileEstimate: this.#fileEstimate, lastResultName: this.#lastResultName, passed: this.#passed, sawTap: this.#sawTap, skipped: this.#skipped, suppressedResults: this.#consumedResults - this.#passed - this.#failed, }; } #consumeDiagnosticBlock(lines: string[]): void { const pending = this.#pendingFailure; this.#pendingFailure = undefined; this.#diagnosticLines = undefined; const diagnostic = parseTapDiagnosticBlock(lines); if (!pending || isStepFailureDiagnostic(diagnostic)) return; this.#recordFailure(pending.name, diagnostic); } #flushPendingFailure(): void { const pending = this.#pendingFailure; if (!pending) return; if (this.#diagnosticLines) { this.#consumeDiagnosticBlock(this.#diagnosticLines); return; } this.#pendingFailure = undefined; this.#recordFailure(pending.name, { message: NO_TAP_DIAGNOSTIC, }); } #recordFailure(name: string, diagnostic: TapDiagnostic): void { const message = diagnostic.message?.trimEnd() || "No failure message was emitted."; const location = locationFromStack(this.#cwd, message) ?? locationFromDiagnostic(this.#cwd, diagnostic); const failure: CompactFailure = { location, message, name }; this.#failed++; this.#failures.push(failure); this.#stderr(this.#formatResultLine("fail", name)); this.#stderr(` at ${formatLocation(location)}`); for (const detailLine of message.split("\n")) { this.#stderr(` ${detailLine}`); } } #formatResultLine(prefix: string, name: string): string { const progress = this.#progress(); return progress ? `${prefix} ${progress} ${name}` : `${prefix} ${name}`; } #growEstimatedTotal(total: number): void { if (total <= 0) return; this.#estimatedTotal = Math.max(this.#estimatedTotal, total); } /** A bar and a count; the total grows whenever the run outruns it. */ #progress(): string { if (this.#hideProgress) return ""; // A result line is what asks for progress, so at least one test is done // and growing the total by it always leaves a total of one or more. const done = this.#passed + this.#failed; this.#growEstimatedTotal(done); const total = this.#estimatedTotal; const shownDone = Math.min(done, total); const fill = Math.min( PROGRESS_WIDTH, Math.max(1, Math.round((shownDone / total) * PROGRESS_WIDTH)), ); const bar = `${"#".repeat(fill)}${"-".repeat(PROGRESS_WIDTH - fill)}`; return `[${bar}] ${String(done).padStart( String(total).length, " ", )}/${total}`; } } /** The report path as the harness process sees it: the child writes the * report relative to its own working directory, so reads and removals must * resolve the path there, not in the parent's. */ const reportFileFor = (options: { cwd: string; junitPath?: string; }): string | undefined => options.junitPath === undefined ? undefined : isAbsolute(options.junitPath) ? options.junitPath : join(options.cwd, options.junitPath); export const runCompactDenoTest = async ( args: string[], options: { cwd: string; env: Record; estimatedTotal?: number; junitPath?: string; /** The `--filter` value the run selects by; set when the caller passed * one, so a run that matched nothing can fail loudly. */ nameFilter?: string; }, ): Promise => { console.log("Running tests..."); // A report left by a killed prior run must not name this run's dead files: // the child rewrites the report only when it completes. const reportFile = reportFileFor(options); if (reportFile !== undefined) { await Deno.remove(reportFile).catch(rethrowUnlessNotFound); } // A filtered run's child records the suites it registered beside its marks, // so the zero-execution decision can tell BDD suite containers from plain // tests whose own body ran. The file is this runner's; the child appends. const announcePath = options.nameFilter === undefined ? undefined : await Deno.makeTempFile(); try { const command = new Deno.Command(Deno.execPath(), { args, cwd: options.cwd, env: announcePath === undefined ? options.env : { ...options.env, TICKETS_TEST_FILTER_MARKS: announcePath }, stderr: "piped", stdin: "inherit", stdout: "piped", }); const child = command.spawn(); const reporter = new CompactTapReporter({ cwd: options.cwd, estimatedTotal: options.estimatedTotal, hideProgress: Boolean(options.env.CI || options.env.GITHUB_ACTIONS), }); const stdoutTask = readStream(child.stdout, (line) => reporter.consumeLine(line), ); const stderrTask = readStream(child.stderr); const status = await child.status; await stdoutTask; const stderrText = await stderrTask; const summary = reporter.finish(); const junit = reportFile === undefined ? "" : ((await nullIfNotFound(Deno.readTextFile(reportFile))) ?? ""); printCompactSummary( summary, { code: status.code, signal: status.signal }, stderrText, junitErrorFiles(junit), junitErroredTests(junit), ); // A filtered run that executed nothing did not select any test. A failed // run has its own cause to report, so only a clean exit names the filter. // On a clean exit every suite the child announced reported exactly one // pass that ran no test body, so the announcement count leaves the // execution count. const announcedSuites = announcePath === undefined ? 0 : ((await nullIfNotFound(Deno.readTextFile(announcePath))) ?? "") .split("\n") .filter((line) => line.startsWith("kept-suite ")).length; if ( options.nameFilter !== undefined && status.code === 0 && summary.passed - summary.skipped - announcedSuites + summary.failed === 0 ) { console.error(`No test matched --filter ${options.nameFilter}.`); return 1; } return status.code; } finally { if (announcePath !== undefined) { await Deno.remove(announcePath).catch(rethrowUnlessNotFound); } } };