A suite run in which no test passed exits non-zero and names its skips (issue 693)
mesh/merge-gate head 8635560993aa merged into trunk f9be71b9ad44 — pass: the change touches no module of the mesh's graph
mesh/repo-check head 8635560993aa merged into trunk f9be71b9ad44 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery-group group fix/693-a-run-that-proved-nothing-is-not-passed ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request

The runner exits 0 when every test skipped, so a bed that stopped before its
subject and skipped its only test as NOT REACHED was recorded as passed, "0
passed, 0 failed". The suite now collects each skip and its reason through a
reporter of its own, prints NOT PASSED and a skipped line per skip after its
recorded line, keeps them in the receipt, and exits 1 when no test passed or a
test was not reached. A skip for a declared reason beside passes stays passed.
This commit is contained in:
jochen
2026-10-14 03:14:05 +02:00
parent f9be71b9ad
commit 8635560993
5 changed files with 345 additions and 21 deletions
+19 -3
View File
@@ -24,6 +24,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import type { Outcome, Skip } from "./outcomes.ts";
import { repositories } from "./repos.ts";
/** What a run was taken against, per repository. */
@@ -38,6 +39,11 @@ export interface Receipt {
against: Against;
/** The test files this run was pointed at. See {@link endToEnd}. */
ran: string[];
/** How many tests skipped, and each with its reason (novox/hq issue 693). Absent on older receipts. */
skipped?: number;
skips?: Skip[];
/** Why the run did not pass although nothing failed — no test passed, or one was not reached. */
notPassed?: string;
}
/**
@@ -104,6 +110,7 @@ export function record(
ran: string[],
env = process.env,
builtAgainst?: Against,
outcome?: Outcome,
): Receipt {
const receipt: Receipt = {
at: new Date().toISOString(),
@@ -115,6 +122,8 @@ export function record(
// never in the binaries. A verdict is only worth something attributed to one exact state.
against: builtAgainst ?? whatWasTested(env),
ran,
...(outcome ? { skipped: outcome.skipped, skips: outcome.skips } : {}),
...(outcome?.notPassed ? { notPassed: outcome.notPassed } : {}),
};
const path = receiptPath();
mkdirSync(dirname(path), { recursive: true });
@@ -166,9 +175,14 @@ export function judge(
const days = (now.getTime() - Date.parse(receipt.at)) / 86_400_000;
const lines: string[] = [];
// A run that proved nothing did not pass, though nothing failed (novox/hq issue 693): no test passed,
// or one skipped as not reached. A receipt from before the field is judged by its count alone.
const notPassed = receipt.failed > 0 ? "" : receipt.notPassed ?? (receipt.passed === 0 ? "no test passed" : "");
const outcome = receipt.failed > 0
? `last ran ${ago(days)} and ${receipt.failed} test(s) failed`
: `last passed ${ago(days)}, ${receipt.passed} test(s)`;
: notPassed
? `last ran ${ago(days)} and did not pass: ${notPassed}`
: `last passed ${ago(days)}, ${receipt.passed} test(s)`;
lines.push(`the end-to-end suite ${outcome}`);
let moved = false;
@@ -192,7 +206,9 @@ export function judge(
lines.push(` That run did not include ${endToEnd}, so it raised no machines.`);
}
if (receipt.failed > 0) {
for (const s of receipt.skips ?? []) lines.push(` skipped: ${s.test} — ${s.reason || "no reason given"}`);
if (receipt.failed > 0 || notPassed) {
lines.push(" Nothing has been proven end to end since.");
} else if (moved) {
lines.push(" What it proved was proven about code that has since changed.");
@@ -201,7 +217,7 @@ export function judge(
}
return {
current: endToEndRan && receipt.failed === 0 && !moved && days <= staleAfterDays,
current: endToEndRan && receipt.failed === 0 && !notPassed && !moved && days <= staleAfterDays,
lines,
};
}
+113
View File
@@ -0,0 +1,113 @@
/**
* What a suite's tests did, beyond the runner's totals: which were skipped and why, and whether the run
* proved anything (novox/hq issue 693).
*
* **A run in which no test passed has proven nothing, whatever its exit code.** The runner exits 0 when
* every test skipped, and the lab recorded such a run as passed: the genesis bed stopped before its
* subject, skipped its only test as "NOT REACHED", and the run read "0 passed, 0 failed", passed.
* Failure must be loud, and a green run that verified nothing is the quietest failure there is.
*
* The default export is a reporter for node's test runner (`--test-reporter`): it writes one JSON line per
* skipped test, with its file, its name and the reason it gave, word for word. The reasons come from the
* runner's events rather than from the spec reporter's text, because a reason runs over several lines and a
* later test's output lands between them.
*/
import { readFileSync } from "node:fs";
/** One skipped test and the reason it gave ("" when it gave none). */
export interface Skip {
file: string;
test: string;
reason: string;
}
/** What a receipt keeps beside the totals: the skips, and why the run did not pass when it did not. */
export interface Outcome {
skipped: number;
skips: Skip[];
notPassed?: string;
}
/**
* The words a bed's test skips with when the run stopped before its subject: the subject was not reached,
* so nothing about it was proven (the beds say "<what> NOT REACHED: <where it stopped>").
*/
export const NOT_REACHED = "NOT REACHED";
/**
* Whether a run passed, and if not, why — the design limit of issue 693, in one place:
*
* - a test failed: not passed (the runner already says so);
* - **no test passed**: not passed, whatever was skipped — a run that skipped everything, or ran
* nothing, verified nothing;
* - **a test skipped as NOT REACHED**: not passed, even beside tests that passed — its subject was not
* reached, which is the bed failing to get where it was sent, not a skip anyone declared expected;
* - otherwise passed, its other skips listed with their reasons: a test skipped for a reason its file
* declares (no hypervisor, an optional bed not asked for) beside tests that passed is an expected
* skip, and the run still proved what passed.
*
* Counts the runner did not report (null) decide nothing here: that is reportOn's "no receipt" case.
*/
export function judgeRun(
counts: { passed: number | null; failed: number | null },
skips: Skip[],
): { passed: boolean; why: string } {
if (counts.failed !== null && counts.failed > 0) return { passed: false, why: `${counts.failed} test(s) failed` };
const unreached = skips.filter((s) => s.reason.includes(NOT_REACHED));
if (counts.passed === 0) {
return {
passed: false,
why: skips.length > 0
? `no test passed: ${skips.length} skipped${unreached.length > 0 ? `, ${unreached.length} of them as ${NOT_REACHED}` : ""}`
: "no test passed: none ran",
};
}
if (unreached.length > 0) {
return { passed: false, why: `${unreached.length} test(s) skipped as ${NOT_REACHED}: their subject was not reached` };
}
return { passed: true, why: "" };
}
/** A reason on one line, for a log line and a record: its whitespace folded, and at most 500 characters. */
export function oneLine(reason: string): string {
const flat = reason.replace(/\s+/g, " ").trim();
return flat.length > 500 ? `${flat.slice(0, 499)}…` : flat;
}
/** The skips the reporter wrote to a file; none when it wrote nothing or the file cannot be read. */
export function readSkips(path: string): Skip[] {
let text: string;
try {
text = readFileSync(path, "utf8");
} catch {
return [];
}
const skips: Skip[] = [];
for (const line of text.split("\n")) {
if (!line.trim()) continue;
try {
const s = JSON.parse(line) as Partial<Skip>;
skips.push({ file: String(s.file ?? ""), test: String(s.test ?? ""), reason: String(s.reason ?? "") });
} catch {
// A torn last line, when the runner was killed mid-write: the lines before it still stand.
}
}
return skips;
}
interface Event {
type: string;
data: { name?: string; file?: string; skip?: string | boolean; details?: { type?: string } };
}
/** The reporter: one JSON line per skipped test (not per skipped suite: its tests are reported each). */
export default async function* skipsReporter(source: AsyncIterable<Event>): AsyncGenerator<string> {
for await (const event of source) {
if (event.type !== "test:pass" && event.type !== "test:fail") continue;
const { skip, details } = event.data;
if (skip === undefined || skip === false || details?.type === "suite") continue;
const said: Skip = { file: event.data.file ?? "", test: event.data.name ?? "", reason: typeof skip === "string" ? skip : "" };
yield `${JSON.stringify(said)}\n`;
}
}
+62 -12
View File
@@ -10,11 +10,16 @@
*/
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { isAbsolute, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { endToEnd, record, whatWasTested } from "./lastrun.ts";
import { judgeRun, oneLine, readSkips, type Outcome, type Skip } from "./outcomes.ts";
import { rebuild } from "./rebuild.ts";
/** counted is what the runner said, or nulls when it said nothing recognisable. */
export function counted(output: string): { passed: number | null; failed: number | null } {
export function counted(output: string): { passed: number | null; failed: number | null; skipped: number | null } {
// The runner's own summary lines, each on a line of its own. Anchored, so a test *named*
// "pass 3" cannot be mistaken for the total — which is not a hypothetical worry in a suite whose
// tests are named in sentences.
@@ -30,6 +35,7 @@ export function counted(output: string): { passed: number | null; failed: number
return {
passed: total(/^\s*(?:\u2139|#)\s*pass\s+(\d+)\s*$/m),
failed: total(/^\s*(?:\u2139|#)\s*fail\s+(\d+)\s*$/m),
skipped: total(/^\s*(?:\u2139|#)\s*skipped\s+(\d+)\s*$/m),
};
}
@@ -57,16 +63,38 @@ export async function runSuite(args: string[]): Promise<number> {
// `test/integration/canary.test.ts` is still there and still runs when it is named; it is no
// longer raised on the way to everything else.
const { code, seen } = await runFiles(files);
console.log("\n" + reportOn(counted(seen), (p, f) => record(p, f, files, process.env, against)));
return code;
const { code, seen, skips } = await runFiles(files);
const counts = counted(seen);
console.log("\n" + reportOn(counts, skips, (p, f, outcome) => record(p, f, files, process.env, against, outcome)));
return exitOf(code, counts, skips);
}
/** Run some test files, passing their output through as it arrives. */
async function runFiles(files: string[]): Promise<{ code: number; seen: string }> {
/**
* The suite's exit: the runner's, unless it says 0 for a run that did not pass (novox/hq issue 693). The
* runner exits 0 when every test skipped; the lab takes this exit as the run's verdict, so a run that
* proved nothing must not end in 0.
*/
export function exitOf(code: number, counts: { passed: number | null; failed: number | null }, skips: Skip[]): number {
if (code !== 0) return code;
if (counts.passed === null || counts.failed === null) return code;
return judgeRun(counts, skips).passed ? 0 : 1;
}
/** The reporter that writes each skipped test and its reason (outcomes.ts), by its path. */
const skipsReporter = fileURLToPath(new URL("./outcomes.ts", import.meta.url));
/**
* Run some test files, passing their output through as it arrives, and collect what was skipped and why:
* the spec reporter to stdout as before, and the skips reporter beside it into a file of the run's own.
*/
async function runFiles(files: string[]): Promise<{ code: number; seen: string; skips: Skip[] }> {
const scratch = mkdtempSync(join(tmpdir(), "mesh-lab-suite-"));
const skipsFile = join(scratch, "skips.jsonl");
const running = spawn(
process.execPath,
["--test", "--test-concurrency=1", "--experimental-strip-types",
"--test-reporter=spec", "--test-reporter-destination=stdout",
`--test-reporter=${skipsReporter}`, `--test-reporter-destination=${skipsFile}`,
...files],
{ stdio: ["inherit", "pipe", "inherit"] },
);
@@ -81,7 +109,14 @@ async function runFiles(files: string[]): Promise<{ code: number; seen: string }
const code: number = await new Promise((resolve) => {
running.on("close", (c) => resolve(c ?? 1));
});
return { code, seen };
// Named as the files were given, relative to where the suite runs, rather than as the runner's absolute paths.
const named = (file: string) => {
const rel = isAbsolute(file) ? relative(process.cwd(), file) : file;
return rel.startsWith("..") ? file : rel;
};
const skips = readSkips(skipsFile).map((s) => ({ ...s, file: named(s.file) }));
rmSync(scratch, { recursive: true, force: true });
return { code, seen, skips };
}
/**
@@ -92,16 +127,31 @@ async function runFiles(files: string[]): Promise<{ code: number; seen: string }
* could check it — which is 04-ISSUES/005 in miniature, inside the fix for it.
*/
export function reportOn(
counts: { passed: number | null; failed: number | null },
write: (passed: number, failed: number) => { against: Record<string, string>; ran: string[] },
counts: { passed: number | null; failed: number | null; skipped?: number | null },
skips: Skip[],
write: (passed: number, failed: number, outcome: Outcome) => { against: Record<string, string>; ran: string[] },
): string {
if (counts.passed === null || counts.failed === null) {
// A run whose result could not be read is a run nobody can say anything about. Writing
// "0 failed" because nothing said otherwise is how a green record comes to mean nothing.
return "could not read what the runner reported; no receipt written";
}
const receipt = write(counts.passed, counts.failed);
// **A skip is said, with its reason, and a run that proved nothing says NOT PASSED** (novox/hq issue
// 693): "0 passed, 0 failed" read as a clean run. The lab module reads these lines into its record.
const skipped = counts.skipped ?? skips.length;
const judged = judgeRun({ passed: counts.passed, failed: counts.failed }, skips);
const outcome: Outcome = {
skipped,
skips: skips.map((s) => ({ ...s, reason: oneLine(s.reason) })),
...(judged.passed ? {} : { notPassed: judged.why }),
};
const receipt = write(counts.passed, counts.failed, outcome);
const against = Object.entries(receipt.against).map(([n, c]) => `${n} ${c}`).join(", ");
return `recorded: ${receipt.ran.join(", ")} — ${counts.passed} passed, ` +
`${counts.failed} failed, against ${against || "nothing in git"}`;
const lines = [`recorded: ${receipt.ran.join(", ")} — ${counts.passed} passed, ` +
`${counts.failed} failed, ${skipped} skipped, against ${against || "nothing in git"}`];
if (!judged.passed) lines.push(`NOT PASSED: ${judged.why}`);
// As the runner's own spec line has it, "<test> (<file>) # <reason>": a test's name may hold any
// punctuation, so the file in brackets is what the lab module reads the line apart by.
for (const s of outcome.skips) lines.push(`skipped: ${s.test} (${s.file}) # ${s.reason || "no reason given"}`);
return lines.join("\n");
}