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

The pull's running-check line updates live and counts files and tests from the shard runner's own lines #2313

Merged opened this

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

Merged

scout/forge:scout/2280-the-pull-s-running-check-line-updates-li → main 7 commits fork 9 threads · 9 unresolved

  • Merged. scout/2280-the-pull-s-running-check-line-updates-li was merged into main as 583eacd.

Review

Sign in to review this change.
12 files changed +708 −31
README.md +1 −1

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

View file
src/checks/progress.ts added +92
@@ -0,0 +1,92 @@
1 /**
2 * How far a running check has got, read from the lines the shard runner
3 * (`scripts/test-shards.ts`) writes itself: its banner names how many files
4 * the run has, and each shard it finishes says which files it ran and
5 * carries `bun test`'s `N pass` / `M fail` for them. Only those lines count;
6 * a log that never had them (another runner, a build step) yields nothing,
7 * and a count not yet known is left out, never guessed.
8 */
9
10 import type { CheckSummary } from "../core/runs.ts";
11 import { readRunLog } from "../runs/log.ts";
12
13 export type CheckProgress = {
14 /** Files in the run, from the banner; undefined until it is written. */
15 of?: number;
16 /** Files whose shard has finished. */
17 files?: number;
18 passed?: number;
19 failed?: number;
20 };
21
22 const BANNER = /^test-shards: (?:recording |measuring )?(\d+) files\b/;
23 const SHARD_OPEN = /^── shard \d+\/\d+(?: \(exit \d+, [0-9.]+s\))?: (.+)$/;
24 const SHARD_LIVE_OPEN = /^── shard \d+\/\d+: (.+)$/;
25 const SHARD_DONE = /^── shard \d+\/\d+ done \(exit \d+, [0-9.]+s\)$/;
26 const PASS_COUNT = /^\s*(\d+) pass$/;
27 const FAIL_COUNT = /^\s*(\d+) fail$/;
28
29 const fileCount = (names: string): number =>
30 names.split(" ").filter((n) => n !== "").length;
31
32 export function parseShardProgress(lines: readonly string[]): CheckProgress {
33 const out: CheckProgress = {};
34 // The files of the shard whose lines are being read; counted once its
35 // report is complete (live: at its `done` line; batched: at its header).
36 let open = 0;
37 for (const line of lines) {
38 const banner = BANNER.exec(line);
39 if (banner) {
40 out.of = Number(banner[1]);
41 continue;
42 }
43 const batched = SHARD_OPEN.exec(line);
44 if (batched && line.includes("(exit")) {
45 out.files = (out.files ?? 0) + fileCount(batched[1]!);
46 continue;
47 }
48 const live = SHARD_LIVE_OPEN.exec(line);
49 if (live) {
50 open = fileCount(live[1]!);
51 out.files ??= 0;
52 continue;
53 }
54 if (SHARD_DONE.test(line)) {
55 out.files = (out.files ?? 0) + open;
56 open = 0;
57 continue;
58 }
59 // `bun test`'s own tail counts only inside a shard's report.
60 if (out.files === undefined) continue;
61 const pass = PASS_COUNT.exec(line);
62 if (pass) out.passed = (out.passed ?? 0) + Number(pass[1]);
63 const fail = FAIL_COUNT.exec(line);
64 if (fail) out.failed = (out.failed ?? 0) + Number(fail[1]);
65 }
66 return out;
67 }
68
69 /**
70 * `61 of 170 files · 812 passed, 0 failed`, with each part left out when it
71 * is not known: nothing at all, as an empty string, when nothing is.
72 */
73 export function progressWords(p: CheckProgress): string {
74 const parts: string[] = [];
75 if (p.files !== undefined && p.of !== undefined && p.files <= p.of)
76 parts.push(`${p.files} of ${p.of} files`);
77 if (p.passed !== undefined && p.failed !== undefined)
78 parts.push(`${p.passed} passed, ${p.failed} failed`);
79 return parts.join(" · ");
80 }
81
82 /**
83 * What both doors (the pull page and the terminal) say of a check's
84 * progress: the words for a running check's own log, empty for anything
85 * else. One reader, so the two cannot disagree.
86 */
87 export function runningProgress(dataDir: string, check: CheckSummary): string {
88 const run = check.run;
89 if (check.state !== "running" || run === null || run.kind !== "check")
90 return "";
91 return progressWords(parseShardProgress(readRunLog(dataDir, run.id).lines));
92 }
src/checks/summary.ts +3 −1
@@ -94,11 +94,13 @@ export function checkWords(
9494 "kind" | "state" | "createdAt" | "startedAt" | "finishedAt" | "summary" | "checkDetail"
9595 >,
9696 now = Date.now(),
97 /** `progressWords` of the run's log, when it has any. */
98 progress = "",
9799 ): string | undefined {
98100 if (run.kind !== "check") return undefined;
99101 const began = Date.parse(run.startedAt ?? run.createdAt);
100102 if (run.state === "running")
101 return `Check running · ${spanWords(now - began)}`;
103 return `Check running · ${spanWords(now - began)}${progress === "" ? "" : ` · ${progress}`}`;
102104 if (run.state === "ok")
103105 return run.finishedAt === null
104106 ? "Check passed"
src/http/server.ts +2
@@ -318,6 +318,7 @@ import { variantsPage, variantVoteSubmit } from "../web/pages/variants.ts";
318318 import {
319319 pullCommentSubmit,
320320 pullFilesPage,
321 pullChecksFragment,
321322 pullMergeSubmit,
322323 pullChecksSubmit,
323324 pullReviewStateSubmit,
@@ -1085,6 +1086,7 @@ export function buildRouter(): Router<Handler> {
10851086 router.add("POST", "/:owner/:name/pulls/:number/resolve/propose", proposeSubmit);
10861087 router.add("GET", "/:owner/:name/pulls/:number/thread", pullThreadFragment);
10871088 router.add("GET", "/:owner/:name/pulls/:number/state", pullStateFragment);
1089 router.add("GET", "/:owner/:name/pulls/:number/checks-line", pullChecksFragment);
10881090 router.add("POST", "/:owner/:name/pulls/:number/comments", pullCommentSubmit);
10891091 router.add("POST", "/:owner/:name/pulls/:number/review", pullReviewSubmit);
10901092 // The verdict on the change as a whole, and the threads it settles.
src/tui/screens/pull.ts +17 −2
@@ -49,6 +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";
5253 import { askCheck } from "../../checks/again.ts";
5354 import { checkStale, checksHold } from "../../checks/stale.ts";
5455 import { checkWords } from "../../checks/summary.ts";
@@ -192,6 +193,9 @@ const VERDICT_WORDS: Record<ReviewFact["state"], string> = {
192193 commented: "commented",
193194 };
194195
196 /** How often a pull's screen re-reads while its check is queued or running. */
197 const CHECK_TICK_MS = 5000;
198
195199 export function pullScreen(
196200 f: Session,
197201 ctx: Ctx,
@@ -232,6 +236,7 @@ export function pullScreen(
232236 let check: CheckSummary = NO_CHECK;
233237 /** The pull's base branch, to name what the check was merged with. */
234238 let checkBase = "";
239 let checkProgress = "";
235240 /** The check's green was against a base that has since moved. */
236241 let checkOld = false;
237242 /** Agent runs about this pull, newest first. */
@@ -282,6 +287,7 @@ export function pullScreen(
282287 ? NO_CHECK
283288 : checkSummary(f.db, row.id, row.headOid || (facts?.headOid ?? ""));
284289 checkBase = row?.baseBranch ?? facts?.baseBranch ?? "";
290 checkProgress = runningProgress(f.app.config.dataDir, check);
285291 checkOld =
286292 repo !== undefined &&
287293 row !== undefined &&
@@ -767,6 +773,13 @@ export function pullScreen(
767773 load(signal) {
768774 if (repo !== undefined)
769775 live(f, signal, { repoId: repo.id }, () => runAsync(ctx, reload));
776 // A run emits no event when a shard finishes, so while a check is
777 // queued or running the screen re-reads on a slow tick of its own.
778 const tick = setInterval(() => {
779 if (check.state === "queued" || check.state === "running")
780 runAsync(ctx, reload);
781 }, CHECK_TICK_MS);
782 signal.addEventListener("abort", () => clearInterval(tick));
770783 return reload();
771784 },
772785 render(area, s) {
@@ -777,7 +790,7 @@ export function pullScreen(
777790 // The box is exactly as tall as what is in it: the state, then a row
778791 // for the verdicts when there are any, then a row for the checks when
779792 // a deploy has run or the gate demands one.
780 const checksLine = checksText(check, requireChecks, isApp, checkBase, checkOld);
793 const checksLine = checksText(check, requireChecks, isApp, checkBase, checkOld, checkProgress);
781794 const boxH =
782795 3 +
783796 (standingText(standing(), requireReview) === "" ? 0 : 1) +
@@ -1236,6 +1249,8 @@ export function checksText(
12361249 isApp: boolean,
12371250 baseBranch = "",
12381251 stale = false,
1252 /** `runningProgress` of the check's log; empty when none is known. */
1253 progress = "",
12391254 ): string {
12401255 if (check.state === "none")
12411256 return required
@@ -1252,7 +1267,7 @@ export function checksText(
12521267 run === null || run.summary === "" ? "" : ` · ${run.summary}`
12531268 }${against}`;
12541269 const isCheck = run !== null && run.kind === "check";
1255 const words = run === null ? undefined : checkWords({ ...run, state: check.state });
1270 const words = run === null ? undefined : checkWords({ ...run, state: check.state }, Date.now(), progress);
12561271 const ofRun = `${run === null ? "" : ` · run ${run.id}`}${against}`;
12571272 switch (check.state) {
12581273 case "ok":
src/web/client.ts +16 −4
@@ -1938,6 +1938,8 @@ export const liveRegionsJs = /* js */ `
19381938 mine.innerHTML = theirs.innerHTML;
19391939 opened.forEach(function (i) { var d = document.getElementById(i); if (d) d.open = true; });
19401940 Array.prototype.forEach.call(theirs.attributes, function (a) { if (a.name !== 'id') mine.setAttribute(a.name, a.value); });
1941 if (!theirs.hasAttribute('data-live-every')) mine.removeAttribute('data-live-every');
1942 arm(mine);
19411943 mine.dispatchEvent(new CustomEvent('forge:swapped', { bubbles: true }));
19421944 });
19431945 }
@@ -2041,11 +2043,21 @@ export const liveRegionsJs = /* js */ `
20412043 }
20422044 // A region with data-live-every="<seconds>" is also due on that beat, for
20432045 // what no event announces; it rides the same fetch, etag and swap.
2044 Array.prototype.forEach.call(document.querySelectorAll('[data-live-every]'), function (r) {
2046 // The beat lasts as long as the region asks for it: a swap that drops the
2047 // attribute ends it, and one that brings it (a check starting) begins it.
2048 var beats = {};
2049 function arm(r) {

This is what stops and starts the beat, and nothing tests it. If it were reverted, a pull page left open would ask every 5 seconds forever after its check finished, and every test would still pass. Add a test in test/live-pages.test.ts that runs this script against a fake DOM (swap without data-live-every → clearInterval called; swap with it → setInterval called once, not twice).

20452050 var s = parseInt(r.getAttribute('data-live-every'), 10);
2046 if (!r.id || !(s > 0)) return;
2047 window.setInterval(function () { due[r.id] = true; if (timer === null) timer = setTimeout(refresh, 0); }, s * 1000);
2048 });
2051 if (!r.id) return;
2052 if (!(s > 0)) {
2053 if (beats[r.id]) { window.clearInterval(beats[r.id]); delete beats[r.id]; }
2054 return;
2055 }
2056 if (beats[r.id]) return;
2057 var id = r.id;
2058 beats[id] = window.setInterval(function () { due[id] = true; if (timer === null) timer = setTimeout(refresh, 0); }, s * 1000);
2059 }
2060 Array.prototype.forEach.call(document.querySelectorAll('[data-live-every]'), arm);
20492061 connect();
20502062 // Back online: reconnect from the last id and look at every region once,
20512063 // in case the replay window no longer reaches back that far.
src/web/pages/pull.ts +120 −20
@@ -28,6 +28,7 @@ import { marksByName } from "../../core/marks.ts";
2828 import {
2929 pullDiff,
3030 pullDir,
31 pullStatus,
3132 type MergePullError,
3233 type Pull,
3334 type PullReviewStatus,
@@ -35,6 +36,7 @@ import {
3536 } from "../../core/pulls.ts";
3637 import { isProposed, pullMayRun } from "../../core/pull_trust.ts";
3738 import { checkWords } from "../../checks/summary.ts";
39 import { runningProgress } from "../../checks/progress.ts";
3840 import { checkStale, checksHold } from "../../checks/stale.ts";
3941 import { checkSummary, type CheckSummary } from "../../core/runs.ts";
4042 import {
@@ -70,7 +72,8 @@ import {
7072 } from "../diff-view.ts";
7173 import { field, readForm } from "../form.ts";
7274 import { html, raw, when, type Html } from "../html.ts";
73 import { flashTo, page, redirect } from "../layout.ts";
75 import { flashTo, fragment, page, redirect } from "../layout.ts";
76 import { liveRegionsJs } from "../client.ts";
7477 import { liveClientJs, liveMount } from "../live.ts";
7578 import {
7679 avatar,
@@ -220,6 +223,10 @@ export function checksLine(
220223 baseBranch = "",
221224 /** The green was checked against a base that has since moved. */
222225 stale = false,
226 /** `progressWords` of a running check's log; empty when none is known. */
227 progress = "",
228 /** Where this line answers for itself (`data-live-src`); empty for none. */
229 liveSrc = "",
223230 ): Html {
224231 if (!isApp && !required && check.state === "none" && !waiting && again === "")
225232 return html``;
@@ -231,7 +238,7 @@ export function checksLine(
231238 ><a href="${base}/runs/${run.id}"
232239 >${run.kind === "check" ? "Check of" : "deploy"} ${run.commitOid.slice(0, 7)}</a
233240 >`;
234 const words = run === null ? undefined : checkWords(run);
241 const words = run === null ? undefined : checkWords(run, Date.now(), progress);
235242 const against =
236243 run !== null && run.baseOid !== "" && baseBranch !== ""
237244 ? html`<span class="where-sep" aria-hidden="true">·</span
@@ -265,7 +272,16 @@ export function checksLine(
265272 : waiting
266273 ? html`<span>${icon("lock")}${WAITING_WORDS}</span>`
267274 : html`<span>${icon("clock")}No checks on this head yet</span>`;
268 return html`<p class="checks-line">
275 // Its own address: an event or the beat fetches this line, not the page.
276 // While a check runs there is no event for a finished shard, so it also
277 // asks on a beat.
278 const live =
279 liveSrc === ""
280 ? null
outdated

This region is never driven on the page that renders it. pullFilesPage ships only liveClientJs, which ignores data-live-src and data-live-every. Load liveRegionsJs there too (or fold the region swap into liveClientJs), and add a test that the page's script handles this region.

281 : html`id="live-checks" data-live="run.*" data-live-src="${liveSrc}"${
outdated

Right condition. The server half of ending the beat lives here, and it needs the route test in pull-checks-line-route.test.ts that shows it goes away once the check is no longer queued or running.

Medium. This region has no data-live-repo, so every run.* event on the whole forge marks it due. When the check is not running, the refetch takes the full path (pullStatus plus resolveOid) once per event per open tab, rate-limited to one per 1.5 s. Add data-live-repo="owner/name" the way chain.ts and studio/now.ts do. Tracked in #2356.

282 check.state === "running" || check.state === "queued" ? html` data-live-every="5"` : null

This beat is set once at load and never stops (client.ts installs a setInterval and never clears it). When the check finishes, the region keeps polling every 5 seconds for as long as the tab is open.

283 }`;
284 return html`<p class="checks-line" ${live}>
269285 ${body}${against}${when(
270286 tier !== "",
271287 () =>
@@ -315,6 +331,43 @@ async function tierOf(
315331
316332 // ---- the merge box ----
317333
334 /**
335 * The checks line of a pull, as the merge box shows it and as its live
336 * address answers it: one function, so the two never disagree. A running
337 * check's counts come from its own log, read here and nowhere else.
338 */
339 function pullChecksLine(
340 ctx: Ctx,
341 repo: Repo,
342 pull: Pull,
343 status: PullStatus,
344 check: CheckSummary,
345 isApp: boolean,
346 tier = "",
347 stale = false,
348 ): Html {
349 const thread = `${repoBase(repo)}/pulls/${pull.number}`;
350 const progress = runningProgress(ctx.app.config.dataDir, check);
351 return checksLine(
352 repoBase(repo),
353 check,
354 repo.requireChecks,
355 isApp,
356 !pullMayRun(ctx.app.db, repo, pull, status.headOid),
357 tier,
358 pull.state === "open" && canWrite(ctx.app.db, repo, ctx.auth.user)
359 ? html`<form method="post" action="${thread}/checks" class="inline-form">
360 ${btn("Check again", { size: "sm", icon: "loader" })}
361 </form>`
362 : "",
363 status.conflicts.length,
364 pull.baseBranch,
365 stale,
366 progress,
367 `${thread}/checks-line`,
368 );
369 }
370
318371 /**
319372 * Six states, six cards. Every one names the situation, gives the reason,
320373 * and offers exactly the actions that are actually available.
@@ -334,22 +387,7 @@ function mergeBox(
334387 const thread = `${repoBase(repo)}/pulls/${pull.number}`;
335388 // A reader's head runs nothing until a writer approves it; say so rather
336389 // than leave "no checks yet" waiting for something that will not come.
337 const checks = checksLine(
338 repoBase(repo),
339 check,
340 repo.requireChecks,
341 isApp,
342 !pullMayRun(ctx.app.db, repo, pull, status.headOid),
343 tier,
344 pull.state === "open" && canWrite(ctx.app.db, repo, ctx.auth.user)
345 ? html`<form method="post" action="${thread}/checks" class="inline-form">
346 ${btn("Check again", { size: "sm", icon: "loader" })}
347 </form>`
348 : "",
349 status.conflicts.length,
350 pull.baseBranch,
351 stale,
352 );
390 const checks = pullChecksLine(ctx, repo, pull, status, check, isApp, tier, stale);
353391 const reopen = html`<form method="post" action="${thread}/state">
354392 <input type="hidden" name="state" value="open" />
355393 ${btn("Reopen", { size: "sm", icon: "pull" })}
@@ -876,7 +914,7 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> {
876914 palette: repoPalette(ctx, repo),
877915 body,
878916 styleCss: labelCss([...allLabels, ...applied]),
879 scriptJs: liveClientJs,
917 scriptJs: `${liveClientJs}\n${liveRegionsJs}`,
880918 });
881919 }
882920
@@ -1229,3 +1267,65 @@ export async function pullMergeSubmit(ctx: Ctx): Promise<Response> {
12291267 }
12301268 return flashTo(ctx, dest, { message: "Pull request merged." });
12311269 }
1270
1271 /**
1272 * GET /:owner/:name/pulls/:number/checks-line — the checks line alone, for
1273 * the region that shows it. Authorized as the page is: a viewer who cannot
1274 * read the pull gets the same 404.
1275 */
1276 export async function pullChecksFragment(ctx: Ctx): Promise<Response> {
1277 const found = pullContext(ctx);
1278 if (found instanceof Response) return fragment(html``, { status: 404 });
outdated

No test covers this route. Add one for the 404 to a viewer who can't read the pull, one for the 200 to a viewer who can, and one showing the counts from a run log on disk. Also, every 5-second beat runs pullStatus and two more git calls here even after the check has finished. Keep it cheap when check.state isn't running or queued.

1279 const { repo, pull } = found;
1280 const dir = await pullDir(ctx.app.db, ctx.app.reposRoot, repo, pull);
1281 const isApp = getAppByRepo(ctx.app.db, repo.id) !== undefined;
1282 // The beat asks every 5 seconds while a check runs: answer from the stored
1283 // head without the merge reading or the base tip, which a running line
1284 // does not show. Anything else takes the full road below.
1285 const quick = checkSummary(ctx.app.db, pull.id, pull.headOid);
1286 if (quick.state === "running" || quick.state === "queued") {
1287 const status: PullStatus = {
1288 baseOid: pull.baseOid,
1289 headOid: pull.headOid,
1290 ahead: 0,
1291 mergeable: false,
1292 conflicts: [],
1293 reviews: null,
1294 };
1295 const tier = await tierOf(dir, pull.headOid, quick);
1296 return fragment(
1297 html`<div>${pullChecksLine(ctx, repo, pull, status, quick, isApp, tier)}</div>`,
1298 { request: ctx.request },
1299 );
1300 }
1301 const status = await pullStatus(dir, pull, {
1302 db: ctx.app.db,
1303 requireReview: repo.requireReview,
1304 });
1305 return fragment(
1306 html`<div>${await pullChecksLineFor(ctx, repo, pull, status, dir)}</div>`,
1307 { request: ctx.request },
1308 );
1309 }
1310
1311 /**
1312 * The checks line with everything the page and the route both derive for it
1313 * (the check, the tier words, an older base), so a page that draws it at first
1314 * paint and the route that refreshes it say the same thing.
1315 */
1316 export async function pullChecksLineFor(
1317 ctx: Ctx,
1318 repo: Repo,
1319 pull: Pull,
1320 status: PullStatus,
1321 dir: string,
1322 ): Promise<Html> {
1323 const check = checkSummary(ctx.app.db, pull.id, status.headOid);
1324 const isApp = getAppByRepo(ctx.app.db, repo.id) !== undefined;
1325 const tier = await tierOf(dir, status.headOid, check);
1326 const stale =
1327 pull.state === "open" && pull.mergedAt === null
1328 ? checkStale(check.run, await resolveOid(dir, `refs/heads/${pull.baseBranch}`))
1329 : false;
1330 return pullChecksLine(ctx, repo, pull, status, check, isApp, tier, stale);
1331 }
src/web/studio/change.ts +11 −3
@@ -63,7 +63,8 @@ import {
6363 } from "../ui.ts";
6464 import { previewCard } from "../pages/apps.ts";
6565 import { lookCard } from "../pages/looks.ts";
66 import { approveHereCard } from "../pages/pull.ts";
66 import { approveHereCard, pullChecksLineFor } from "../pages/pull.ts";
67 import { liveRegionsJs } from "../client.ts";
6768 import { pullState } from "../pages/pulls.ts";
6869 import { workSpent } from "../pages/runs.ts";
6970 import {
@@ -418,6 +419,12 @@ async function changeConversation(
418419 ): Promise<Response> {
419420 const { record, db, base, thread, wordCtx, word, status, conflicted, note, checkLine, preview } =
420421 await pullPanels(ctx, repo, pull);
422 // The check's own line, live, with a running check's counts: the one the
423 // Files step draws (`pullChecksLineFor`), for an open change.
424 const runLine =
425 status === undefined
426 ? undefined
427 : await pullChecksLineFor(ctx, repo, pull, status, wordCtx.dir);
421428 // A change that loosens the policy is a person's call: said here, above
422429 // the decision, as well as on the Files step beside the merge box.
423430 const wall =
@@ -578,7 +585,8 @@ async function changeConversation(
578585 >
579586 </p>`,
580587 )}
581 <div id="pull-check">${checkLine}</div> ${holdCard(ctx, repo, pull, { act: true })}
588 <div id="pull-check">${checkLine}</div>
589 ${when(runLine, (l) => l)} ${holdCard(ctx, repo, pull, { act: true })}

Low. The change page now shows two check rows: the word-badge card, which links deploy abc1234, and this line, which links Check of abc1234 to the same run. Later, consider merging them into one row, so the page stays dense rather than repeating itself.

582590 ${approveHereCard(ctx, repo, pull, headOid, `${thread}/approve-here`)}
583591 ${when(wall, (w) => wallCard(w))} ${decisionSection}
584592 ${takeBackSection}
@@ -623,6 +631,6 @@ async function changeConversation(
623631 crumbs: crumbsFor(repo),
624632 palette: repoPalette(ctx, repo),
625633 body,
626 scriptJs: liveClientJs,
634 scriptJs: `${liveClientJs}\n${liveRegionsJs}`,
627635 });
628636 }
test/check-progress.test.ts added +67
@@ -0,0 +1,67 @@
1 import { expect, test } from "bun:test";
2 import { parseShardProgress, progressWords } from "../src/checks/progress.ts";
3 import { checksLine } from "../src/web/pages/pull.ts";
4 import type { Run } from "../src/core/runs.ts";
5
6 const live = [
7 "test-shards: 170 files in 4 shards, expected 90s 91s 90s 92s",
8 "",
9 "── shard 1/4: a.test.ts b.test.ts",
10 " 500 pass",
11 " 0 fail",
12 "── shard 1/4 done (exit 0, 3.0s)",
13 "── shard 2/4: c.test.ts",
14 " 312 pass",
15 " 0 fail",
16 ];
17
18 test("counts come from the runner's own lines, and only those", () => {
19 expect(parseShardProgress(live)).toEqual({ of: 170, files: 2, passed: 812, failed: 0 });
20 expect(progressWords(parseShardProgress(live))).toBe("2 of 170 files · 812 passed, 0 failed");
21 const batched = [
22 "test-shards: 3 files in 2 shards",
23 "── shard 1/2 (exit 0, 2.0s): a.ts b.ts",
24 " 7 pass",
25 " 1 fail",
26 ];
27 expect(progressWords(parseShardProgress(batched))).toBe("2 of 3 files · 7 passed, 1 failed");
28 // a stray "5 pass" before any shard report, or a build log, is not a count
29 expect(progressWords(parseShardProgress([" 5 pass", " 0 fail", "tsc ok"]))).toBe("");
30 expect(progressWords(parseShardProgress(["test-shards: 9 files in 1 shard"]))).toBe("");
31 });
32
33 const run = {
34 id: "r1",
35 kind: "check",
36 env: "pull-7",
37 commitOid: "abcdef1234567",
38 state: "running",
39 summary: "",
40 createdAt: "2026-10-16T10:00:00Z",
41 startedAt: "2026-10-16T10:00:00Z",
42 finishedAt: null,
43 checkDetail: null,
44 baseOid: "",
45 } as Run;
46 const fragmentOf = (progress: string) =>
47 checksLine("/a/b", { state: "running", run }, false, true, false, "", "", 0, "", false, progress, "/a/b/pulls/7/checks-line").text;
48
49 test("the live fragment changes when the run's counts change", () => {
50 const before = fragmentOf("");
51 const after = fragmentOf(progressWords(parseShardProgress(live)));
52 expect(before).toContain('data-live-src="/a/b/pulls/7/checks-line"');
53 expect(before).toContain('id="live-checks"');
54 expect(before).not.toContain("files");
55 expect(after).toContain("2 of 170 files · 812 passed, 0 failed");
56 expect(after).not.toBe(before);
57 });
58
59 test("the terminal's line carries the same counts", async () => {
60 const { checksText } = await import("../src/tui/screens/pull.ts");
61 const words = progressWords(parseShardProgress(live));
62 const check = { state: "running", run } as const;
63 const withCounts = checksText(check, false, true, "", false, words);
64 expect(withCounts).toContain("2 of 170 files · 812 passed, 0 failed");
65 expect(withCounts).toMatch(/^Check running · /);
66 expect(checksText(check, false, true)).not.toContain(" files");
67 });
test/live-pages.test.ts +102
@@ -170,3 +170,105 @@ describe("live browsing pages", () => {
170170 expect(liveRegionsJs).toContain("data-busy");
171171 });
172172 });
173
174 describe("the beat of a region with data-live-every", () => {
175 type Attrs = Record<string, string>;
176 const region = (attrs: Attrs, html: string) => {
177 const r = {
178 id: attrs["id"] ?? "",
179 attrs: { ...attrs },
180 innerHTML: html,
181 getAttribute: (n: string) => r.attrs[n] ?? null,
182 hasAttribute: (n: string) => n in r.attrs,
183 setAttribute: (n: string, v: string) => void (r.attrs[n] = v),
184 removeAttribute: (n: string) => void delete r.attrs[n],
185 get attributes() {
186 return Object.entries(r.attrs).map(([name, value]) => ({ name, value }));
187 },
188 querySelector: () => null,
189 querySelectorAll: () => [],
190 contains: () => false,
191 dispatchEvent: () => true,
192 };
193 return r;
194 };
195
196 /** Runs the script against a fake page; `answers` are the fragments the server gives, in order. */
197 const run = async (js: string, answers: Attrs[]) => {
198 const mine = region(
199 { id: "live-checks", "data-live": "run.*", "data-live-src": "/x/checks-line", "data-live-every": "5" },
200 "running",
201 );
202 let n = 0;
203 let fragment = region({ id: "live-checks" }, "");
204 const timers: Array<() => void> = [];
205 const intervals = new Map<number, () => void>();
206 const cleared: number[] = [];
207 let nextId = 1;
208 const doc = {
209 hidden: false,
210 activeElement: null,
211 querySelectorAll: (sel: string) =>
212 sel === "[data-live-every]"
213 ? "data-live-every" in mine.attrs ? [mine] : []
214 : sel === "[data-live]" ? [mine] : [],
215 getElementById: (id: string) => (id === "live-checks" ? mine : null),
216 createElement: () => ({
217 set innerHTML(_: string) {},
218 content: { querySelector: () => null, getElementById: () => fragment },
219 }),
220 dispatchEvent: () => true,
221 addEventListener: () => {},
222 };
223 const win = {
224 setInterval: (f: () => void) => (intervals.set(nextId, f), nextId++),
225 clearInterval: (id: number) => (intervals.delete(id), void cleared.push(id)),
226 addEventListener: () => {},
227 };
228 class ES {
229 addEventListener() {}
230 close() {}
231 }
232 const fake = {
233 document: doc,
234 window: win,
235 location: { href: "http://x/page" },
236 EventSource: ES,
237 CustomEvent: class {},
238 DOMParser: class {},
239 setTimeout: (f: () => void) => (timers.push(f), 0),
240 clearTimeout: () => {},
241 fetch: async () => {
242 fragment = region(answers[n++] ?? {}, "changed " + n);
243 return { status: 200, ok: true, headers: { get: () => null }, text: async () => "" };
244 },
245 };
246 try { new Function(...Object.keys(fake), js)(...Object.values(fake)); } catch (e) { console.log("ERR", e); }

Nit: the catch that logs ERR hides a script that throws on load. The assertions below still catch it, but rethrowing, or dropping the try, would make the failure say why.

247 const beat = async () => {
248 for (const f of [...intervals.values()]) f();
249 while (timers.length > 0) timers.shift()!();
250 await new Promise((r) => setTimeout(r, 0));
251 };
252 return { mine, intervals, cleared, beat };
253 };
254
255 test("a swap without the attribute clears the beat and the region stops asking", async () => {
256 const { liveRegionsJs } = await import("../src/web/client.ts");
257 const { mine, intervals, cleared, beat } = await run(liveRegionsJs, [{}]);
258 expect(intervals.size).toBe(1);
259 await beat();
260 expect(cleared.length).toBe(1);
261 expect(intervals.size).toBe(0);
262 expect(mine.hasAttribute("data-live-every")).toBe(false);
263 });
264
265 test("a swap that brings the attribute arms one beat, and a repeat does not arm a second", async () => {
266 const { liveRegionsJs } = await import("../src/web/client.ts");
267 const every = { "data-live-every": "5" };
268 const { intervals, cleared, beat } = await run(liveRegionsJs, [every, every]);
269 await beat();
270 await beat();
271 expect(intervals.size).toBe(1);
272 expect(cleared.length).toBe(0);
273 });
274 });
test/pull-checks-line-route.test.ts added +166
@@ -0,0 +1,166 @@
1 /**
2 * GET …/pulls/:number/checks-line (#2280): the checks line alone, gated as
3 * the pull page is, carrying a running check's counts from its own log.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { openPull } from "../src/actions/pulls.ts";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { createRun } from "../src/core/runs.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { appendRunLog } from "../src/runs/log.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 let dataDir: string;
22 let app: App;
23 let servers: Servers;
24 let alice: User;
25 let bob: User;
26 const numbers: Record<string, number> = {};
27 let runId = "";
28 const cells = process.env["FORGE_CELLS"];
29
30 const mk = async (username: string, first = false): Promise<User> => {
31 const made = await createUser(app.db, {
32 trusted: true,
33 username,
34 email: `${username}@forge.local`,
35 password: "a-good-passphrase",
36 adminIfFirst: first,
37 });
38 if (!made.ok) throw new Error(String(made.error));
39 return made.value;
40 };
41
42 const get = (repo: string, user: User): Promise<Response> =>
43 fetch(`${servers.url}/alice/${repo}/pulls/${numbers[repo]}/checks-line`, {
44 headers: { cookie: `forge_session=${createSession(app.db, user.id).token}` },
45 });
46
47 beforeAll(async () => {
48 process.env["FORGE_CELLS"] = "off";
49 dataDir = await mkdtemp(join(tmpdir(), "forge-checks-line-"));
50 app = createApp({ dataDir, port: 0 });
51 servers = await serve(app);
52 alice = await mk("alice", true);
53 bob = await mk("bob");
54 for (const [name, visibility] of [
55 ["open", "public"],
56 ["hidden", "private"],
57 ] as const) {
58 const made = await createRepo(app, alice, { name, visibility });
59 if (!made.ok) throw new Error(String(made.error));
60 const repo = made.value;
61 for (const [branch, file] of [[undefined, "forge.json"], ["topic", "t.txt"]] as const) {
62 const done = await commitToBranch(app, alice, repo, {
63 ...(branch === undefined ? {} : { branch }),
64 message: "change",
65 files: [{ path: file, content: file === "forge.json" ? "{}" : "x" }],
66 });
67 if (!done.ok) throw new Error(JSON.stringify(done.error));
68 }
69 const pull = await openPull(app, alice, repo, {
70 title: name,
71 body: "",
72 baseBranch: "main",
73 headBranch: "topic",
74 });
75 if (!pull.ok) throw new Error(JSON.stringify(pull.error));
76 numbers[name] = pull.value.number;
77 if (name === "open") {
78 const run = createRun(app.db, {
79 repoId: repo.id,
80 kind: "check",
81 issueId: pull.value.id,
82 env: `pull-${pull.value.number}`,
83 commitOid: pull.value.headOid,
84 });
85 app.db.run("UPDATE run SET state = 'running', started_at = ? WHERE id = ?", [
86 new Date().toISOString(),
87 run.id,
88 ]);
89 runId = run.id;
90 }
91 }
92 });
93
94 afterAll(async () => {
95 await servers.stop();
96 app.close();
97 if (cells === undefined) delete process.env["FORGE_CELLS"];
98 else process.env["FORGE_CELLS"] = cells;
99 await rm(dataDir, { recursive: true, force: true });
100 });
101
102 test("a viewer who cannot read the pull gets 404", async () => {
103 expect((await get("hidden", bob)).status).toBe(404);
104 });
105
106 test("a viewer who can gets the line, without counts until the log has them", async () => {
107 const res = await get("open", bob);
108 expect(res.status).toBe(200);
109 const text = await res.text();
110 expect(text).toContain('id="live-checks"');
111 expect(text).toContain("Check running");
112 expect(text).toContain('data-live-every="5"');
113 expect(text).not.toContain(" files");
114 });
115
116 test("the counts appear once the run's log holds shard lines", async () => {

Add the other half here. Set the run to state = 'ok', finished_at = now, then assert that the line no longer carries data-live-every, shows no counts, and reads as passed. Without that test, nothing proves the region tells the client to stop asking.

117 for (const line of [
118 "test-shards: 170 files in 4 shards, expected 90s",
119 "── shard 1/4: a.test.ts b.test.ts",
120 " 500 pass",
121 " 0 fail",
122 "── shard 1/4 done (exit 0, 3.0s)",
123 ])
124 appendRunLog(dataDir, runId, line);
125 const text = await (await get("open", bob)).text();
126 expect(text).toContain("2 of 170 files · 500 passed, 0 failed");
127 });
128
129 test("the page that draws the line also ships the script that drives it", async () => {
130 const res = await fetch(`${servers.url}/alice/open/pulls/${numbers["open"]}/files`, {
131 headers: { cookie: `forge_session=${createSession(app.db, bob.id).token}` },
132 });
133 expect(res.status).toBe(200);
134 const text = await res.text();
135 expect(text).toContain('id="live-checks"');
136 expect(text).toContain('data-live-every="5"');
137 // liveRegionsJs reads data-live-src and arms the beat; liveClientJs alone does not.
138 expect(text).toContain("data-live-src");
139 expect(text).toContain("function arm(");
140 });
141
142 test("once the check has passed, the line has no beat, no counts, and reads as passed", async () => {
143 app.db.run("UPDATE run SET state = 'ok', finished_at = ? WHERE id = ?", [
144 new Date().toISOString(),
145 runId,
146 ]);
147 const text = await (await get("open", bob)).text();
148 expect(text).toContain('id="live-checks"');
149 expect(text).not.toContain("data-live-every");
150 expect(text).not.toContain(" files");
151 expect(text).not.toContain("Check running");
152 expect(text).toContain("Check passed");
153 });
154
155 test("the change page the owner lands on draws the same live line, with the counts", async () => {
156 app.db.run("UPDATE run SET state = 'running', finished_at = NULL WHERE id = ?", [runId]);
157 const res = await fetch(`${servers.url}/alice/open/changes/${numbers["open"]}`, {
158 headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` },
159 });
160 expect(res.status).toBe(200);
161 const text = await res.text();
162 expect(text).toContain('id="live-checks"');
163 expect(text).toContain('data-live-every="5"');
164 expect(text).toContain("2 of 170 files · 500 passed, 0 failed");
165 expect(text).toContain("function arm(");
166 });
test/pull-tui-check-tick.test.ts added +111
@@ -0,0 +1,111 @@
1 /**
2 * A pull's terminal screen re-reads on a slow tick while its check runs
3 * (#2280): the runner emits no event when a shard finishes, so counts that
4 * arrive after the first read must still reach the line.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { openPull } from "../src/actions/pulls.ts";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { claimRun, createRun } from "../src/core/runs.ts";
14 import { getPull } from "../src/core/pulls.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { appendRunLog } from "../src/runs/log.ts";
17 import { createForgeApp } from "../src/tui/forge.ts";
18 import { pullScreen } from "../src/tui/screens/pull.ts";
19 import { local } from "../src/tui/screens/where.ts";
20 import { headlessTerminal } from "../src/tui/term/terminal.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22 import { settleApp } from "./helpers/tui.ts";
23
24 quietLogs();
25
26 let dataDir: string;
27 let app: App;
28
29 beforeAll(async () => {
30 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-tick-"));
31 app = createApp({ dataDir, port: 0, host: "forge.test" });
32 });
33
34 afterAll(async () => {
35 app.close();
36 await rm(dataDir, { recursive: true, force: true });
37 });
38
39 test("counts written after the first read reach the terminal's line", async () => {
40 const made = await createUser(app.db, {
41 trusted: true,
42 username: "alice",
43 email: "[email protected]",
44 password: "a-good-passphrase",
45 adminIfFirst: true,
46 });
47 if (!made.ok) throw new Error(String(made.error));
48 const alice = made.value;
49 const r = await createRepo(app, alice, { name: "lib", visibility: "public" });
50 if (!r.ok) throw new Error(String(r.error));
51 const repo = r.value;
52 for (const branch of ["main", "feat"]) {
53 const done = await commitToBranch(app, alice, repo, {
54 branch,
55 message: branch,
56 files: [{ path: "code.txt", content: branch }],
57 });
58 if (!done.ok) throw new Error(JSON.stringify(done.error));
59 }
60 const opened = await openPull(app, alice, repo, {
61 title: "Feat",
62 baseBranch: "main",
63 headBranch: "feat",
64 });
65 if (!opened.ok) throw new Error(String(opened.error));
66 const pull = getPull(app.db, repo.id, opened.value.number)!;
67 const run = createRun(app.db, {
68 repoId: repo.id,
69 kind: "check",
70 issueId: pull.id,
71 env: `pull-${pull.number}`,
72 commitOid: pull.headOid,
73 });
74 claimRun(app.db, run.id);
75
76 const { app: tui, session } = createForgeApp(
77 app,
78 {
79 sshUsername: "alice",
80 remoteAddress: "127.0.0.1",
81 fingerprint: "SHA256:alice",
82 user: alice,
83 },
84 { terminal: headlessTerminal(160, 44), exitHooks: false },
85 );
86 tui.start();
87 try {
88 tui.ctx.push(pullScreen(session, tui.ctx, local(session, repo), pull.number));
89 const until = async (needle: string, ms: number) => {
90 const end = Date.now() + ms;
91 while (!tui.screen.toText().includes(needle)) {
92 if (Date.now() > end) throw new Error(tui.screen.toText());
93 await settleApp(tui);
94 await Bun.sleep(20);
95 }
96 };
97 await until("Check running", 20_000);
98 expect(tui.screen.toText()).not.toContain(" files");
99 for (const line of [
100 "test-shards: 170 files in 4 shards, expected 90s",
101 "── shard 1/4: a.test.ts b.test.ts",
102 " 500 pass",
103 " 0 fail",
104 "── shard 1/4 done (exit 0, 3.0s)",
105 ])
106 appendRunLog(dataDir, run.id, line);
107 await until("2 of 170 files", 15_000);
108 } finally {
109 tui.stop();
110 }
111 }, 60_000);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs