Public: anyone can read this; the crew can change it.

A failed check says only that it exited 1; the failing test is buried in a 240-second log #315

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/278-a-failed-check-says-only-that-it-exited → main 1 commit fork

  • Merged. scout/278-a-failed-check-says-only-that-it-exited was merged into main as bdad743.

Review

Sign in to review this change.
5 files changed +151 −18
src/checks/service.ts +14 −9
@@ -36,7 +36,7 @@ import { appendRunLog } from "../runs/log.ts";
3636 import { exportTree, readFileAt } from "../apps/checkout.ts";
3737 import { parseCheck } from "../apps/manifest.ts";
3838 import { childEnv, runStep } from "../apps/process.ts";
39 import { parseBunTestFailure } from "./bun-test.ts";
39 import { summariseCheckFailure } from "./summary.ts";
4040 import type { CheckDetail } from "../core/runs.ts";
4141
4242 const log = createLog("checks");
@@ -347,24 +347,29 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
347347 }
348348 // The check step's own lines are kept beside what `write` already
349349 // logs, so a failure can be summarised once here — never by reading
350 // the log back, on this run or any later render of it.
350 // the log back, on this run or any later render of it. `runStep`
351 // echoes the command as its first line before any output of the
352 // command's own; that echo is not output to summarise from.
351353 const checkLines: string[] = [];
354 let sawEcho = false;
352355 const ran = await runStep("check", manifest.run, {
353356 cwd: dir,
354357 env,
355358 log: (line) => {
356 checkLines.push(line);
359 if (sawEcho) checkLines.push(line);
360 sawEcho = true;
357361 write(line);
358362 },
359363 signal,
360364 timeoutMs: manifest.timeout * 1000,
361365 });
362 if (!ran.ok)
363 return {
364 ok: false,
365 error: ran.error,
366 detail: parseBunTestFailure(checkLines),
367 };
366 if (!ran.ok) {
367 const { summary, detail } = summariseCheckFailure(
368 checkLines,
369 ran.error,
370 );
371 return { ok: false, error: summary, detail };
372 }
368373 return { ok: true };
369374 } finally {
370375 if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
src/checks/summary.ts added +55
@@ -0,0 +1,55 @@
1 /**
2 * What a failed check's summary says first: bun test's own name and counts
3 * when the check step's output carried them, the output's own last word
4 * when it did not, and the bare exit code only when the output said
5 * nothing at all. Plain data in (the check step's own lines, and the
6 * error `runStep` already gives up), plain data out — never a re-read of
7 * the run's log, and never a guess at *why* a test failed.
8 */
9
10 import type { CheckDetail } from "../core/runs.ts";
11 import { parseBunTestFailure } from "./bun-test.ts";
12
13 /** A summary is one line on a card; a name from a subprocess is bounded to fit it. */
14 const SUMMARY_MAX = 200;
15
16 function truncate(text: string): string {
17 return text.length > SUMMARY_MAX
18 ? `${text.slice(0, SUMMARY_MAX - 1)}…`
19 : text;
20 }
21
22 export interface CheckFailure {
23 /** The one line a card shows, already bounded. */
24 readonly summary: string;
25 /** `bun test`'s own counts and names, when the output was its shape. */
26 readonly detail: CheckDetail | undefined;
27 }
28
29 /**
30 * `lines` is the check step's own output, nothing from `install` mixed in
31 * (`checks/service.ts` keeps them apart already). `fallback` is what
32 * `runStep` says when a step exits non-zero — "check exited with code 1",
33 * sometimes with a reason it found on its own — used only when this
34 * output named nothing better, so a truthful "no output at all" is never
35 * dressed up as more than it is.
36 */
37 export function summariseCheckFailure(
38 lines: readonly string[],
39 fallback: string,
40 ): CheckFailure {
41 const detail = parseBunTestFailure(lines);
42 if (detail !== undefined) {
43 const total = detail.passed + detail.failed;
44 const name = detail.names[0] ?? "";
45 return {
46 summary: truncate(`${detail.failed} of ${total} tests — "${name}"`),
47 detail,
48 };
49 }
50 for (let i = lines.length - 1; i >= 0; i--) {
51 const line = lines[i]!.trim();
52 if (line !== "") return { summary: truncate(line), detail: undefined };
53 }
54 return { summary: fallback, detail: undefined };
55 }
src/crew/dispatch.ts +13 −5
@@ -546,8 +546,13 @@ function parkedReason(
546546 head: string,
547547 ): string {
548548 const what = kind === "deploy" ? "the preview's deploy" : "the check";
549 if (judged.cause !== "environment")
550 return `${what} failed and the decider saw no point in another try`;
549 if (judged.cause !== "environment") {
550 const named =
551 judged.failingTest === undefined
552 ? ""
553 : ` ("${judged.failingTest}")`;
554 return `${what} failed${named} and the decider saw no point in another try`;
555 }
551556 return retriedChecks.has(head)
552557 ? `${what} failed in the place it ran, not in the change, and failed the same way when it was run again`
553558 : `${what} failed in the place it ran, not in the change, and it could not be run again`;
@@ -567,6 +572,8 @@ export interface CheckJudgement {
567572 readonly retry: "again" | "stop";
568573 /** Whose fault the failure is, from the tail of the check's log. */
569574 readonly cause: "change" | "environment" | "base";
575 /** The failing test's own name, when the run's output named one. */
576 readonly failingTest: string | undefined;
570577 }
571578
572579 const RETRY_OPTIONS = ["again", "stop"];
@@ -589,9 +596,10 @@ export async function judgeFailedCheck(
589596 runId: string,
590597 since: string,
591598 ): Promise<CheckJudgement> {
592 if (app.config.decider === undefined)
593 return { retry: "again", cause: "change" };
594599 const run = getRun(app.db, runId);
600 const failingTest = run?.checkDetail?.names[0];
601 if (app.config.decider === undefined)
602 return { retry: "again", cause: "change", failingTest };
595603 const tail = readRunLog(app.config.dataDir, runId, 8 * 1024).lines.slice(-25);
596604 const decided = await decideAndRecord(
597605 app,
@@ -655,7 +663,7 @@ export async function judgeFailedCheck(
655663 p: decided.answers.retry.p.toFixed(2),
656664 cause,
657665 });
658 return { retry, cause };
666 return { retry, cause, failingTest };
659667 }
660668
661669 /** When a person last said anything on the conversation, or its start. */
test/checks.test.ts +57
@@ -12,6 +12,7 @@ import { createApp, type App } from "../src/app.ts";
1212 import { openPull } from "../src/actions/pulls.ts";
1313 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
1414 import { parseBunTestFailure } from "../src/checks/bun-test.ts";
15 import { summariseCheckFailure } from "../src/checks/summary.ts";
1516 import {
1617 pickNext,
1718 startChecks,
@@ -75,6 +76,59 @@ describe("parseBunTestFailure: a convenience over one known runner, not a genera
7576 });
7677 });
7778
79 describe("summariseCheckFailure: what a failed check's summary says first", () => {
80 test("real bun test output with one failure yields the name and the counts", () => {
81 const lines = [
82 "bun test v1.3.14 (0d9b296a)",
83 "",
84 "check.test.ts:",
85 "1 tests failed:",
86 '(fail) the forge\'s own restart never reads as this deploy\'s failure (issue #255) > an install still in flight when the runtime stops finishes cancelled, not failed [30000.49ms]',
87 " ^ this test timed out after 30000ms.",
88 "",
89 " 409 pass",
90 " 1 fail",
91 "Ran 410 tests across 1 file. [30.05s]",
92 ];
93 expect(summariseCheckFailure(lines, "check exited with code 1")).toEqual({
94 summary:
95 '1 of 410 tests — "the forge\'s own restart never reads as this deploy\'s failure (issue #255) > an install still in flight when the runtime stops finishes cancelled, not failed"',
96 detail: {
97 passed: 409,
98 failed: 1,
99 names: [
100 "the forge's own restart never reads as this deploy's failure (issue #255) > an install still in flight when the runtime stops finishes cancelled, not failed",
101 ],
102 },
103 });
104 });
105
106 test("output with no bun test shape yields the last non-empty line, not the bare exit code", () => {
107 const lines = ["src/foo.ts(10,5): error TS2345: not assignable", ""];
108 expect(
109 summariseCheckFailure(lines, "check exited with code 2"),
110 ).toEqual({
111 summary: "src/foo.ts(10,5): error TS2345: not assignable",
112 detail: undefined,
113 });
114 });
115
116 test("no output at all falls back to the bare exit code — nothing was there to name", () => {
117 expect(summariseCheckFailure([], "check exited with code 1")).toEqual({
118 summary: "check exited with code 1",
119 detail: undefined,
120 });
121 });
122
123 test("a very long name is bounded to one line", () => {
124 const long = "x".repeat(500);
125 const lines = [`(fail) ${long} [1.00ms]`, " 0 pass", " 1 fail"];
126 const { summary } = summariseCheckFailure(lines, "check exited with code 1");
127 expect(summary.length).toBeLessThanOrEqual(200);
128 expect(summary.endsWith("…")).toBe(true);
129 });
130 });
131
78132 describe("checks", () => {
79133 let dataDir: string;
80134 let app: App;
@@ -245,6 +299,9 @@ describe("checks", () => {
245299 failed: 2,
246300 names: ["first failing test", "second failing test"],
247301 });
302 expect(failed.summary).toBe(
303 'check failed: 2 of 3 tests — "first failing test"',
304 );
248305 }, 30_000);
249306
250307 test("a fork's pull is checked at the base's snapshot, and follows the fork's pushes", async () => {
test/ledger.test.ts +12 −4
@@ -389,7 +389,12 @@ describe("the decision ledger", () => {
389389 claimRun(app.db, run.id);
390390 finishRun(app.db, run.id, {
391391 state: "failed",
392 summary: "expect(received).toBe(expected): 1 of 274",
392 summary: 'check failed: 1 of 274 tests — "renders the broken widget"',
393 checkDetail: {
394 passed: 273,
395 failed: 1,
396 names: ["renders the broken widget"],
397 },
393398 });
394399 script["retry"] = "stop";
395400 script["cause"] = "change";
@@ -404,10 +409,13 @@ describe("the decision ledger", () => {
404409 }),
405410 ).toBe("parked");
406411 expect(getPull(app.db, repo.id, pull.number)?.state).toBe("closed");
412 const comments = listComments(app.db, pull.id).map((c) => c.body);
407413 expect(
408 listComments(app.db, pull.id).some((c) =>
409 c.body.includes("saw no point in another try"),
410 ),
414 comments.some((b) => b.includes("saw no point in another try")),
415 ).toBe(true);
416 // The red test's own name, not just "the check failed".
417 expect(
418 comments.some((b) => b.includes('"renders the broken widget"')),
411419 ).toBe(true);
412420 } finally {
413421 delete script["cause"];

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs