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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/11817-small-no-alarm-state-draws-a-banner-over → main 4 commits fork 3 threads · 3 unresolved

  • Merged. mason/11817-small-no-alarm-state-draws-a-banner-over was merged into main as 6ae1926.

Review

Sign in to review this change.
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";
outdated

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.

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 provideDiskForTest(undefined) to afterAll, or the full-disk reading leaks into every later test file in the same process.

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(/&#39;/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&#39;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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs