Public: anyone can read this; the crew can change it.
A finished check shard is announced on /events, and the pull page's checks line stops beating every 5 seconds #2893
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2889-a-finished-check-shard-is-announced-on-e → main 3 commits fork 4 threads · 4 unresolved
21 files
changed +157
−35
src/checks/service.ts +4 −1
| @@ -17,6 +17,7 @@ import { tmpdir } from "node:os"; | ||
| 17 | 17 | import { join } from "node:path"; |
| 18 | 18 | import type { App } from "../app.ts"; |
| 19 | 19 | import { announceRun } from "../actions/runs.ts"; |
| 20 | import { announceShard } from "./shard_event.ts"; | |
| 20 | 21 | import type { ForgeEvent } from "../core/events.ts"; |
| 21 | 22 | import { getPull, listPulls, type Pull } from "../core/pulls.ts"; |
| 22 | 23 | import { issueMayRun } from "../core/pull_trust.ts"; |
| @@ -476,8 +477,10 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 476 | 477 | if (!claimed) return; |
| 477 | 478 | const lease = holdLease(app.db, run.id); |
| 478 | 479 | announceRun(app, claimed); |
| 479 | const write = (line: string): void => | |
| 480 | const write = (line: string): void => { | |
| 480 | 481 | appendRunLog(app.config.dataDir, run.id, line); |
| 482 | announceShard(app, run, line); | |
| 483 | }; | |
| 481 | 484 | if (options.label !== undefined) |
| 482 | 485 | write(`forge ${options.label} — checked in its parent's lane`); |
| 483 | 486 | const started = Date.now(); |
src/checks/shard_event.ts added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | /** | |
| 2 | * A check's shard finishing, said on the outbox. The shard runner | |
| 3 | * (`scripts/test-shards.ts`) writes a line when a shard ends; this reads that | |
| 4 | * line and announces it as `run.shard`, so a pull's checks line, which listens | |
| 5 | * to `run.*`, patches itself instead of beating on a timer. One line, one | |
| 6 | * event: the live form ends at `done`, the batched form at its `(exit` header, | |
| 7 | * and neither line is the other's. | |
| 8 | */ | |
| 9 | import type { App } from "../app.ts"; | |
| 10 | import type { Run } from "../core/runs.ts"; | |
| 11 | ||
| 12 | const LIVE_DONE = /^── shard (\d+)\/(\d+) done \(exit \d+, [0-9.]+s\)$/; | |
These regexes are coupled to the runner output wording. With the beat removed, a format drift fails silently: the line stops updating until the run finishes. Add a test that feeds real test-shards.ts output through finishedShard. |
||
| 13 | const BATCHED = /^── shard (\d+)\/(\d+) \(exit \d+, [0-9.]+s\): /; | |
| 14 | ||
| 15 | /** Which shard a log line says has finished, or nothing. */ | |
| 16 | export function finishedShard(line: string): { shard: number; of: number } | undefined { | |
| 17 | const m = LIVE_DONE.exec(line) ?? BATCHED.exec(line); | |
| 18 | return m === null ? undefined : { shard: Number(m[1]), of: Number(m[2]) }; | |
| 19 | } | |
| 20 | ||
| 21 | /** Announce the shard `line` finishes, if it finishes one. */ | |
| 22 | export function announceShard(app: App, run: Run, line: string): void { | |
| 23 | const done = finishedShard(line); | |
| 24 | if (done === undefined) return; | |
| 25 | app.bus.append({ | |
| 26 | kind: "run.shard", | |
| 27 | repoId: run.repoId, | |
| 28 | payload: { | |
| 29 | runId: run.id, | |
| 30 | kind: run.kind, | |
| 31 | env: run.env, | |
| 32 | commitOid: run.commitOid, | |
| 33 | shard: done.shard, | |
| 34 | of: done.of, | |
| 35 | }, | |
| 36 | }); | |
| 37 | } |
src/web/pages/board.ts +6 −6
| @@ -338,7 +338,7 @@ function forgeStrip(ctx: Ctx): Html | null { | ||
| 338 | 338 | return html`<div |
| 339 | 339 | class="board-forge" |
| 340 | 340 | id="forge" |
| 341 | data-live="run.*,app.*" | |
| 341 | data-live="run.queued,run.started,run.finished,app.*" | |
| 342 | 342 | data-ok="${ok ? "yes" : "no"}" |
| 343 | 343 | data-rollout="${fh.rollout.state}" |
| 344 | 344 | role="status" |
| @@ -491,7 +491,7 @@ function declinedStrip(ctx: Ctx, scope: BoardScope): Html | null { | ||
| 491 | 491 | return html`<div |
| 492 | 492 | class="board-declined" |
| 493 | 493 | id="declined" |
| 494 | data-live="run.*,issue.*,pull.*" | |
| 494 | data-live="run.queued,run.started,run.finished,issue.*,pull.*" | |
| 495 | 495 | role="status" |
| 496 | 496 | > |
| 497 | 497 | ${declines.map((d) => { |
| @@ -611,7 +611,7 @@ function windowsStrip(ctx: Ctx): Html | null { | ||
| 611 | 611 | return html`<div |
| 612 | 612 | class="board-windows" |
| 613 | 613 | id="windows" |
| 614 | data-live="run.*,usage.*" | |
| 614 | data-live="run.queued,run.started,run.finished,usage.*" | |
| 615 | 615 | aria-label="Subscription windows" |
| 616 | 616 | > |
| 617 | 617 | <div class="board-subs" data-group="crew"> |
| @@ -700,7 +700,7 @@ function working(ctx: Ctx, scope: BoardScope): Html { | ||
| 700 | 700 | "working", |
| 701 | 701 | "1", |
| 702 | 702 | "Working now", |
| 703 | "run.*", | |
| 703 | "run.queued,run.started,run.finished", | |
| 704 | 704 | rows, |
| 705 | 705 | scope.repos.length === 0 |
| 706 | 706 | ? "No apps to watch." |
| @@ -921,7 +921,7 @@ function decided(ctx: Ctx, scope: BoardScope): Html { | ||
| 921 | 921 | "decided", |
| 922 | 922 | "3", |
| 923 | 923 | "Decided", |
| 924 | "run.*,pull.*", | |
| 924 | "run.queued,run.started,run.finished,pull.*", | |
| 925 | 925 | rows, |
| 926 | 926 | pile === null ? "Nothing decided yet." : PILE_EMPTY[pile], |
| 927 | 927 | pile === null |
| @@ -1041,7 +1041,7 @@ function today(ctx: Ctx, scope: BoardScope): Html { | ||
| 1041 | 1041 | "today", |
| 1042 | 1042 | "4", |
| 1043 | 1043 | "Today", |
| 1044 | "run.*,pull.merged", | |
| 1044 | "run.queued,run.started,run.finished,pull.merged", | |
| 1045 | 1045 | rows, |
| 1046 | 1046 | scope.repos.length === 0 |
| 1047 | 1047 | ? "No apps to watch." |
src/web/pages/boards-view.ts +1 −1
| @@ -23,7 +23,7 @@ import { cellsOf, chartBox, paramsOf, queryFields, readParams, runFor } from "./ | ||
| 23 | 23 | |
| 24 | 24 | const RANGE_MS = { "1h": 3_600_000, "24h": 86_400_000, "7d": 604_800_000, "30d": 2_592_000_000 } as const; |
| 25 | 25 | /** The events the panels' queries read (runs, pulls, checks, issues, usage): any of them may change a chart. */ |
| 26 | const READS = "run.*,pull.*,check.*,issue.*,usage.*"; | |
| 26 | const READS = "run.queued,run.started,run.finished,pull.*,check.*,issue.*,usage.*"; | |
| 27 | 27 | |
| 28 | 28 | const option = (value: string, selected: boolean) => |
| 29 | 29 | html`<option value="${value}" ${selected ? raw("selected") : null}>${value}</option>`; |
src/web/pages/conflicts.ts +1 −1
| @@ -124,7 +124,7 @@ async function proposalCard( | ||
| 124 | 124 | </form>`); |
| 125 | 125 | if (parts.length === 0) return html``; |
| 126 | 126 | // A live region: the run's events re-read the card, so nobody reloads. |
| 127 | return html`<div id="crew-proposal" data-live="run.*,pull.*,comment.*">${card(html`${parts}`, { head: html`<h2>The crew</h2>` })}</div>`; | |
| 127 | return html`<div id="crew-proposal" data-live="run.queued,run.started,run.finished,pull.*,comment.*">${card(html`${parts}`, { head: html`<h2>The crew</h2>` })}</div>`; | |
| 128 | 128 | } |
| 129 | 129 | |
| 130 | 130 | const CSS = ` |
src/web/pages/map.ts +1 −1
| @@ -1301,7 +1301,7 @@ export const mapViewJs = /* js */ ` | ||
| 1301 | 1301 | })(); |
| 1302 | 1302 | `; |
| 1303 | 1303 | |
| 1304 | const LIVE_KINDS = "run.*,issue.*,pull.*"; | |
| 1304 | const LIVE_KINDS = "run.queued,run.started,run.finished,issue.*,pull.*"; | |
The same run.queued,run.started,run.finished string is repeated in about nine places. Export one constant and use it, so the next run.* kind does not need nine edits. |
||
| 1305 | 1305 | |
| 1306 | 1306 | /** The empty state in the studio's own words, pointing at the app page's |
| 1307 | 1307 | * own box for telling the crew (`#tell`, `src/web/studio/app.ts`). */ |
src/web/pages/pull.ts +3 −6
| @@ -272,15 +272,12 @@ export function checksLine( | ||
| 272 | 272 | : waiting |
| 273 | 273 | ? html`<span>${icon("lock")}${WAITING_WORDS}</span>` |
| 274 | 274 | : html`<span>${icon("clock")}No checks on this head yet</span>`; |
| 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. | |
| 275 | // Its own address: an event fetches this line, not the page. A finished | |
| 276 | // shard is a `run.shard` event (`checks/shard_event.ts`), so no beat. | |
| 278 | 277 | const live = |
| 279 | 278 | liveSrc === "" |
| 280 | 279 | ? null |
| 281 | : html`id="live-checks" data-live="run.*" data-live-src="${liveSrc}"${ | |
| 282 | check.state === "running" || check.state === "queued" ? html` data-live-every="5"` : null | |
| 283 | }`; | |
| 280 | : html`id="live-checks" data-live="run.*" data-live-src="${liveSrc}"`; | |
| 284 | 281 | return html`<p class="checks-line" ${live}> |
| 285 | 282 | ${body}${against}${when( |
| 286 | 283 | tier !== "", |
src/web/pages/spend-tab.ts +1 −1
| @@ -16,7 +16,7 @@ import { usageShell } from "./usage-tabs.ts"; | ||
| 16 | 16 | export function spendPage(ctx: Ctx): Response { |
| 17 | 17 | if (!ctx.auth.user) return signInFirst(ctx); |
| 18 | 18 | if (!ctx.auth.user.isAdmin) return notFoundPage(ctx); |
| 19 | const body = html`<div id="spend" data-live="run.*,usage.*">${card( | |
| 19 | const body = html`<div id="spend" data-live="run.queued,run.started,run.finished,usage.*">${card( | |
| 20 | 20 | spendView(readSpend(ctx.app, new Date())), |
| 21 | 21 | { head: html`${icon("clock")}<h2>Spend</h2>` }, |
| 22 | 22 | )}</div>`; |
src/web/studio/answers.ts +1 −1
| @@ -22,7 +22,7 @@ import { time } from "../ui.ts"; | ||
| 22 | 22 | import { tellHref } from "./nouns.ts"; |
| 23 | 23 | |
| 24 | 24 | /** The events that can change an answer. */ |
| 25 | export const ANSWERS_LIVE = "run.*,issue.*"; | |
| 25 | export const ANSWERS_LIVE = "run.queued,run.started,run.finished,issue.*"; | |
| 26 | 26 | |
| 27 | 27 | /** How many tells the region shows, and the longest answer it quotes. */ |
| 28 | 28 | const SHOWN = 8; |
src/web/studio/now.ts +1 −1
| @@ -40,7 +40,7 @@ import { | ||
| 40 | 40 | } from "./words.ts"; |
| 41 | 41 | |
| 42 | 42 | /** The events that can change what Now says. */ |
| 43 | export const NOW_LIVE = "run.*,pull.*,issue.*"; | |
| 43 | export const NOW_LIVE = "run.queued,run.started,run.finished,pull.*,issue.*"; | |
| 44 | 44 | |
| 45 | 45 | /** |
| 46 | 46 | * The card in its live region: the kinds it listens for, the repository |
test/board.test.ts +5 −5
| @@ -136,12 +136,12 @@ describe("the board", () => { | ||
| 136 | 136 | expect(html).not.toContain('class="topbar"'); |
| 137 | 137 | expect(html).not.toContain('class="foot"'); |
| 138 | 138 | expect(html).toMatch(/<main\s+class="bare"\s+id="main"\s*>/); |
| 139 | expect(html).toMatch(/id="working"\s+data-live="run\.\*"/); | |
| 139 | expect(html).toMatch(/id="working"\s+data-live="run\.queued,run\.started,run\.finished"/); | |
| 140 | 140 | expect(html).toMatch( |
| 141 | 141 | /id="landed"\s+data-live="pull\.merged,run\.finished"/, |
| 142 | 142 | ); |
| 143 | expect(html).toMatch(/id="decided"\s+data-live="run\.\*,pull\.\*"/); | |
| 144 | expect(html).toMatch(/id="today"\s+data-live="run\.\*,pull\.merged"/); | |
| 143 | expect(html).toMatch(/id="decided"\s+data-live="run\.queued,run\.started,run\.finished,pull\.\*"/); | |
| 144 | expect(html).toMatch(/id="today"\s+data-live="run\.queued,run\.started,run\.finished,pull\.merged"/); | |
| 145 | 145 | expect(html).toContain("Nobody is working right now."); |
| 146 | 146 | expect(html).toContain("Nothing has shipped yet."); |
| 147 | 147 | expect(html).toContain("Nothing decided yet."); |
| @@ -348,7 +348,7 @@ describe("the board", () => { | ||
| 348 | 348 | expect(res.status).toBe(200); |
| 349 | 349 | const html = await res.text(); |
| 350 | 350 | expect(html).toMatch(/<div class="board"\s+data-wall="">/); |
| 351 | expect(html).toMatch(/id="working"\s+data-live="run\.\*"/); | |
| 351 | expect(html).toMatch(/id="working"\s+data-live="run\.queued,run\.started,run\.finished"/); | |
| 352 | 352 | expect(html).toMatch(/class="board-scope"\s*>notes</); |
| 353 | 353 | }); |
| 354 | 354 | |
| @@ -449,7 +449,7 @@ describe("the board's forge strip", () => { | ||
| 449 | 449 | |
| 450 | 450 | test("an admin reads All fine; a stuck run becomes a linked sentence; a non-admin sees nothing", async () => { |
| 451 | 451 | const fine = await board("root"); |
| 452 | expect(fine).toMatch(/id="forge"\s+data-live="run\.\*,app\.\*"/); | |
| 452 | expect(fine).toMatch(/id="forge"\s+data-live="run\.queued,run\.started,run\.finished,app\.\*"/); | |
| 453 | 453 | expect(fine).toContain("All fine"); |
| 454 | 454 | |
| 455 | 455 | const made = await createRepo(app, admin, { name: "stuck" }); |
test/boards-store.test.ts +1 −1
| @@ -175,7 +175,7 @@ describe("/boards", () => { | ||
| 175 | 175 | const grid = body.match(/<section[^>]*id="board-grid"[^>]*>/)?.[0] ?? ""; |
| 176 | 176 | expect(grid).not.toContain("data-live-every"); |
| 177 | 177 | expect(grid).toMatch(/data-live="[^"]+"/); |
| 178 | expect(grid).toContain('data-live="run.*,pull.*,check.*,issue.*,usage.*"'); | |
| 178 | expect(grid).toContain('data-live="run.queued,run.started,run.finished,pull.*,check.*,issue.*,usage.*"'); | |
| 179 | 179 | expect(body).toContain('data-live-src="/boards/fleet-health/live"'); |
| 180 | 180 | expect(body).toMatch(/<option value="10s" selected>on: redraw when events arrive<\/option>/); |
| 181 | 181 | expect(body).not.toContain(">1m</option>"); |
test/check-shard-event.test.ts added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { announceShard, finishedShard } from "../src/checks/shard_event.ts"; | |
| 3 | import { checksLine } from "../src/web/pages/pull.ts"; | |
| 4 | ||
| 5 | const run = { id: "r1", repoId: "repo1", kind: "check", env: "pull-7", commitOid: "abc" } as never; | |
| 6 | ||
| 7 | function fakeApp() { | |
| 8 | const events: { kind: string; repoId: string; payload: Record<string, unknown> }[] = []; | |
| 9 | return { events, app: { bus: { append: (e: never) => events.push(e) } } as never }; | |
| 10 | } | |
| 11 | ||
| 12 | test("a finished shard, live or batched, is one line and one event", () => { | |
| 13 | expect(finishedShard("── shard 2/4 done (exit 0, 3.0s)")).toEqual({ shard: 2, of: 4 }); | |
| 14 | expect(finishedShard("── shard 3/4 (exit 1, 2.0s): a.ts b.ts")).toEqual({ shard: 3, of: 4 }); | |
| 15 | expect(finishedShard("── shard 2/4: a.ts b.ts")).toBeUndefined(); | |
| 16 | expect(finishedShard(" 5 pass")).toBeUndefined(); | |
| 17 | ||
| 18 | const { app, events } = fakeApp(); | |
| 19 | for (const line of ["── shard 1/2: a.ts", " 5 pass", "── shard 1/2 done (exit 0, 1.0s)"]) | |
| 20 | announceShard(app, run, line); | |
| 21 | expect(events).toHaveLength(1); | |
| 22 | expect(events[0]!.kind).toBe("run.shard"); | |
| 23 | expect(events[0]!.repoId).toBe("repo1"); | |
| 24 | expect(events[0]!.payload).toEqual({ runId: "r1", kind: "check", env: "pull-7", commitOid: "abc", shard: 1, of: 2 }); | |
| 25 | }); | |
| 26 | ||
| 27 | test("the checks line of a running check listens for run.* and never beats", () => { | |
| 28 | const run = { id: "r1", kind: "check", env: "pull-7", commitOid: "abcdef1234567", state: "running", summary: "", createdAt: "2026-10-16T10:00:00Z" } as never; | |
| 29 | const html = checksLine("/a/b", { state: "running", run }, false, true, false, "", "", 0, "", false, "", "/a/b/pulls/7/checks-line").text; | |
| 30 | expect(html).toContain('data-live="run.*"'); | |
| 31 | expect(html).not.toContain("data-live-every"); | |
| 32 | }); |
test/checks.test.ts +53
| @@ -46,6 +46,7 @@ import { pullWord, servingHistory } from "../src/web/studio/words.ts"; | ||
| 46 | 46 | import { createUser, type User } from "../src/core/users.ts"; |
| 47 | 47 | import type { Repo } from "../src/core/repos.ts"; |
| 48 | 48 | import { serve, type Servers } from "../src/http/server.ts"; |
| 49 | import { listEvents } from "../src/core/events.ts"; | |
| 49 | 50 | import { readRunLog } from "../src/runs/log.ts"; |
| 50 | 51 | import { promote } from "./helpers/members.ts"; |
| 51 | 52 | |
| @@ -1210,3 +1211,55 @@ describe("startChecks: a booting forge removes only checkouts nobody is running | ||
| 1210 | 1211 | } |
| 1211 | 1212 | }); |
| 1212 | 1213 | }); |
| 1214 | ||
| 1215 | describe("checks: a finished shard is announced as the runner writes it (#2889)", () => { | |
| 1216 | test("each shard-done line the check prints puts one run.shard on the bus, and nothing else does", async () => { | |
| 1217 | const dir = await mkdtemp(join(tmpdir(), "forge-shards-")); | |
| 1218 | const shardApp = createApp({ dataDir: dir, port: 0 }); | |
| 1219 | const shardServers = await serve(shardApp); | |
| 1220 | const handle = startChecks(shardApp, { forgeUrl: shardServers.url, concurrency: 1 }); | |
| 1221 | try { | |
| 1222 | const u = await createUser(shardApp.db, { | |
| 1223 | origin: "seed", | |
| 1224 | trusted: true, | |
| 1225 | username: "alice", | |
| 1226 | email: "[email protected]", | |
| 1227 | password: "a-good-passphrase", | |
| 1228 | adminIfFirst: true, | |
| 1229 | }); | |
| 1230 | if (!u.ok) throw new Error(u.error); | |
| 1231 | const made = await createRepo(shardApp, u.value, { name: "lib", visibility: "public" }); | |
| 1232 | if (!made.ok) throw new Error(String(made.error)); | |
| 1233 | const lines = [ | |
| 1234 | "test-shards: 3 files in 2 shards", | |
| 1235 | "── shard 1/2: a.ts b.ts", | |
| 1236 | " 5 pass", | |
| 1237 | "── shard 1/2 done (exit 0, 1.0s)", | |
| 1238 | "── shard 2/2: c.ts", | |
| 1239 | "── shard 2/2 done (exit 0, 1.0s)", | |
| 1240 | ]; | |
| 1241 | const script = lines.map((l) => `echo '${l}'`).join("; "); | |
| 1242 | const pushed = await commitToBranch(shardApp, u.value, made.value, { | |
| 1243 | message: "Declare a check", | |
| 1244 | files: [{ path: "forge.json", content: checkJson(script) }], | |
| 1245 | }); | |
| 1246 | if (!pushed.ok) throw new Error(JSON.stringify(pushed.error)); | |
| 1247 | const deadline = Date.now() + 30_000; | |
| 1248 | let run: Run | undefined; | |
| 1249 | while (Date.now() < deadline) { | |
| 1250 | run = listRuns(shardApp.db, made.value.id, { kind: "check", limit: 5 })[0]; | |
| 1251 | if (run && (run.state === "ok" || run.state === "failed")) break; | |
| 1252 | await Bun.sleep(50); | |
| 1253 | } | |
| 1254 | expect(run?.state).toBe("ok"); | |
| 1255 | const shards = listEvents(shardApp.db, { repoId: made.value.id, kind: "run.shard" }); | |
| 1256 | expect(shards.map((e) => e.payload["shard"]).sort()).toEqual([1, 2]); | |
| 1257 | expect(shards.every((e) => e.payload["runId"] === run!.id && e.payload["of"] === 2)).toBe(true); | |
| 1258 | } finally { | |
| 1259 | await handle.stop(); | |
| 1260 | shardServers.stop(); | |
| 1261 | shardApp.close(); | |
| 1262 | await rm(dir, { recursive: true, force: true }); | |
| 1263 | } | |
| 1264 | }); | |
| 1265 | }); |
test/credentials.test.ts +1 −1
| @@ -324,7 +324,7 @@ describe("the board", () => { | ||
| 324 | 324 | expect(page.slice(crewAt, apartAt)).toContain("platform (env)"); |
| 325 | 325 | expect(page.slice(crewAt, apartAt)).not.toContain("trevato-espo"); |
| 326 | 326 | expect(page.slice(apartAt)).toContain("trevato-espo"); |
| 327 | expect(page).toContain('data-live="run.*,usage.*"'); | |
| 327 | expect(page).toContain('data-live="run.queued,run.started,run.finished,usage.*"'); | |
| 328 | 328 | expect(page).toContain('data-by="byKind"'); |
| 329 | 329 | expect(page).toContain("The 10 costliest runs"); |
| 330 | 330 |
test/crew-proposal.test.ts +1 −1
| @@ -225,7 +225,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Wrote note | ||
| 225 | 225 | ).then((r) => r.text()); |
| 226 | 226 | expect(page).toContain("The crew proposed a resolution"); |
| 227 | 227 | expect(page).toContain("Kept both lines"); |
| 228 | expect(page).toContain('id="crew-proposal" data-live="run.*'); | |
| 228 | expect(page).toContain('id="crew-proposal" data-live="run.queued,run.started,run.finished'); | |
| 229 | 229 | expect(page).toContain(`/edit/`); |
| 230 | 230 | expect(page).toContain(now.headOid.slice(0, 7)); |
| 231 | 231 | }); |
test/map.test.ts +2 −2
| @@ -366,7 +366,7 @@ describe("the map, per app and across the forge", () => { | ||
| 366 | 366 | await fetch(`${base}/alice/engine/map`, { headers: { cookie } }) |
| 367 | 367 | ).text(); |
| 368 | 368 | const region = page.match(/<div[^>]*id="map-live"[^>]*>/)?.[0] ?? ""; |
| 369 | expect(region).toContain('data-live="run.*,issue.*,pull.*"'); | |
| 369 | expect(region).toContain('data-live="run.queued,run.started,run.finished,issue.*,pull.*"'); | |
| 370 | 370 | expect(region).toContain('data-live-repo="alice/engine"'); |
| 371 | 371 | // The canvas carries its size for the fit, and the tools for the rest. |
| 372 | 372 | expect(page).toMatch( |
| @@ -392,7 +392,7 @@ describe("the map, per app and across the forge", () => { | ||
| 392 | 392 | ).text(); |
| 393 | 393 | expect(page).toContain("<h1>Map</h1>"); |
| 394 | 394 | expect(page).toMatch( |
| 395 | /<div[^>]*id="map-lanes"[^>]*data-live="run\.\*,issue\.\*,pull\.\*"/, | |
| 395 | /<div[^>]*id="map-lanes"[^>]*data-live="run\.queued,run\.started,run\.finished,issue\.\*,pull\.\*"/, | |
| 396 | 396 | ); |
| 397 | 397 | const lane = (id: string) => |
| 398 | 398 | page.match(new RegExp(`<details[^>]*id="${id}"[^>]*>`))?.[0] ?? ""; |
test/no-polling.test.ts −1
| @@ -14,7 +14,6 @@ const ALLOWED: Record<string, { allow: string[]; why: string }> = { | ||
| 14 | 14 | "src/web/pages/migrate.ts": { allow: ["refresh: {", 'http-equiv="refresh"'], why: "migrate progress runs in a process that emits no events" }, |
| 15 | 15 | "src/web/pages/browse.ts": { allow: ["refresh: {"], why: "mirror progress runs in a process that emits no events" }, |
| 16 | 16 | "src/web/pages/backups.ts": { allow: ["refresh: {"], why: "a backup moving emits no event" }, |
| 17 | "src/web/pages/pull.ts": { allow: ["data-live-every"], why: "a shard finishing emits no event, so a running check row beats" }, | |
| 18 | 17 | "src/web/client.ts": { allow: ["data-live-every", "setInterval"], why: "the data-live-every mechanism itself, and clocks that only redraw text" }, |
| 19 | 18 | "src/web/fleet-graph.ts": { allow: ["setInterval"], why: "no fleet event yet; remove once it has one" }, |
| 20 | 19 | }; |
test/pull-checks-line-route.test.ts +4 −3
| @@ -110,7 +110,7 @@ test("a viewer who can gets the line, without counts until the log has them", as | ||
| 110 | 110 | const text = await res.text(); |
| 111 | 111 | expect(text).toContain('id="live-checks"'); |
| 112 | 112 | expect(text).toContain("Check running"); |
| 113 | expect(text).toContain('data-live-every="5"'); | |
| 113 | expect(text).not.toContain("data-live-every"); | |
| 114 | 114 | expect(text).not.toContain(" files"); |
| 115 | 115 | }); |
| 116 | 116 | |
| @@ -134,7 +134,8 @@ test("the page that draws the line also ships the script that drives it", async | ||
| 134 | 134 | expect(res.status).toBe(200); |
| 135 | 135 | const text = await res.text(); |
| 136 | 136 | expect(text).toContain('id="live-checks"'); |
| 137 | expect(text).toContain('data-live-every="5"'); | |
| 137 | // The line itself carries no beat attribute; the script still knows the mechanism. | |
| 138 | expect(text).not.toMatch(/id="live-checks"[^>]*data-live-every/); | |
| 138 | 139 | // liveRegionsJs reads data-live-src and arms the beat; liveClientJs alone does not. |
| 139 | 140 | expect(text).toContain("data-live-src"); |
| 140 | 141 | expect(text).toContain("function arm("); |
| @@ -161,7 +162,7 @@ test("the change page the owner lands on draws the same live line, with the coun | ||
| 161 | 162 | expect(res.status).toBe(200); |
| 162 | 163 | const text = await res.text(); |
| 163 | 164 | expect(text).toContain('id="live-checks"'); |
| 164 | expect(text).toContain('data-live-every="5"'); | |
| 165 | expect(text).not.toMatch(/id="live-checks"[^>]*data-live-every/); | |
| 165 | 166 | expect(text).toContain("2 of 170 files · 500 passed, 0 failed"); |
| 166 | 167 | expect(text).toContain("function arm("); |
| 167 | 168 | }); |
test/studio-door.test.ts +1 −1
| @@ -1332,7 +1332,7 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1332 | 1332 | expect(text).toMatch(/^\s*<div data-etag="[0-9a-f]{16}"/); |
| 1333 | 1333 | expect(`"${/data-etag="([0-9a-f]{16})"/.exec(text)![1]}"`).toBe(etag!); |
| 1334 | 1334 | expect(text).toContain('id="live-now"'); |
| 1335 | expect(text).toContain('data-live="run.*,pull.*,issue.*"'); | |
| 1335 | expect(text).toContain('data-live="run.queued,run.started,run.finished,pull.*,issue.*"'); | |
| 1336 | 1336 | expect(text).toContain('data-live-src="/alice/vault/app/now"'); |
| 1337 | 1337 | expect(text).toContain("<h2>Now</h2>"); |
| 1338 | 1338 |
test/studio-runs.test.ts +1 −1
| @@ -1021,7 +1021,7 @@ describe("the app's page tails the crew's log while it works", () => { | ||
| 1021 | 1021 | // for this repository's runs, and the tail rides the one /events stream. |
| 1022 | 1022 | expect(page).not.toMatch(/<meta\s+http-equiv="refresh"/); |
| 1023 | 1023 | expect(page).toMatch( |
| 1024 | /id="live-now"\s+data-live="run\.\*[^"]*"\s+data-live-repo="alice\/engine"/, | |
| 1024 | /id="live-now"\s+data-live="run\.queued,run\.started,run\.finished[^"]*"\s+data-live-repo="alice\/engine"/, | |
| 1025 | 1025 | ); |
| 1026 | 1026 | |
| 1027 | 1027 | const stream = await get( |
new above
offline
This call is untested: remove it and the suite stays green. Drive a run through the callback and assert a
run.shardis appended.