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

A check's run page shows install, typecheck and tests as step rows from the first paint #2564

Merged opened this

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

Merged

scout/forge:scout/2420-a-check-s-run-page-shows-install-typeche → main 3 commits fork 5 threads · 5 unresolved

  • Merged. scout/2420-a-check-s-run-page-shows-install-typeche was merged into main as c7155aa.

Review

Sign in to review this change.
9 files changed +432 −3
README.md +2

Diff not shown — a line 2,315 characters wide (limit 2,000). +2

View file
src/checks/progress.ts +18
@@ -8,6 +8,7 @@
88 */
99
1010 import type { CheckSummary } from "../core/runs.ts";
11 import { checkSteps, stepsWords } from "./summary.ts";
1112 import { readRunLog } from "../runs/log.ts";
1213
1314 export type CheckProgress = {
@@ -90,3 +91,20 @@ export function runningProgress(dataDir: string, check: CheckSummary): string {
9091 return "";
9192 return progressWords(parseShardProgress(readRunLog(dataDir, run.id).lines));
9293 }
94
95 /**
96 * The run page's three step rows as one line, for the terminal: the same
97 * `checkSteps` over the same log, and nothing at all when the run is not a
98 * check or when the viewer may not read its log (`mayReadRunLog`), as the
99 * page withholds the rows then.
100 */
101 export function stepsLine(
102 dataDir: string,
103 check: CheckSummary,
104 mayReadLog: boolean,
105 ): string {
106 const run = check.run;
107 if (!mayReadLog || run === null || run.kind !== "check") return "";
108 const { lines, truncated } = readRunLog(dataDir, run.id);
109 return stepsWords(checkSteps({ ...run, state: check.state === "none" ? run.state : check.state }, lines, truncated));
110 }
src/checks/summary.ts +88
@@ -111,3 +111,91 @@ export function checkWords(
111111 }
112112 return undefined;
113113 }
114
115 export type StepName = "install" | "typecheck" | "tests";
116 export type StepState =
117 | "pending"
118 | "running"
119 | "ok"
120 | "failed"
121 | "stopped"
122 | "unknown";
123
124 export interface CheckStep {
125 readonly name: StepName;
126 readonly state: StepState;
127 }
128
129 /** `$ <command>` is what `runStep` and the job runner echo before each command. */
130 const ECHO = /^\$ /;
131 /** The package manager's own install, never a script that merely has the word in its name. */
outdated

\binstall\b also matches a command like $ bun scripts/install-hooks, because the hyphen counts as a word boundary. Anchor it to the package-manager install command.

132 const INSTALL_ECHO = /^\$ (?:bun|npm|pnpm|yarn)(?: -\S+)* (?:install|ci)\b/;
133 /** The shard runner's banner and shard headers: the tests have begun. */
134 const TESTS_BEGUN = /^test-shards: |^── shard /;
135
136 /**
137 * The three rows of a check's run page — install, typecheck, tests — read
138 * from the run's state and the lines the check wrote, never from a new
139 * record. The log carries no step boundaries of its own, so each is
140 * inferred from the furthest sign of progress: an echoed command, then the
141 * shard runner's banner (typecheck comes before it in the check command).
142 * A repository without an install command has its install row `ok` once
143 * the check begins. A job-placed check echoes every command up front, so
144 * its install is only done once output follows the echoes. Always three
145 * rows, so the page never shifts when a state changes. `lines` may be only
146 * the tail of a long log (`truncated`): the echoes are then gone, so unless
147 * the tests are seen to have begun, or the run passed, the rows are
148 * `unknown` rather than read wrong.
149 */
150 export function checkSteps(
outdated

Progress inferred from the tail only; if the install echo was truncated away, hasInstall is false and the row states shift. Needs the full log or an explicit unknown state.

151 run: Pick<Run, "state" | "placement">,
152 lines: readonly string[],
153 truncated = false,
154 ): readonly CheckStep[] {
155 const echoes = lines.flatMap((line, i) => (ECHO.test(line) ? [i] : []));
156 const hasInstall = lines.some((line) => INSTALL_ECHO.test(line));
157 const needed = hasInstall ? 2 : 1;
158 const lastEcho = echoes.at(-1);
159 const output = lastEcho !== undefined && lastEcho < lines.length - 1;
160 const checkBegan =
161 echoes.length >= needed && (run.placement !== "job" || output);
162 const testsBegan = lines.some((line) => TESTS_BEGUN.test(line));
163 if (truncated && run.state !== "ok" && !testsBegan)
164 return (["install", "typecheck", "tests"] as const).map((name) => ({
165 name,
166 state: "unknown",
167 }));
168 // A tail that begins after the echoes still shows the tests by their own banner.
169 const live = run.state === "running";
170 const dead = run.state === "failed" || run.state === "cancelled";
171 const after = run.state === "cancelled" ? "stopped" : "failed";
172
173 // The first step not yet done is where the run is, or where it ended.
174 const reached = testsBegan ? 2 : checkBegan ? 1 : 0;
175 const names: StepName[] = ["install", "typecheck", "tests"];
176 return names.map((name, i): CheckStep => {
177 if (run.state === "ok") return { name, state: "ok" };
178 if (i < reached) return { name, state: "ok" };
179 if (i === reached) {
180 if (live) return { name, state: "running" };
181 if (dead) return { name, state: after };
182 }
183 return { name, state: "pending" };
184 });
185 }
186
187 const STEP_WORDS: Record<StepState, string> = {
188 pending: "waiting",
189 running: "running",
190 ok: "passed",
191 failed: "failed",
192 stopped: "stopped",
193 unknown: "unknown",
194 };
195
196 /** `install passed · typecheck passed · tests running`: the page's rows, in a line. */
197 export const stepWord = (state: StepState): string => STEP_WORDS[state];
198
199 export function stepsWords(steps: readonly CheckStep[]): string {
200 return steps.map((s) => `${s.name} ${stepWord(s.state)}`).join(" · ");
201 }
src/tui/screens/pull.ts +27 −3
@@ -39,7 +39,7 @@ import {
3939 submitReview,
4040 } from "../../actions/pulls.ts";
4141 import { openEnvUrl } from "../../actions/apps.ts";
42 import { listRepoRuns } from "../../actions/runs.ts";
42 import { listRepoRuns, mayReadRunLog } from "../../actions/runs.ts";
4343 import { previewEnv } from "../../apps/data.ts";
4444 import {
4545 appsRuntime,
@@ -49,7 +49,7 @@ import { proposedByText } from "../../core/proposal_token.ts";
4949 import { getAppByRepo } from "../../core/apps.ts";
5050 import { getIssue, type Issue } from "../../core/issues.ts";
5151 import { isProposed, pullMayRun } from "../../core/pull_trust.ts";
52 import { runningProgress } from "../../checks/progress.ts";
52 import { runningProgress, stepsLine } from "../../checks/progress.ts";
5353 import { askCheck } from "../../checks/again.ts";
5454 import { checkStale, checksHold } from "../../checks/stale.ts";
5555 import { checkWords } from "../../checks/summary.ts";
@@ -237,6 +237,8 @@ export function pullScreen(
237237 /** The pull's base branch, to name what the check was merged with. */
238238 let checkBase = "";
239239 let checkProgress = "";
240 /** The run page's three step rows as a line; empty when the log is hidden. */
241 let checkSteps = "";
240242 /** The check's green was against a base that has since moved. */
241243 let checkOld = false;
242244 /** Agent runs about this pull, newest first. */
@@ -288,6 +290,14 @@ export function pullScreen(
288290 : checkSummary(f.db, row.id, row.headOid || (facts?.headOid ?? ""));
289291 checkBase = row?.baseBranch ?? facts?.baseBranch ?? "";
290292 checkProgress = runningProgress(f.app.config.dataDir, check);
293 checkSteps =
294 settings === undefined
295 ? ""
296 : stepsLine(
297 f.app.config.dataDir,
298 check,
299 mayReadRunLog(f.app, f.user, settings),
300 );
291301 checkOld =
292302 repo !== undefined &&
293303 row !== undefined &&
@@ -790,7 +800,7 @@ export function pullScreen(
790800 // The box is exactly as tall as what is in it: the state, then a row
791801 // for the verdicts when there are any, then a row for the checks when
792802 // a deploy has run or the gate demands one.
793 const checksLine = checksText(check, requireChecks, isApp, checkBase, checkOld, checkProgress);
803 const checksLine = checksText(check, requireChecks, isApp, checkBase, checkOld, checkProgress, checkSteps);
794804 const boxH =
795805 3 +
796806 (standingText(standing(), requireReview) === "" ? 0 : 1) +
@@ -1251,6 +1261,20 @@ export function checksText(
12511261 stale = false,
12521262 /** `runningProgress` of the check's log; empty when none is known. */
12531263 progress = "",
1264 /** `stepsLine` of the check's log; empty when none is shown. */
1265 steps = "",
1266 ): string {
1267 const line = checksLine(check, required, isApp, baseBranch, stale, progress);
1268 return line === "" || steps === "" ? line : `${line} · ${steps}`;
1269 }
1270
1271 function checksLine(
1272 check: CheckSummary,
1273 required: boolean,
1274 isApp: boolean,
1275 baseBranch: string,
1276 stale: boolean,
1277 progress: string,
12541278 ): string {
12551279 if (check.state === "none")
12561280 return required
src/web/client.ts +24
@@ -295,6 +295,27 @@ export const studioRunJs = /* js */ `
295295
296296 ${elapsedTickJs}
297297
298 // A check's step rows are server-derived from the log; when a line that
299 // can move them lands (a command's echo, the shard runner's banner or a
300 // shard header) or the run ends, the rows are fetched again and swapped in.
301 var stepsTimer = 0;
302 function steps() {
303 var cur = document.getElementById('check-steps');
304 if (!cur || !window.fetch || !window.DOMParser) return;
305 fetch(location.href, { credentials: 'same-origin', headers: { accept: 'text/html' } })
306 .then(function (r) { return r.ok ? r.text() : ''; })
307 .then(function (body) {
308 var next = new DOMParser().parseFromString(body, 'text/html').getElementById('check-steps');
309 var now = document.getElementById('check-steps');
310 if (next && now && now.parentNode) now.parentNode.replaceChild(next, now);
311 })
312 .catch(function () {});
313 }
314 function stepsSoon() {
315 if (!document.getElementById('check-steps') || stepsTimer) return;
316 stepsTimer = window.setTimeout(function () { stepsTimer = 0; steps(); }, 400);
317 }

Debounced at 400 ms, but still one full-page fetch per shard header. A rows-only response would be much cheaper.

318
298319 if (steer && window.fetch) {
299320 var text = steer.querySelector('textarea');
300321 function size() { if (!text) return; text.style.height = 'auto'; text.style.height = Math.min(160, text.scrollHeight) + 'px'; fit(); }
@@ -341,12 +362,15 @@ export const studioRunJs = /* js */ `
341362 var text = event.data;
342363 try { var parsed = JSON.parse(text); if (typeof parsed === 'string') text = parsed; } catch (err) {}
343364 add(draw(text));
365 if (/^(\\$ |test-shards: |\u2500\u2500 shard )/.test(text)) stepsSoon();
344366 });
345367 source.addEventListener('done', function (event) {
346368 source.close();
347369 var run;
348370 try { run = JSON.parse(event.data); } catch (err) { return; }
349371 paint(run);
372 if (stepsTimer) { window.clearTimeout(stepsTimer); stepsTimer = 0; }
373 steps();
350374 if (steer) {
351375 steer.setAttribute('data-running', '0');
352376 var n = document.getElementById('run-steer-note');
src/web/studio/run.ts +15
@@ -8,6 +8,7 @@
88 * at `?all=1`.
99 */
1010
11 import { checkSteps, stepWord } from "../../checks/summary.ts";
1112 import { directionNote } from "../../crew/direct.ts";
1213 import { appsRuntime } from "../../apps/runtime.ts";
1314 import { getAppByRepo } from "../../core/apps.ts";
@@ -244,6 +245,20 @@ export async function runView(
244245 Nothing has been logged yet.
245246 </p>`,
246247 )}
248 ${when(
249 // Progress is read from the log, so a reader the log is hidden from
250 // gets no rows either.
251 run.kind === "check" && !logHidden,
252 () => html`<ol class="check-steps" id="check-steps" role="status" aria-live="polite">
253 ${checkSteps(run, lines, truncated).map(
254 (s) =>
255 html`<li data-step="${s.name}" data-state="${s.state}">
256 <span class="step-name">${s.name}</span>
outdated

Static after first paint: nothing updates this list as lines stream in or when the run ends. Wire it to the live stream or re-render on state change.

257 <span class="step-state">${stepWord(s.state)}</span>
258 </li>`,
259 )}
260 </ol>`,
261 )}
247262 <div class="run-live" id="run-live" data-state="${run.state}">
248263 ${when(!logHidden, () => logBox({
249264 id: "run-log",
src/web/style/content.ts +7
@@ -865,6 +865,13 @@ form.inline { display: inline; margin-left: auto; }
865865 .checks-line .ok { display: inline-flex; align-items: center; gap: 4px; color: var(--success); }
866866 .checks-line .bad { display: inline-flex; align-items: center; gap: 4px; color: var(--danger); }
867867 .checks-line .now { display: inline-flex; align-items: center; gap: 4px; color: var(--attn); }
868 .check-steps { list-style: none; margin: 12px 0 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }

repeat(3, minmax(0,1fr)) is too narrow at 360px for name + state side by side. Use a single column under 480px (or flex-wrap) so nothing clips.

869 .check-steps li { display: flex; justify-content: space-between; gap: 8px; padding: 8px 12px; min-height: 2.5em; border: 1px solid var(--border); border-radius: var(--radius); }
870 @media (max-width: 480px) { .check-steps { grid-template-columns: minmax(0, 1fr); } }
871 .check-steps .step-state { color: var(--faint); }
872 .check-steps [data-state="ok"] .step-state { color: var(--success); }
873 .check-steps [data-state="failed"] .step-state { color: var(--danger); }
874 .check-steps [data-state="running"] .step-state { color: var(--attn); }
868875 /* A run, live: a transcript with a speaker column, bounded so the page
869876 head stays reachable, and a composer that sits under it. */
870877 .run-live { position: relative; margin-top: 12px; }
test/check-steps.test.ts added +148
@@ -0,0 +1,148 @@
1 /**
2 * A check's three step rows, read from the run's state and its log
3 * (`checkSteps`, `src/checks/summary.ts`): what each row says while the
4 * check runs, when it ends red at each step, when it is stopped, for a job
5 * placed check, and for a log whose head was cut away.
6 */
7
8 import { describe, expect, test } from "bun:test";
9 import { mkdtempSync, rmSync } from "node:fs";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { stepsLine } from "../src/checks/progress.ts";
13 import { checkSteps } from "../src/checks/summary.ts";
14 import type { Run } from "../src/core/runs.ts";
15 import { appendRunLog } from "../src/runs/log.ts";
16
17 const read = (
18 state: "queued" | "running" | "ok" | "failed" | "cancelled",
19 lines: string[],
20 opts: { placement?: string; truncated?: boolean } = {},
21 ) =>
22 checkSteps(
23 { state, placement: opts.placement ?? "process" },
24 lines,
25 opts.truncated ?? false,
26 ).map((s) => `${s.name}:${s.state}`);
27
28 const INSTALL = "$ bun install";
29 const CHECK = "$ gate && tsc && bun scripts/test-shards.ts";
30 const BANNER = "test-shards: 9 files";
31
32 describe("checkSteps", () => {
33 test("a run that has logged nothing is waiting, then running install", () => {
34 expect(read("queued", [])).toEqual([
35 "install:pending",
36 "typecheck:pending",
37 "tests:pending",
38 ]);
39 expect(read("running", [INSTALL])).toEqual([
40 "install:running",
41 "typecheck:pending",
42 "tests:pending",
43 ]);
44 });
45
46 test("typecheck runs between the check's echo and the shard banner", () => {
47 expect(read("running", [INSTALL, CHECK])).toEqual([
48 "install:ok",
49 "typecheck:running",
50 "tests:pending",
51 ]);
52 });
53
54 test("a red typecheck fails typecheck and leaves the tests unrun", () => {
55 expect(read("failed", [INSTALL, CHECK, "src/a.ts(1,1): error TS2322"])).toEqual([
56 "install:ok",
57 "typecheck:failed",
58 "tests:pending",
59 ]);
60 });
61
62 test("a red install fails install", () => {
63 expect(read("failed", [INSTALL, "error: no such package"])).toEqual([
64 "install:failed",
65 "typecheck:pending",
66 "tests:pending",
67 ]);
68 });
69
70 test("a repository with no install command starts at typecheck", () => {
71 expect(read("running", [CHECK])).toEqual([
72 "install:ok",
73 "typecheck:running",
74 "tests:pending",
75 ]);
76 });
77
78 test("a cancelled run stops at the step it was on", () => {
79 expect(read("cancelled", [INSTALL, CHECK, BANNER])).toEqual([
80 "install:ok",
81 "typecheck:ok",
82 "tests:stopped",
83 ]);
84 });
85
86 test("a passed run is passed at every step", () => {
87 expect(read("ok", [CHECK])).toEqual([
88 "install:ok",
89 "typecheck:ok",
90 "tests:ok",
91 ]);
92 });
93
94 test("a job-placed check echoes every command first: install is done only once output follows", () => {
95 expect(read("running", [INSTALL, CHECK], { placement: "job" })).toEqual([
96 "install:running",
97 "typecheck:pending",
98 "tests:pending",
99 ]);
100 expect(
101 read("running", [INSTALL, CHECK, "src/a.ts ok"], { placement: "job" }),
102 ).toEqual(["install:ok", "typecheck:running", "tests:pending"]);
103 });
104
105 test("a truncated log whose tests cannot be seen reads unknown, not wrong", () => {
106 expect(read("running", ["some late output"], { truncated: true })).toEqual([
107 "install:unknown",
108 "typecheck:unknown",
109 "tests:unknown",
110 ]);
111 expect(
112 read("running", ["── shard 2/4: test/a.test.ts"], { truncated: true }),
113 ).toEqual(["install:ok", "typecheck:ok", "tests:running"]);
114 });
115 });
116
117 describe("the terminal's step line", () => {
118 const dir = mkdtempSync(join(tmpdir(), "forge-steps-"));
119 const run = { id: "r1", kind: "check", state: "running", placement: "process" } as Run;
120 for (const l of [INSTALL, CHECK, BANNER]) appendRunLog(dir, "r1", l);
121
122 test("reads the same rows as the page, through the same function", () => {
123 expect(stepsLine(dir, { state: "running", run }, true)).toBe(
124 "install passed · typecheck passed · tests running",
125 );
126 expect(
127 stepsLine(dir, { state: "failed", run: { ...run, state: "failed" } }, true),
128 ).toBe("install passed · typecheck passed · tests failed");
129 });
130
131 test("says nothing to a reader who may not read the log, nor for a deploy", () => {
132 expect(stepsLine(dir, { state: "running", run }, false)).toBe("");
133 expect(
134 stepsLine(dir, { state: "running", run: { ...run, kind: "deploy" } }, true),
135 ).toBe("");
136 rmSync(dir, { recursive: true, force: true });
137 });
138 });
139
140 describe("install detection", () => {
141 test("a script with install in its name is not the install", () => {
142 expect(read("running", ["$ bun scripts/install-hooks.ts"])).toEqual([
143 "install:ok",
144 "typecheck:running",
145 "tests:pending",
146 ]);
147 });
148 });
test/studio-runs.test.ts +103
@@ -657,6 +657,109 @@ describe("working: a run's own page in the studio", () => {
657657 // Sub-second, in tenths — not rounded away to "took 0s".
658658 expect(page).toContain(">took 0.3s<");
659659 });
660
661 const stepStates = (page: string) =>
662 [...page.matchAll(/<li data-step="(\w+)" data-state="(\w+)"/g)].map(
663 (m) => `${m[1]}:${m[2]}`,
664 );
665
666 test("a running check's page has its three step rows from the first paint: install done, tests running", async () => {
667 const run = createRun(app.db, {
668 repoId: repo.id,
669 kind: "check",
670 commitOid: "c740479",
671 reason: "push",
672 });
673 claimRun(app.db, run.id);
674 for (const line of [
675 "$ bun install",
676 "$ bun scripts/gate.ts && tsc && bun scripts/test-shards.ts",
677 "test-shards: 12 files",
678 "── shard 1/2: test/a.test.ts",
679 ])
680 appendRunLog(app.config.dataDir, run.id, line);
681 const page = await (
682 await get(`/alice/engine/runs/${run.id}`, writerCookie)
683 ).text();
684 expect(stepStates(page)).toEqual([
685 "install:ok",
686 "typecheck:ok",
687 "tests:running",
688 ]);
689 });
690
691 test("the page re-reads its rows as the run goes, and a reader who cannot see the log gets none", async () => {
692 const script = await (
693 await get(
694 `/alice/engine/runs/${
695 (
696 createRun(app.db, {
697 repoId: repo.id,
698 kind: "check",
699 commitOid: "c740479",
700 reason: "push",
701 })
702 ).id
703 }`,
704 writerCookie,
705 )
706 ).text();
707 expect(script).toContain("getElementById('check-steps')");
708 const plain = await createRepo(app, alice, {
709 name: "plain",
710 visibility: "public",
711 description: "no app",
712 });
713 if (!plain.ok) throw new Error(String(plain.error));
714 const hidden = createRun(app.db, {
715 repoId: plain.value.id,
716 kind: "check",
717 commitOid: "c740479",
718 reason: "push",
719 });
720 claimRun(app.db, hidden.id);
721 appendRunLog(app.config.dataDir, hidden.id, "$ bun install");
722 const page = await (
723 await get(`/alice/plain/runs/${hidden.id}`, readerCookie)
724 ).text();
725 expect(page).toContain('id="run-log-hidden"');
726 expect(page).not.toContain('id="check-steps"');
727 });
728
729 test("a finished red check's page shows tests failed, and a fresh one still has three rows", async () => {
730 const run = createRun(app.db, {
731 repoId: repo.id,
732 kind: "check",
733 commitOid: "c740479",
734 reason: "push",
735 });
736 claimRun(app.db, run.id);
737 for (const line of ["$ bun install", "$ bun test", "test-shards: 3 files"])
738 appendRunLog(app.config.dataDir, run.id, line);
739 finishRun(app.db, run.id, { state: "failed", summary: "1 of 3 tests" });
740 const page = await (
741 await get(`/alice/engine/runs/${run.id}`, writerCookie)
742 ).text();
743 expect(stepStates(page)).toEqual([
744 "install:ok",
745 "typecheck:ok",
746 "tests:failed",
747 ]);
748 const fresh = createRun(app.db, {
749 repoId: repo.id,
750 kind: "check",
751 commitOid: "c740479",
752 reason: "push",
753 });
754 const queued = await (
755 await get(`/alice/engine/runs/${fresh.id}`, writerCookie)
756 ).text();
757 expect(stepStates(queued)).toEqual([
758 "install:pending",
759 "typecheck:pending",
760 "tests:pending",
761 ]);
762 });
660763 });
661764
662765 /**

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs