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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2280-the-pull-s-running-check-line-updates-li → main 7 commits fork 9 threads · 9 unresolved
README.md +1 −1
Diff not shown — a line 2,315 characters wide (limit 2,000). +1 −1
View filesrc/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( | ||
| 94 | 94 | "kind" | "state" | "createdAt" | "startedAt" | "finishedAt" | "summary" | "checkDetail" |
| 95 | 95 | >, |
| 96 | 96 | now = Date.now(), |
| 97 | /** `progressWords` of the run's log, when it has any. */ | |
| 98 | progress = "", | |
| 97 | 99 | ): string | undefined { |
| 98 | 100 | if (run.kind !== "check") return undefined; |
| 99 | 101 | const began = Date.parse(run.startedAt ?? run.createdAt); |
| 100 | 102 | if (run.state === "running") |
| 101 | return `Check running · ${spanWords(now - began)}`; | |
| 103 | return `Check running · ${spanWords(now - began)}${progress === "" ? "" : ` · ${progress}`}`; | |
| 102 | 104 | if (run.state === "ok") |
| 103 | 105 | return run.finishedAt === null |
| 104 | 106 | ? "Check passed" |
src/http/server.ts +2
| @@ -318,6 +318,7 @@ import { variantsPage, variantVoteSubmit } from "../web/pages/variants.ts"; | ||
| 318 | 318 | import { |
| 319 | 319 | pullCommentSubmit, |
| 320 | 320 | pullFilesPage, |
| 321 | pullChecksFragment, | |
| 321 | 322 | pullMergeSubmit, |
| 322 | 323 | pullChecksSubmit, |
| 323 | 324 | pullReviewStateSubmit, |
| @@ -1085,6 +1086,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1085 | 1086 | router.add("POST", "/:owner/:name/pulls/:number/resolve/propose", proposeSubmit); |
| 1086 | 1087 | router.add("GET", "/:owner/:name/pulls/:number/thread", pullThreadFragment); |
| 1087 | 1088 | router.add("GET", "/:owner/:name/pulls/:number/state", pullStateFragment); |
| 1089 | router.add("GET", "/:owner/:name/pulls/:number/checks-line", pullChecksFragment); | |
| 1088 | 1090 | router.add("POST", "/:owner/:name/pulls/:number/comments", pullCommentSubmit); |
| 1089 | 1091 | router.add("POST", "/:owner/:name/pulls/:number/review", pullReviewSubmit); |
| 1090 | 1092 | // 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"; | ||
| 49 | 49 | import { getAppByRepo } from "../../core/apps.ts"; |
| 50 | 50 | import { getIssue, type Issue } from "../../core/issues.ts"; |
| 51 | 51 | import { isProposed, pullMayRun } from "../../core/pull_trust.ts"; |
| 52 | import { runningProgress } from "../../checks/progress.ts"; | |
| 52 | 53 | import { askCheck } from "../../checks/again.ts"; |
| 53 | 54 | import { checkStale, checksHold } from "../../checks/stale.ts"; |
| 54 | 55 | import { checkWords } from "../../checks/summary.ts"; |
| @@ -192,6 +193,9 @@ const VERDICT_WORDS: Record<ReviewFact["state"], string> = { | ||
| 192 | 193 | commented: "commented", |
| 193 | 194 | }; |
| 194 | 195 | |
| 196 | /** How often a pull's screen re-reads while its check is queued or running. */ | |
| 197 | const CHECK_TICK_MS = 5000; | |
| 198 | ||
| 195 | 199 | export function pullScreen( |
| 196 | 200 | f: Session, |
| 197 | 201 | ctx: Ctx, |
| @@ -232,6 +236,7 @@ export function pullScreen( | ||
| 232 | 236 | let check: CheckSummary = NO_CHECK; |
| 233 | 237 | /** The pull's base branch, to name what the check was merged with. */ |
| 234 | 238 | let checkBase = ""; |
| 239 | let checkProgress = ""; | |
| 235 | 240 | /** The check's green was against a base that has since moved. */ |
| 236 | 241 | let checkOld = false; |
| 237 | 242 | /** Agent runs about this pull, newest first. */ |
| @@ -282,6 +287,7 @@ export function pullScreen( | ||
| 282 | 287 | ? NO_CHECK |
| 283 | 288 | : checkSummary(f.db, row.id, row.headOid || (facts?.headOid ?? "")); |
| 284 | 289 | checkBase = row?.baseBranch ?? facts?.baseBranch ?? ""; |
| 290 | checkProgress = runningProgress(f.app.config.dataDir, check); | |
| 285 | 291 | checkOld = |
| 286 | 292 | repo !== undefined && |
| 287 | 293 | row !== undefined && |
| @@ -767,6 +773,13 @@ export function pullScreen( | ||
| 767 | 773 | load(signal) { |
| 768 | 774 | if (repo !== undefined) |
| 769 | 775 | 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)); | |
| 770 | 783 | return reload(); |
| 771 | 784 | }, |
| 772 | 785 | render(area, s) { |
| @@ -777,7 +790,7 @@ export function pullScreen( | ||
| 777 | 790 | // The box is exactly as tall as what is in it: the state, then a row |
| 778 | 791 | // for the verdicts when there are any, then a row for the checks when |
| 779 | 792 | // 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); | |
| 781 | 794 | const boxH = |
| 782 | 795 | 3 + |
| 783 | 796 | (standingText(standing(), requireReview) === "" ? 0 : 1) + |
| @@ -1236,6 +1249,8 @@ export function checksText( | ||
| 1236 | 1249 | isApp: boolean, |
| 1237 | 1250 | baseBranch = "", |
| 1238 | 1251 | stale = false, |
| 1252 | /** `runningProgress` of the check's log; empty when none is known. */ | |
| 1253 | progress = "", | |
| 1239 | 1254 | ): string { |
| 1240 | 1255 | if (check.state === "none") |
| 1241 | 1256 | return required |
| @@ -1252,7 +1267,7 @@ export function checksText( | ||
| 1252 | 1267 | run === null || run.summary === "" ? "" : ` · ${run.summary}` |
| 1253 | 1268 | }${against}`; |
| 1254 | 1269 | 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); | |
| 1256 | 1271 | const ofRun = `${run === null ? "" : ` · run ${run.id}`}${against}`; |
| 1257 | 1272 | switch (check.state) { |
| 1258 | 1273 | case "ok": |
src/web/client.ts +16 −4
| @@ -1938,6 +1938,8 @@ export const liveRegionsJs = /* js */ ` | ||
| 1938 | 1938 | mine.innerHTML = theirs.innerHTML; |
| 1939 | 1939 | opened.forEach(function (i) { var d = document.getElementById(i); if (d) d.open = true; }); |
| 1940 | 1940 | 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); | |
| 1941 | 1943 | mine.dispatchEvent(new CustomEvent('forge:swapped', { bubbles: true })); |
| 1942 | 1944 | }); |
| 1943 | 1945 | } |
| @@ -2041,11 +2043,21 @@ export const liveRegionsJs = /* js */ ` | ||
| 2041 | 2043 | } |
| 2042 | 2044 | // A region with data-live-every="<seconds>" is also due on that beat, for |
| 2043 | 2045 | // 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) { | |
| 2045 | 2050 | 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); | |
| 2049 | 2061 | connect(); |
| 2050 | 2062 | // Back online: reconnect from the last id and look at every region once, |
| 2051 | 2063 | // 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"; | ||
| 28 | 28 | import { |
| 29 | 29 | pullDiff, |
| 30 | 30 | pullDir, |
| 31 | pullStatus, | |
| 31 | 32 | type MergePullError, |
| 32 | 33 | type Pull, |
| 33 | 34 | type PullReviewStatus, |
| @@ -35,6 +36,7 @@ import { | ||
| 35 | 36 | } from "../../core/pulls.ts"; |
| 36 | 37 | import { isProposed, pullMayRun } from "../../core/pull_trust.ts"; |
| 37 | 38 | import { checkWords } from "../../checks/summary.ts"; |
| 39 | import { runningProgress } from "../../checks/progress.ts"; | |
| 38 | 40 | import { checkStale, checksHold } from "../../checks/stale.ts"; |
| 39 | 41 | import { checkSummary, type CheckSummary } from "../../core/runs.ts"; |
| 40 | 42 | import { |
| @@ -70,7 +72,8 @@ import { | ||
| 70 | 72 | } from "../diff-view.ts"; |
| 71 | 73 | import { field, readForm } from "../form.ts"; |
| 72 | 74 | 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"; | |
| 74 | 77 | import { liveClientJs, liveMount } from "../live.ts"; |
| 75 | 78 | import { |
| 76 | 79 | avatar, |
| @@ -220,6 +223,10 @@ export function checksLine( | ||
| 220 | 223 | baseBranch = "", |
| 221 | 224 | /** The green was checked against a base that has since moved. */ |
| 222 | 225 | 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 = "", | |
| 223 | 230 | ): Html { |
| 224 | 231 | if (!isApp && !required && check.state === "none" && !waiting && again === "") |
| 225 | 232 | return html``; |
| @@ -231,7 +238,7 @@ export function checksLine( | ||
| 231 | 238 | ><a href="${base}/runs/${run.id}" |
| 232 | 239 | >${run.kind === "check" ? "Check of" : "deploy"} ${run.commitOid.slice(0, 7)}</a |
| 233 | 240 | >`; |
| 234 | const words = run === null ? undefined : checkWords(run); | |
| 241 | const words = run === null ? undefined : checkWords(run, Date.now(), progress); | |
| 235 | 242 | const against = |
| 236 | 243 | run !== null && run.baseOid !== "" && baseBranch !== "" |
| 237 | 244 | ? html`<span class="where-sep" aria-hidden="true">·</span |
| @@ -265,7 +272,16 @@ export function checksLine( | ||
| 265 | 272 | : waiting |
| 266 | 273 | ? html`<span>${icon("lock")}${WAITING_WORDS}</span>` |
| 267 | 274 | : 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. |
||
| 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 Medium. This region has no |
||
| 282 | check.state === "running" || check.state === "queued" ? html` data-live-every="5"` : null | |
This beat is set once at load and never stops ( |
||
| 283 | }`; | |
| 284 | return html`<p class="checks-line" ${live}> | |
| 269 | 285 | ${body}${against}${when( |
| 270 | 286 | tier !== "", |
| 271 | 287 | () => |
| @@ -315,6 +331,43 @@ async function tierOf( | ||
| 315 | 331 | |
| 316 | 332 | // ---- the merge box ---- |
| 317 | 333 | |
| 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 | ||
| 318 | 371 | /** |
| 319 | 372 | * Six states, six cards. Every one names the situation, gives the reason, |
| 320 | 373 | * and offers exactly the actions that are actually available. |
| @@ -334,22 +387,7 @@ function mergeBox( | ||
| 334 | 387 | const thread = `${repoBase(repo)}/pulls/${pull.number}`; |
| 335 | 388 | // A reader's head runs nothing until a writer approves it; say so rather |
| 336 | 389 | // 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); | |
| 353 | 391 | const reopen = html`<form method="post" action="${thread}/state"> |
| 354 | 392 | <input type="hidden" name="state" value="open" /> |
| 355 | 393 | ${btn("Reopen", { size: "sm", icon: "pull" })} |
| @@ -876,7 +914,7 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> { | ||
| 876 | 914 | palette: repoPalette(ctx, repo), |
| 877 | 915 | body, |
| 878 | 916 | styleCss: labelCss([...allLabels, ...applied]), |
| 879 | scriptJs: liveClientJs, | |
| 917 | scriptJs: `${liveClientJs}\n${liveRegionsJs}`, | |
| 880 | 918 | }); |
| 881 | 919 | } |
| 882 | 920 | |
| @@ -1229,3 +1267,65 @@ export async function pullMergeSubmit(ctx: Ctx): Promise<Response> { | ||
| 1229 | 1267 | } |
| 1230 | 1268 | return flashTo(ctx, dest, { message: "Pull request merged." }); |
| 1231 | 1269 | } |
| 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 |
||
| 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 { | ||
| 63 | 63 | } from "../ui.ts"; |
| 64 | 64 | import { previewCard } from "../pages/apps.ts"; |
| 65 | 65 | 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"; | |
| 67 | 68 | import { pullState } from "../pages/pulls.ts"; |
| 68 | 69 | import { workSpent } from "../pages/runs.ts"; |
| 69 | 70 | import { |
| @@ -418,6 +419,12 @@ async function changeConversation( | ||
| 418 | 419 | ): Promise<Response> { |
| 419 | 420 | const { record, db, base, thread, wordCtx, word, status, conflicted, note, checkLine, preview } = |
| 420 | 421 | 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); | |
| 421 | 428 | // A change that loosens the policy is a person's call: said here, above |
| 422 | 429 | // the decision, as well as on the Files step beside the merge box. |
| 423 | 430 | const wall = |
| @@ -578,7 +585,8 @@ async function changeConversation( | ||
| 578 | 585 | > |
| 579 | 586 | </p>`, |
| 580 | 587 | )} |
| 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 |
||
| 582 | 590 | ${approveHereCard(ctx, repo, pull, headOid, `${thread}/approve-here`)} |
| 583 | 591 | ${when(wall, (w) => wallCard(w))} ${decisionSection} |
| 584 | 592 | ${takeBackSection} |
| @@ -623,6 +631,6 @@ async function changeConversation( | ||
| 623 | 631 | crumbs: crumbsFor(repo), |
| 624 | 632 | palette: repoPalette(ctx, repo), |
| 625 | 633 | body, |
| 626 | scriptJs: liveClientJs, | |
| 634 | scriptJs: `${liveClientJs}\n${liveRegionsJs}`, | |
| 627 | 635 | }); |
| 628 | 636 | } |
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", () => { | ||
| 170 | 170 | expect(liveRegionsJs).toContain("data-busy"); |
| 171 | 171 | }); |
| 172 | 172 | }); |
| 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 |
||
| 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 |
||
| 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); |
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.tsthat runs this script against a fake DOM (swap withoutdata-live-every→clearIntervalcalled; swap with it →setIntervalcalled once, not twice).