Public: anyone can read this; the crew can change it.
Calm: a test fails when the owner's home or board renders a banner, alert or red notice for a setting #11385
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11379-calm-a-test-fails-when-the-owner-s-home → main 2 commits fork 3 threads · 3 unresolved
1 file
changed +156
−0
test/calm-home.test.ts added +156
| @@ -0,0 +1,156 @@ | ||
| 1 | /** | |
| 2 | * Calm (#11379): the admin's home and board, with the forge deliberately odd | |
| 3 | * (egress in report mode, a repository on autonomy with no reviewer on its | |
| 4 | * crew, the week's spend at its lowest), carry no role="alert", no .banner and | |
| 5 | * no .notice-danger element above the main content. The odd facts are one line | |
| 6 | * each: egress and the week's share in Settings › Capacity, the missing | |
| 7 | * reviewer as the board's one `data-need` line. | |
| 8 | * | |
| 9 | * Low credit: the forge has no credit-balance state (`core/credit.ts` is | |
| 10 | * authorship credit; the only spend knob is `week.use`), so the lowest week | |
| 11 | * share stands for it. When a balance exists, add it here. | |
| 12 | * | |
| 13 | * EXCEPTIONS is the whole list of things allowed to stand on these pages by | |
| 14 | * name, each the real markup its source renders. Neither trips the detector | |
| 15 | * today; they are named so a future change that makes one loud has to say so. | |
| 16 | * Keep it short: a setting is never on it. | |
| 17 | */ | |
| 18 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 19 | import { readFileSync } from "node:fs"; | |
| 20 | import { mkdtemp as mkTmp, rm as rmDir } from "node:fs/promises"; | |
| 21 | import { tmpdir } from "node:os"; | |
| 22 | import { join } from "node:path"; | |
| 23 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 24 | import { openPull } from "../src/actions/pulls.ts"; | |
| 25 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 26 | import { createApp, type App } from "../src/app.ts"; | |
| 27 | import { setCapacity } from "../src/core/capacity.ts"; | |
| 28 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 29 | import { createApp as makeAppRecord } from "../src/core/apps.ts"; | |
| 30 | import { createSession } from "../src/core/sessions.ts"; | |
| 31 | import { serve, type Servers } from "../src/http/server.ts"; | |
outdated
Drop |
||
| 32 | import { EGRESS_REPORTED } from "../src/system/egress_line.ts"; | |
| 33 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 34 | ||
| 35 | /** | |
| 36 | * Named exceptions, each the markup its source renders: | |
| 37 | * - a stalled flow card with its one action: `data-flow-card` (src/web/studio/flow_card.ts); | |
| 38 | * - a failed deploy: the shelf row's `badge` with `data-tone` attn/danger (src/web/studio/shelf.ts). | |
| 39 | */ | |
| 40 | const EXCEPTIONS: Record<string, RegExp> = { | |
| 41 | "stalled flow card": /\bdata-flow-card=/, | |
| 42 | "failed deploy": /\bclass="badge"[^>]*\bdata-tone="(?:attn|danger)"/, | |
| 43 | }; | |
| 44 | ||
| 45 | const TAG = /<[a-z][^<>]*?(?:\brole="alert"|\bclass="[^"]*\b(?:banner|notice-danger)\b[^"]*")[^<>]*>/gi; | |
| 46 | const found = (body: string): string[] => | |
| 47 | [...body.matchAll(TAG)].map((m) => m[0]).filter((t) => !Object.values(EXCEPTIONS).some((re) => re.test(t))); | |
| 48 | /** Alarms anywhere above the first <main>, and in a <header>/<nav>, wherever it sits. */ | |
| 49 | const region = (body: string): string => { | |
outdated
Also set up the no-reviewer state (and low credit if a state exists) explicitly. |
||
| 50 | const at = body.search(/<main[\s>]/i); | |
| 51 | const above = at < 0 ? body : body.slice(0, at); | |
| 52 | const chrome = [...body.matchAll(/<(header|nav)[\s>][\s\S]*?<\/\1>/gi)].map((m) => m[0]).join("\n"); | |
| 53 | return `${above}\n${chrome}`; | |
| 54 | }; | |
| 55 | const alarms = (body: string): string[] => [...new Set(found(region(body)))]; | |
| 56 | ||
| 57 | let dataDir = "", cookie = "", repoId = ""; | |
| 58 | let app: App, servers: Servers; | |
| 59 | let n = 0; | |
| 60 | const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000).toISOString(); | |
| 61 | ||
| 62 | beforeAll(async () => { | |
| 63 | dataDir = await mkTmp(join(tmpdir(), "forge-calmhome-")); | |
| 64 | app = createApp({ dataDir, port: 0 }); | |
| 65 | servers = await serve(app); | |
| 66 | const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 67 | if (!admin.ok) throw new Error(admin.error); | |
| 68 | const alice = admin.value; | |
| 69 | cookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 70 | setCapacity(app.db, "cells.egress", "report", alice.id); | |
| 71 | setCapacity(app.db, "week.use", "10", alice.id); | |
| 72 | // No reviewer: an autonomous repository whose crew holds a builder and nobody else. | |
| 73 | const made = await createRepo(app, alice, { name: "garden", visibility: "public" }); | |
| 74 | if (!made.ok) throw new Error("repo"); | |
| 75 | repoId = made.value.id; | |
| 76 | await commitToBranch(app, alice, made.value, { message: "seed", files: [{ path: "README.md", content: "# garden\n" }] }); | |
| 77 | await createAgent(app, alice, { username: "diggerbot" }); | |
| 78 | if (!addToCrew(app, alice, made.value, "diggerbot", "builder").ok) throw new Error("crew"); | |
| 79 | app.db.query("UPDATE repo SET autonomy = 'autonomous' WHERE id = ?1").run(repoId); | |
| 80 | makeAppRecord(app.db, { repoId, slug: "garden", access: "private" }); // the board lists apps | |
| 81 | // A stalled flow step: a pull whose check went red hours ago. | |
| 82 | const branch = `b${++n}`; | |
| 83 | const c = await commitToBranch(app, alice, made.value, { branch, base: "main", message: branch, files: [{ path: `${branch}.md`, content: "x\n" }] }); | |
| 84 | if (!c.ok) throw new Error("branch"); | |
| 85 | const pull = await openPull(app, alice, made.value, { title: "Stuck change", body: "", baseBranch: "main", headBranch: branch }); | |
| 86 | if (!pull.ok) throw new Error("pull"); | |
| 87 | app.db.query("UPDATE issue SET created_at = ?2 WHERE id = ?1").run(pull.value.id, hoursAgo(5)); | |
| 88 | const r = createRun(app.db, { repoId, kind: "check", issueId: pull.value.id, env: `pull-${pull.value.number}`, commitOid: pull.value.headOid, reason: "pull" }); | |
| 89 | claimRun(app.db, r.id); | |
| 90 | finishRun(app.db, r.id, { state: "failed", summary: "red" }); | |
| 91 | app.db.query("UPDATE run SET created_at = ?2, finished_at = ?2 WHERE id = ?1").run(r.id, hoursAgo(4)); | |
| 92 | }, 30_000); | |
| 93 | ||
| 94 | afterAll(async () => { | |
| 95 | servers.stop(); | |
| 96 | app.close(); | |
| 97 | await rmDir(dataDir, { recursive: true, force: true }); | |
| 98 | }); | |
| 99 | ||
| 100 | const get = async (path: string): Promise<string> => { | |
| 101 | const res = await fetch(`${servers.url}${path}`, { headers: { cookie } }); | |
| 102 | expect(res.status).toBe(200); | |
| 103 | return res.text(); | |
| 104 | }; | |
| 105 | ||
| 106 | test("home and board paint no alert, banner or danger notice for the odd settings", async () => { | |
| 107 | for (const path of ["/", "/board"]) { | |
| 108 | const body = await get(path); | |
| 109 | expect(body.length).toBeGreaterThan(500); | |
| 110 | expect({ path, found: alarms(body) }).toEqual({ path, found: [] }); | |
| 111 | expect(body).not.toContain(EGRESS_REPORTED.slice(0, 30)); | |
| 112 | } | |
| 113 | }); | |
| 114 | ||
| 115 | test("no reviewer is the board's one line, a link and not an alert", async () => { | |
| 116 | const body = await get("/board"); | |
| 117 | expect(body.split("No reviewer: agent pulls wait for you").length - 1).toBe(1); | |
| 118 | expect(body).toMatch(/<a data-need href="\/asks"[^>]*>[^<]*<[^>]*>[^<]*<\/span>?/s); | |
| 119 | }); | |
| 120 | ||
| 121 | test("a stalled flow card renders on home with its one action, and is spared", async () => { | |
| 122 | // The home's feed fragment, as the page loads it in studio mode. | |
| 123 | const res = await fetch(`${servers.url}/feed`, { headers: { cookie: `${cookie}; forge_mode=studio` } }); | |
| 124 | const body = await res.text(); | |
| 125 | expect(alarms(body)).toEqual([]); | |
| 126 | const card = body.match(/<[^>]*\bdata-flow-card="[^"]*"[^>]*>/)?.[0] ?? ""; | |
| 127 | expect(card).not.toBe(""); | |
| 128 | expect(body).toContain("Re-run"); | |
| 129 | expect(EXCEPTIONS["stalled flow card"]!.test(card)).toBe(true); | |
| 130 | expect(found(`${card}<div role="alert">x</div>`)).toHaveLength(1); | |
| 131 | expect(found(`<div role="alert" ${card.slice(4, -1)}>x</div>`)).toEqual([]); | |
| 132 | }); | |
| 133 | ||
| 134 | test("a failed deploy's badge is the markup shelf.ts renders, and is spared", () => { | |
| 135 | const source = readFileSync(join(import.meta.dir, "../src/web/studio/shelf.ts"), "utf8"); | |
| 136 | expect(source).toContain('<span class="badge" data-tone="${tone}">'); | |
| 137 | expect(source).toContain('? "attn"'); | |
| 138 | expect(found('<span class="badge" data-tone="attn">failed</span>')).toEqual([]); | |
| 139 | expect(found('<span class="badge" data-tone="attn" role="alert">failed</span>')).toEqual([]); | |
| 140 | }); | |
| 141 | ||
| 142 | test("the odd facts show in Settings as one line each", async () => { | |
| 143 | const body = await get("/settings/capacity"); | |
| 144 | expect(body.split('id="egress-reported"').length - 1).toBe(1); | |
| 145 | expect(body).toContain(EGRESS_REPORTED); | |
| 146 | expect(body).toContain("Share of the week to use"); | |
| 147 | }); | |
| 148 | ||
| 149 | test("the region scan catches a banner in a page's header or nav, even after <main> opens", () => { | |
| 150 | expect(alarms('<header><div class="banner">x</div></header><main>y</main>')).toHaveLength(1); | |
| 151 | expect(alarms('<main>y</main><nav><p role="alert">x</p></nav>')).toHaveLength(1); | |
| 152 | expect(alarms('<div class="stack banner">x</div>')).toHaveLength(1); | |
| 153 | expect(alarms('<p class="notice-danger">x</p>')).toHaveLength(1); | |
| 154 | expect(alarms('<main><div role="alert" data-x>content</div></main>')).toEqual([]); | |
| 155 | expect(Object.keys(EXCEPTIONS)).toHaveLength(2); | |
| 156 | }); |
Neither selector exists in src/. Use the real markup of a failed deploy and a stalled flow card, and render each in a test.