Public: anyone can read this; the crew can change it.
small: no alarm state draws a banner over the board: one test renders every alarm and finds one line in its place #11847
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11817-small-no-alarm-state-draws-a-banner-over → main 4 commits fork 3 threads · 3 unresolved
1 file
changed +210
−0
test/no-alarm-banner.test.ts added +210
| @@ -0,0 +1,210 @@ | ||
| 1 | /** | |
| 2 | * No alarm draws a banner over the board (#11817). Every alarm a test can | |
| 3 | * raise is raised, and each is first asserted present, as a line of its own | |
| 4 | * words, on the page it belongs to; only then are those pages read for | |
| 5 | * anything that draws as a banner (role="alert", a danger/attn toast or card, | |
| 6 | * a banner or notice). An alarm is one line beside the thing it is about. The | |
| 7 | * failure lists every offender by page and element. | |
| 8 | * | |
| 9 | * Raised: a full disk (every page's menu, /board), an overdue failing backup | |
| 10 | * (home, /board), a bug past its clock and an idle crew (home), an open | |
| 11 | * sentinel finding (/board, the only page it shows on), egress reported | |
| 12 | * (/system), the crew's spend reaching the forge ceiling (home), and two flow | |
| 13 | * alarms besides the bug clock: main red past its ten minutes and an issue | |
| 14 | * parked by the repeat stop (home). | |
| 15 | * Not raised here: "no release" and "main is ahead", which read the git | |
| 16 | * history of the running commit, and the stuck, silent, lane and paid-order | |
| 17 | * alarms; every flow alarm draws through the same `alarmLines`, which the | |
| 18 | * three raised here hold to one line each. | |
| 19 | * Known exception, tracked in #11870: the crew-readiness danger toasts on /crew. | |
| 20 | */ | |
| 21 | import { afterAll, expect, test } from "bun:test"; | |
| 22 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 23 | import { tmpdir } from "node:os"; | |
| 24 | import { join } from "node:path"; | |
| 25 | import { createApp, type App } from "../src/app.ts"; | |
| 26 | import { createRepo } from "../src/actions/repos.ts"; | |
| 27 | import { openIssue } from "../src/actions/issues.ts"; | |
| 28 | import { changeCapacity } from "../src/actions/capacity.ts"; | |
| 29 | import { addComment, createLabel, setIssueLabels } from "../src/core/issues.ts"; | |
| 30 | import { REPEAT_NOTE } from "../src/crew/repeat_note.ts"; | |
| 31 | import { createSession } from "../src/core/sessions.ts"; | |
| 32 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 33 | import { provideDiskForTest } from "../src/kernel/disk_reading.ts"; | |
| 34 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 35 | import { quietLogs } from "./helpers/globals.ts"; | |
| 36 | ||
| 37 | quietLogs(); | |
| 38 | ||
| 39 | // The forge's own repository is the one the red-main alarm reads; both globals are put back after. | |
| 40 | const priorOwnRepo = process.env.FORGE_SENTINEL_REPO; | |
| 41 | process.env.FORGE_SENTINEL_REPO = "alice/forge"; | |
| 42 | ||
| 43 | /** | |
| 44 | * An element that draws as a banner: a role="alert" shown at first paint, a | |
| 45 | * danger/attn toast or card, a banner or notice class. A stat tile or a dot | |
| 46 | * is a line beside its subject and is not one; a `hidden` alert is a slot a | |
| 47 | * script fills after a refusal. The whole page is read, not only what | |
| 48 | * precedes the first heading. | |
| 49 | */ | |
| 50 | /** An opening tag that draws (not script, style or an svg part) and is not `hidden`. */ | |
| 51 | const SHOWN_TAG = String.raw`<(?!script|style|svg|path)[a-z0-9]+\b(?![^>]*\bhidden\b)`; | |
| 52 | /** `role="alert"`. */ | |
| 53 | const ALERT_ROLE = String.raw`role="alert"`; | |
| 54 | /** A toast or card class and a danger/attn tone, in either order. */ | |
| 55 | const LOUD_TONE = String.raw`data-tone="(?:danger|attn)"`; | |
| 56 | const BOX_CLASS = String.raw`class="[^"]*\b(?:toast|card)\b[^"]*"`; | |
| 57 | const TONED_BOX = `${BOX_CLASS}[^>]*${LOUD_TONE}|${LOUD_TONE}[^>]*${BOX_CLASS}`; | |
| 58 | /** A banner, notice or alert class. */ | |
| 59 | const BANNER_CLASS = String.raw`class="[^"]*\b(?:banner|notice|notice-danger|notice-warn|alert)\b[^"]*"`; | |
| 60 | const BANNER = new RegExp(`${SHOWN_TAG}[^>]*(?:${ALERT_ROLE}|${TONED_BOX}|${BANNER_CLASS})[^>]*>`, "g"); | |
| 61 | ||
| 62 | const offenders = (path: string, html: string): string[] => | |
outdated
Never reset. Add |
||
| 63 | (html.match(BANNER) ?? []).map((tag) => `${path}: ${tag.slice(0, 120)}`); | |
| 64 | ||
| 65 | interface World { | |
| 66 | readonly root: string; | |
| 67 | readonly app: App; | |
| 68 | readonly servers: Servers; | |
| 69 | readonly cookie: string; | |
| 70 | } | |
| 71 | ||
| 72 | /** A forge in an alarm state; `spent` is the crew's spend today, in dollars, against a $1 ceiling. */ | |
| 73 | async function world(spent: number): Promise<World> { | |
| 74 | const root = mkdtempSync(join(tmpdir(), "forge-no-alarm-banner-")); | |
| 75 | const app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge", crewCeilingUsd: 1 }); | |
| 76 | const made = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" }); | |
| 77 | if (!made.ok) throw new Error(made.error); | |
| 78 | const cookie = `forge_session=${createSession(app.db, made.value.id).token}`; | |
| 79 | const repo = await createRepo(app, made.value, { name: "forge" }); | |
| 80 | if (!repo.ok) throw new Error(String(repo.error)); | |
outdated
Only the backup text is asserted. Add an assertion per raised alarm (disk, egress, budget, flow, sentinel) on its page, else the banner test above can pass on pages that show no alarm. |
||
| 81 | // A full disk. | |
| 82 | provideDiskForTest(() => ({ freeBytes: 1024 ** 2, totalBytes: 1000 * 1024 ** 3 })); | |
| 83 | // Egress reported, not enforced. | |
| 84 | const set = changeCapacity(app, made.value, "cells.egress", "report", "test: an alarm state"); | |
| 85 | if (!set.ok) throw new Error(set.error); | |
| 86 | // An overdue off-box backup that is failing. | |
| 87 | mkdirSync(join(root, "snapshot"), { recursive: true }); | |
| 88 | const long = new Date(Date.now() - 72 * 3_600_000).toISOString(); | |
| 89 | writeFileSync(join(root, "snapshot", "state.json"), JSON.stringify({ at: long, failingSince: long, error: "the bucket refused" })); | |
| 90 | // A bug past its 48 h clock, in a repository that runs itself and has started no run: the bug-clock and idle-crew alarms. | |
| 91 | const bug = openIssue(app, made.value, repo.value, { title: "The page is blank" }); | |
| 92 | if (!bug.ok) throw new Error(String(bug.error)); | |
| 93 | const label = createLabel(app.db, repo.value.id, "bug", "d73a4a"); | |
| 94 | if (!label.ok) throw new Error(label.error); | |
| 95 | setIssueLabels(app.db, bug.value.id, [label.value.id]); | |
| 96 | const old = new Date(Date.now() - 100 * 3_600_000).toISOString(); | |
| 97 | app.db.query("UPDATE issue SET created_at = ?1 WHERE id = ?2").run(old, bug.value.id); | |
| 98 | app.db.query("UPDATE repo SET autonomy = 'autonomous' WHERE id = ?1").run(repo.value.id); | |
| 99 | // An open sentinel finding. | |
| 100 | app.db | |
| 101 | .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen) VALUES ('setting:x', ?1, '', 'a', 'a')") | |
| 102 | .run(bug.value.id); | |
| 103 | // An issue parked by the repeat stop, no person answering: the repeat alarm. | |
| 104 | const parked = openIssue(app, made.value, repo.value, { title: "The runs keep repeating" }); | |
| 105 | if (!parked.ok) throw new Error(String(parked.error)); | |
| 106 | const note = addComment(app.db, { issueId: parked.value.id, authorId: made.value.id, body: REPEAT_NOTE }); | |
| 107 | if (!note.ok) throw new Error(note.error); | |
| 108 | // Main's last check failed half an hour ago: the red-main alarm. | |
| 109 | const red = new Date(Date.now() - 30 * 60_000).toISOString(); | |
| 110 | app.db | |
| 111 | .query( | |
| 112 | `INSERT INTO run (id, repo_id, kind, env, state, summary, check_detail, commit_oid, created_at, finished_at) | |
| 113 | VALUES ('r-red', ?1, 'check', ?2, 'failed', '1 failed', '{"names":["home renders"]}', 'c', ?3, ?3)`, | |
| 114 | ) | |
| 115 | .run(repo.value.id, repo.value.defaultBranch, red); | |
| 116 | // The crew's spend reaching the ceiling. | |
| 117 | if (spent > 0) { | |
| 118 | app.db | |
| 119 | .query("INSERT INTO run (id, repo_id, kind, cost_usd, created_at) VALUES ('r-spent', ?1, 'agent', ?2, ?3)") | |
| 120 | .run(repo.value.id, spent, new Date().toISOString()); | |
| 121 | } | |
| 122 | return { root, app, servers: await serve(app), cookie }; | |
| 123 | } | |
| 124 | ||
| 125 | const worlds: World[] = []; | |
| 126 | const open = async (spent: number): Promise<World> => { | |
| 127 | const w = await world(spent); | |
| 128 | worlds.push(w); | |
| 129 | return w; | |
| 130 | }; | |
| 131 | afterAll(() => { | |
| 132 | provideDiskForTest(undefined); | |
| 133 | if (priorOwnRepo === undefined) delete process.env.FORGE_SENTINEL_REPO; | |
| 134 | else process.env.FORGE_SENTINEL_REPO = priorOwnRepo; | |
| 135 | for (const w of worlds) { | |
| 136 | w.servers.stop(); | |
| 137 | w.app.close(); | |
| 138 | rmSync(w.root, { recursive: true, force: true }); | |
| 139 | } | |
| 140 | }); | |
| 141 | ||
| 142 | const read = async (w: World, path: string): Promise<string> => { | |
| 143 | const res = await fetch(`${w.servers.url}${path}`, { headers: { cookie: w.cookie }, redirect: "follow" }); | |
| 144 | expect(res.status).toBe(200); | |
| 145 | return res.text(); | |
| 146 | }; | |
| 147 | ||
| 148 | /** What a person reads: the markup without tags, entities decoded, one line. */ | |
| 149 | const said = (html: string): string => | |
| 150 | html | |
| 151 | .replace(/<(script|style|svg)\b.*?<\/\1>/gs, " ") | |
| 152 | .replace(/<[^>]*>/g, " ") | |
| 153 | .replace(/'/g, "'") | |
| 154 | .replace(/\s+/g, " "); | |
| 155 | ||
| 156 | /** Each alarm, the words it must say, and every page that must carry them. */ | |
| 157 | const ALARMS: readonly { readonly alarm: string; readonly says: string; readonly pages: readonly string[] }[] = [ | |
| 158 | { alarm: "disk full", says: "disk has 0.0% free", pages: ["/", "/alice/forge/next", "/alice/forge", "/system"] }, | |
| 159 | { alarm: "disk full, as the board needs it", says: "Disk is nearly full", pages: ["/board"] }, | |
| 160 | { alarm: "backup overdue", says: "the bucket refused", pages: ["/", "/board"] }, | |
| 161 | { alarm: "bug past its clock", says: "Bug past its clock: alice/forge#1", pages: ["/"] }, | |
| 162 | { alarm: "main red", says: "Main has been red for", pages: ["/"] }, | |
| 163 | { alarm: "repeating issue", says: "alice/forge#2 ran more than three times in ten minutes", pages: ["/"] }, | |
| 164 | { alarm: "sentinel finding", says: "The sentinel has 1 open finding", pages: ["/board"] }, | |
| 165 | { alarm: "egress reported", says: "Egress is reported, not enforced", pages: ["/system"] }, | |
| 166 | ]; | |
| 167 | const PAGES = ["/", "/alice/forge/next", "/alice/forge", "/board", "/system"]; | |
| 168 | ||
| 169 | test("each alarm is raised, said on its page, and no page draws a banner for any of them", async () => { | |
| 170 | const w = await open(0); | |
| 171 | const html = new Map<string, string>(); | |
| 172 | for (const path of PAGES) { | |
| 173 | const page = await read(w, path); | |
| 174 | expect(page).toContain("<main"); | |
| 175 | html.set(path, page); | |
| 176 | } | |
| 177 | for (const { alarm, says, pages } of ALARMS) { | |
| 178 | for (const path of pages) expect(said(html.get(path)!), `${alarm} on ${path}`).toContain(says); | |
| 179 | } | |
| 180 | const found = PAGES.flatMap((path) => offenders(path, html.get(path)!)); | |
| 181 | expect(found).toEqual([]); | |
| 182 | }); | |
| 183 | ||
| 184 | test("the idle-crew alarm is said on home while the spend is under the ceiling", async () => { | |
| 185 | const w = await open(0); | |
| 186 | expect(said(await read(w, "/"))).toContain("ready and no crew run has started"); | |
| 187 | }); | |
| 188 | ||
| 189 | test("the spend reaching the ceiling is said on home, the crew is paused on purpose, and no banner draws", async () => { | |
| 190 | const w = await open(2.5); | |
| 191 | const home = await read(w, "/"); | |
| 192 | expect(said(home)).toContain("Crew spend today $2.50 of $1.00 ceiling hit"); | |
| 193 | expect(said(home)).not.toContain("no crew run has started"); | |
| 194 | expect(offenders("/", home)).toEqual([]); | |
| 195 | }); | |
| 196 | ||
| 197 | test("the detector names a banner and passes the lines the forge really draws", () => { | |
| 198 | const banner = '<div class="toast" data-tone="attn" role="status">Disk is low</div>'; | |
| 199 | expect(offenders("/", `<main id="main">${banner}<h1>Home</h1></main>`)).toHaveLength(1); | |
| 200 | expect(offenders("/", '<p role="alert">Disk is low</p>')).toHaveLength(1); | |
| 201 | expect(offenders("/", '<div class="stack banner">x</div>')).toHaveLength(1); | |
| 202 | // Markup copied from the rendered pages: the home alarm line, the board's need, the menu item that carries the disk. | |
| 203 | expect(offenders("/", '<p class="small"><a href="/flow">No off-box backup has landed. The snapshot failed: the bucket refused</a></p>')).toEqual([]); | |
| 204 | expect(offenders("/board", '<li><a data-need id="sentinel" href="/trevato/forge/issues"><span class="dot" data-tone="attn"></span> The sentinel has 1 open finding</a></li>')).toEqual([]); | |
| 205 | expect(offenders("/", '<span class="menu-item" aria-disabled="true" title="this host's disk has 0.0% free">New forge</span>')).toEqual([]); | |
| 206 | expect(offenders("/", '<p class="error" role="alert" hidden></p>')).toEqual([]); | |
| 207 | expect(offenders("/", '<a class="stat" data-tone="attn" href="/x">Backup overdue</a>')).toEqual([]); | |
| 208 | // The known exception (#11870): the crew-readiness toast is a banner, and the detector sees it. | |
| 209 | expect(offenders("/crew", '<div class="toast" data-tone="danger" role="alert"><div class="toast-body">No crew runner is up</div></div>')).toHaveLength(1); | |
| 210 | }); |
new above
offline
The regex is the whole detector; add a case for each real alarm markup the forge emits (taken from the rendered pages), not only hand-written ones, so it is shown to match what the forge draws.