Public: anyone can read this; the crew can change it.
The flow panel's last merge gates speak the home page's language #9965
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9954-the-flow-panel-s-last-merge-gates-speak → main 3 commits fork 2 threads · 2 unresolved
3 files
changed +94
−12
src/web/pages/boards-gates.ts +8 −10
| @@ -10,7 +10,7 @@ import type { Database } from "bun:sqlite"; | ||
| 10 | 10 | import { recentGates, type KeptResult } from "../../checks/gate_result.ts"; |
| 11 | 11 | import { span } from "../../flow/pipeline.ts"; |
| 12 | 12 | import { html, type Html } from "../html.ts"; |
| 13 | import { badge, card, row } from "../ui.ts"; | |
| 13 | import { badge, row } from "../ui.ts"; | |
| 14 | 14 | |
| 15 | 15 | /** The most gates the card lists. */ |
| 16 | 16 | export const GATES_LISTED = 10; |
| @@ -68,8 +68,8 @@ function gateRow(l: Listed, now: number): Html { | ||
| 68 | 68 | }); |
| 69 | 69 | } |
| 70 | 70 | |
| 71 | /** The "Last gates" card. */ | |
| 72 | export function gatesCard(db: Database, owner: boolean, now: number): Html { | |
| 71 | /** The "Last gates" section: a heading and rows, no card. */ | |
| 72 | export function gatesSection(db: Database, owner: boolean, now: number): Html { | |
| 73 | 73 | const all: Listed[] = []; |
| 74 | 74 | for (const r of repos(db, owner)) { |
| 75 | 75 | for (const gate of recentGates(db, r.id, GATES_LISTED)) { |
| @@ -78,11 +78,9 @@ export function gatesCard(db: Database, owner: boolean, now: number): Html { | ||
| 78 | 78 | } |
| 79 | 79 | all.sort((a, b) => (a.gate.finishedAt < b.gate.finishedAt ? 1 : a.gate.finishedAt > b.gate.finishedAt ? -1 : 0)); |
| 80 | 80 | const top = all.slice(0, GATES_LISTED); |
| 81 | return card( | |
| 82 | html`<h2>Last gates</h2> | |
| 83 | ${top.length === 0 | |
| 84 | ? html`<p class="muted">No gate has run yet.</p>` | |
| 85 | : html`<ul class="list">${top.map((l) => gateRow(l, now))}</ul>`}`, | |
| 86 | { id: "gates" }, | |
| 87 | ); | |
| 81 | return html`<section class="settings-block" id="gates"><h2>Last gates</h2>${ | |
| 82 | top.length === 0 | |
| 83 | ? html`<p class="muted">No gate has run yet.</p>` | |
| 84 | : html`<ul class="list">${top.map((l) => gateRow(l, now))}</ul>` | |
| 85 | }</section>`; | |
| 88 | 86 | } |
src/web/pages/flow-panel.ts +2 −2
| @@ -48,7 +48,7 @@ import { readVerified, verifiedSays, type VerifiedRow } from "../../flow/verifie | ||
| 48 | 48 | import type { App } from "../../app.ts"; |
| 49 | 49 | import { html, type Html } from "../html.ts"; |
| 50 | 50 | import { keeperRow } from "../studio/keeper_row.ts"; |
| 51 | import { gatesCard } from "./boards-gates.ts"; | |
| 51 | import { gatesSection } from "./boards-gates.ts"; | |
| 52 | 52 | import { heldCard } from "./boards-held.ts"; |
| 53 | 53 | import { badge, card, meter } from "../ui.ts"; |
| 54 | 54 | |
| @@ -219,7 +219,7 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 219 | 219 | ${hotFilesCard(app, reader)} |
| 220 | 220 | ${heldCard(readHeld(app, reader.kind === "owner", now))} |
| 221 | 221 | ${queueCard(queuesFor(app, reader, new Date(now), { batches: true }))} |
| 222 | ${gatesCard(app.db, reader.kind === "owner", now)} | |
| 222 | ${gatesSection(app.db, reader.kind === "owner", now)} | |
| 223 | 223 | ${url.searchParams.get("idle") === "all" && idle.length > 0 ? idleList(idle) : null} |
| 224 | 224 | ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`; |
| 225 | 225 | } |
test/boards-gates-language.test.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | /** /boards' last merge gates speak the home page's language (#9954): a heading and rows, no card. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp } from "../src/app.ts"; | |
| 7 | import { keepGateResult, type GateResult } from "../src/checks/gate_result.ts"; | |
| 8 | import { createToken } from "../src/core/tokens.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { serve } from "../src/http/server.ts"; | |
| 11 | ||
| 12 | let dir: string; | |
| 13 | let app: ReturnType<typeof createApp>; | |
| 14 | let servers: Awaited<ReturnType<typeof serve>>; | |
| 15 | let token: string; | |
| 16 | const ago = (min: number) => new Date(Date.now() - min * 60_000).toISOString(); | |
| 17 | const gate = (over: Partial<GateResult>): GateResult => ({ | |
| 18 | repoId: "r1", runId: "gate-abc", tipOid: "abc", pulls: [1], state: "green", passed: 10, failed: 0, failing: [], | |
| 19 | startedAt: ago(12), finishedAt: ago(10), ...over, | |
| 20 | }); | |
| 21 | ||
| 22 | beforeAll(async () => { | |
| 23 | dir = await mkdtemp(join(tmpdir(), "forge-gates-lang-")); | |
| 24 | app = createApp({ dataDir: dir, port: 0 }); | |
| 25 | servers = await serve(app); | |
| 26 | const r = await createUser(app.db, { | |
| 27 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 28 | password: "a-good-passphrase", adminIfFirst: true, | |
| 29 | }); | |
| 30 | if (!r.ok) throw new Error(r.error); | |
| 31 | token = createToken(app.db, r.value.id, "t", "admin").token; | |
| 32 | app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('r1', ?1, 'svc', 'public')", [r.value.id]); | |
| 33 | app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES ('r2', ?1, 'hidden', 'private')", [r.value.id]); | |
| 34 | keepGateResult(app.db, "g1", gate({ pulls: [12] })); | |
| 35 | keepGateResult( | |
| 36 | app.db, "g2", | |
| 37 | gate({ pulls: [15], state: "red", failed: 1, failing: [{ name: "it works", errors: ["boom", "second"] }], finishedAt: ago(5) }), | |
| 38 | ); | |
| 39 | }); | |
| 40 | afterAll(async () => { | |
| 41 | servers.stop(); | |
| 42 | app.close(); | |
| 43 | await rm(dir, { recursive: true, force: true }); | |
| 44 | }); | |
| 45 | ||
| 46 | const section = async (as: string | null = token): Promise<string> => { | |
| 47 | const html = await (await fetch(`${servers.url}/boards`, { headers: as === null ? {} : { authorization: `Bearer ${as}` } })).text(); | |
| 48 | const at = html.indexOf('id="gates"'); | |
| 49 | expect(at).toBeGreaterThan(-1); | |
| 50 | return html.slice(html.lastIndexOf("<section", at), html.indexOf("</section>", at)); | |
| 51 | }; | |
| 52 | ||
| 53 | test("the last gates are a section under an h2, rows in a .list, no card", async () => { | |
| 54 | const s = await section(); | |
| 55 | expect(s).toMatch(/^<section[^>]*id="gates"/); | |
| 56 | expect(s).not.toContain('class="card'); | |
| 57 | expect(s).toContain("<h2>Last gates</h2>"); | |
| 58 | expect(s).toContain('<ul class="list">'); | |
| 59 | expect(s).toContain('href="/alice/svc/pulls/12"'); | |
| 60 | }); | |
| 61 | ||
| 62 | test("a red row names the first failing test and its first error only", async () => { | |
| 63 | const s = await section(); | |
| 64 | expect(s).toContain("it works — boom"); | |
| 65 | expect(s).not.toContain("second"); | |
| 66 | }); | |
| 67 | ||
| 68 | test("a private repository's gate is listed for the owner (the public one too)", async () => { | |
| 69 | keepGateResult(app.db, "g4", gate({ repoId: "r2", pulls: [77], finishedAt: ago(1) })); | |
Only the owner is tested. Add a second user (or unauthenticated fetch) and assert href="/alice/hidden/pulls/77" is absent while /alice/svc/pulls/12 is present, so "only repositories the reader may see" is held on /boards. This test passes on main too, so it proves nothing about the change. |
||
| 70 | expect(await section()).toContain('href="/alice/hidden/pulls/77"'); | |
| 71 | }); | |
| 72 | ||
| 73 | test("/boards shows gates to the forge's owner alone: a plain account or a guest never gets the private gate", async () => { | |
| 74 | const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bobby", email: "[email protected]", password: "a-good-passphrase" }); | |
| 75 | if (!b.ok) throw new Error(b.error); | |
| 76 | const other = createToken(app.db, b.value.id, "t", "ro").token; | |
| 77 | for (const who of [other, null]) { | |
| 78 | const r = await fetch(`${servers.url}/boards`, { redirect: "manual", headers: who === null ? {} : { authorization: `Bearer ${who}` } }); | |
| 79 | const body = await r.text(); | |
| 80 | expect(body).not.toContain("pulls/77"); | |
| 81 | expect(body).not.toContain("alice/hidden"); | |
| 82 | expect(body).not.toContain('id="gates"'); | |
| 83 | } | |
| 84 | }); |
Principle 7 / clean: the function says card but returns a section. Rename it (e.g. gatesSection) and update flow-panel.ts.