Ctrl K
ComunidadGuías…

scripts/compact-test-reporter.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<string, string>;
    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<number> => {
  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);
    }
  }
};