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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2420-a-check-s-run-page-shows-install-typeche → main 3 commits fork 5 threads · 5 unresolved
README.md +2
Diff not shown — a line 2,315 characters wide (limit 2,000). +2
View filesrc/checks/progress.ts +18
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { CheckSummary } from "../core/runs.ts"; |
| 11 | import { checkSteps, stepsWords } from "./summary.ts"; | |
| 11 | 12 | import { readRunLog } from "../runs/log.ts"; |
| 12 | 13 | |
| 13 | 14 | export type CheckProgress = { |
| @@ -90,3 +91,20 @@ export function runningProgress(dataDir: string, check: CheckSummary): string { | ||
| 90 | 91 | return ""; |
| 91 | 92 | return progressWords(parseShardProgress(readRunLog(dataDir, run.id).lines)); |
| 92 | 93 | } |
| 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( | ||
| 111 | 111 | } |
| 112 | 112 | return undefined; |
| 113 | 113 | } |
| 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. */ | |
| 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 { | ||
| 39 | 39 | submitReview, |
| 40 | 40 | } from "../../actions/pulls.ts"; |
| 41 | 41 | import { openEnvUrl } from "../../actions/apps.ts"; |
| 42 | import { listRepoRuns } from "../../actions/runs.ts"; | |
| 42 | import { listRepoRuns, mayReadRunLog } from "../../actions/runs.ts"; | |
| 43 | 43 | import { previewEnv } from "../../apps/data.ts"; |
| 44 | 44 | import { |
| 45 | 45 | appsRuntime, |
| @@ -49,7 +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 | import { runningProgress, stepsLine } from "../../checks/progress.ts"; | |
| 53 | 53 | import { askCheck } from "../../checks/again.ts"; |
| 54 | 54 | import { checkStale, checksHold } from "../../checks/stale.ts"; |
| 55 | 55 | import { checkWords } from "../../checks/summary.ts"; |
| @@ -237,6 +237,8 @@ export function pullScreen( | ||
| 237 | 237 | /** The pull's base branch, to name what the check was merged with. */ |
| 238 | 238 | let checkBase = ""; |
| 239 | 239 | let checkProgress = ""; |
| 240 | /** The run page's three step rows as a line; empty when the log is hidden. */ | |
| 241 | let checkSteps = ""; | |
| 240 | 242 | /** The check's green was against a base that has since moved. */ |
| 241 | 243 | let checkOld = false; |
| 242 | 244 | /** Agent runs about this pull, newest first. */ |
| @@ -288,6 +290,14 @@ export function pullScreen( | ||
| 288 | 290 | : checkSummary(f.db, row.id, row.headOid || (facts?.headOid ?? "")); |
| 289 | 291 | checkBase = row?.baseBranch ?? facts?.baseBranch ?? ""; |
| 290 | 292 | 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 | ); | |
| 291 | 301 | checkOld = |
| 292 | 302 | repo !== undefined && |
| 293 | 303 | row !== undefined && |
| @@ -790,7 +800,7 @@ export function pullScreen( | ||
| 790 | 800 | // The box is exactly as tall as what is in it: the state, then a row |
| 791 | 801 | // for the verdicts when there are any, then a row for the checks when |
| 792 | 802 | // 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); | |
| 794 | 804 | const boxH = |
| 795 | 805 | 3 + |
| 796 | 806 | (standingText(standing(), requireReview) === "" ? 0 : 1) + |
| @@ -1251,6 +1261,20 @@ export function checksText( | ||
| 1251 | 1261 | stale = false, |
| 1252 | 1262 | /** `runningProgress` of the check's log; empty when none is known. */ |
| 1253 | 1263 | 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, | |
| 1254 | 1278 | ): string { |
| 1255 | 1279 | if (check.state === "none") |
| 1256 | 1280 | return required |
src/web/client.ts +24
| @@ -295,6 +295,27 @@ export const studioRunJs = /* js */ ` | ||
| 295 | 295 | |
| 296 | 296 | ${elapsedTickJs} |
| 297 | 297 | |
| 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 | ||
| 298 | 319 | if (steer && window.fetch) { |
| 299 | 320 | var text = steer.querySelector('textarea'); |
| 300 | 321 | 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 */ ` | ||
| 341 | 362 | var text = event.data; |
| 342 | 363 | try { var parsed = JSON.parse(text); if (typeof parsed === 'string') text = parsed; } catch (err) {} |
| 343 | 364 | add(draw(text)); |
| 365 | if (/^(\\$ |test-shards: |\u2500\u2500 shard )/.test(text)) stepsSoon(); | |
| 344 | 366 | }); |
| 345 | 367 | source.addEventListener('done', function (event) { |
| 346 | 368 | source.close(); |
| 347 | 369 | var run; |
| 348 | 370 | try { run = JSON.parse(event.data); } catch (err) { return; } |
| 349 | 371 | paint(run); |
| 372 | if (stepsTimer) { window.clearTimeout(stepsTimer); stepsTimer = 0; } | |
| 373 | steps(); | |
| 350 | 374 | if (steer) { |
| 351 | 375 | steer.setAttribute('data-running', '0'); |
| 352 | 376 | var n = document.getElementById('run-steer-note'); |
src/web/studio/run.ts +15
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * at `?all=1`. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { checkSteps, stepWord } from "../../checks/summary.ts"; | |
| 11 | 12 | import { directionNote } from "../../crew/direct.ts"; |
| 12 | 13 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 13 | 14 | import { getAppByRepo } from "../../core/apps.ts"; |
| @@ -244,6 +245,20 @@ export async function runView( | ||
| 244 | 245 | Nothing has been logged yet. |
| 245 | 246 | </p>`, |
| 246 | 247 | )} |
| 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 | )} | |
| 247 | 262 | <div class="run-live" id="run-live" data-state="${run.state}"> |
| 248 | 263 | ${when(!logHidden, () => logBox({ |
| 249 | 264 | id: "run-log", |
src/web/style/content.ts +7
| @@ -865,6 +865,13 @@ form.inline { display: inline; margin-left: auto; } | ||
| 865 | 865 | .checks-line .ok { display: inline-flex; align-items: center; gap: 4px; color: var(--success); } |
| 866 | 866 | .checks-line .bad { display: inline-flex; align-items: center; gap: 4px; color: var(--danger); } |
| 867 | 867 | .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); } | |
| 868 | 875 | /* A run, live: a transcript with a speaker column, bounded so the page |
| 869 | 876 | head stays reachable, and a composer that sits under it. */ |
| 870 | 877 | .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", () => { | ||
| 657 | 657 | // Sub-second, in tenths — not rounded away to "took 0s". |
| 658 | 658 | expect(page).toContain(">took 0.3s<"); |
| 659 | 659 | }); |
| 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 | }); | |
| 660 | 763 | }); |
| 661 | 764 | |
| 662 | 765 | /** |
\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.