Public: anyone can read this; the crew can change it.

The Resilience page shows each THREATS.md protection green, red or untested from the last gate #1975

Merged opened this

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

Merged

scout/forge:scout/1963-the-resilience-page-shows-each-threats-m → main 5 commits fork 6 threads · 6 unresolved

  • Merged. scout/1963-the-resilience-page-shows-each-threats-m was merged into main as f9476e2.

Review

Sign in to review this change.
14 files changed +431 −2
README.md +2
@@ -554,6 +554,8 @@ mechanical rather than advisory.
554554 `docs/FLOW.md` is where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h.
555555 Where the whole forge waits is a forge admin's: `/flow` shows each stage from ready to live with what waits, the oldest wait and its capacity, under one sentence naming the bottleneck, and beside each what it let out in the last hour with a 24-hour sparkline, under a line of landings and the median ask→live — every count links to the items it counts. A stalled step is a card on the home (Studio mode) for anyone who can write to the repository: an open pull whose check was never asked for, or is red, past its stage's allowance, with its stage, how long it has been stuck, and one button — Run check or Re-run — that asks for a check at the head and returns to the home. The terminal's **Stalled steps** command (palette) lists the same steps, live, and Enter or `r` does the same re-run; both read `flow/stalled.ts` and act through `flow/unblock.ts`, and neither shows anything to someone who cannot write. The terminal's Flow command and the MCP `flow` tool read the same: the sentence, the landings line, and each stage's count out in the last hour and in 24 h.
556556
557 `/resilience` is a forge admin's too (a guest is sent to sign in, anyone else gets 404): each protection in `docs/THREATS.md` as one line, green, red, untested or "no test yet", judged by the test file it cites in the last full gate of the forge's own repository (`FORGE_REPO`; other repositories' gates never touch it). The terminal's **Resilience** command (palette, admins) lists the same lines.
558
557559 The terminal also has an Access command (`:` → Access), a site admin's: it reads the same last-day non-browser requests as Settings → Access and the `access` MCP tool, newest first, and filters them with `s` (since), `u` (user agent) and `p` (path), `c` to clear.
558560
559561 `docs/PLANNING.md` is how issues depend on issues: the edge, the map, and
scripts/gate.ts +1
@@ -52,6 +52,7 @@ export const GATE: readonly string[] = [
5252 "test/webauthn.test.ts",
5353 "test/chaos.test.ts",
5454 "test/flow-stage.test.ts",
55 "test/resilience.test.ts",
5556 "test/flow-card.test.ts",
5657 "test/cluster-jobs.test.ts",
5758 "test/checks-placement.test.ts",
src/checks/gate_last.ts added +63
@@ -0,0 +1,63 @@
1 /**
2 * What the last full gate said of each test file: `bun test` prints the file
3 * as `test/x.test.ts:` before its results, `(pass) name` and `(fail) name`
4 * under it. Kept as one small file under the data directory, replaced by
5 * every gate, for the Resilience page to read.
6 */
7
8 import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
9 import { join } from "node:path";
10
11 export type FileResult = "pass" | "fail";
12
13 const HEADER = /^([A-Za-z0-9_./-]+\.test\.[cm]?[jt]sx?):$/;
14 const RESULT = /^\((pass|fail)\) /;
15
16 /** A file is red when any of its tests failed, green when it ran and none did. */
17 export function fileResults(lines: readonly string[]): Record<string, FileResult> {
18 const out: Record<string, FileResult> = {};
19 let file: string | undefined;
20 for (const line of lines) {
21 const header = HEADER.exec(line);
22 if (header) {
23 file = header[1];
24 if (file !== undefined && out[file] === undefined) out[file] = "pass";
25 continue;
26 }
27 const result = RESULT.exec(line);
28 if (result && file !== undefined && result[1] === "fail") out[file] = "fail";
29 }
30 return out;
31 }
32
33 const fileOf = (dataDir: string): string => join(dataDir, "checks", "gate-last.json");
34
35 export function keepGate(dataDir: string, lines: readonly string[]): void {
36 const results = fileResults(lines);
37 if (Object.keys(results).length === 0) return;
38 mkdirSync(join(dataDir, "checks"), { recursive: true });
39 writeFileSync(fileOf(dataDir), JSON.stringify(results));

Low: two gates finishing together can interleave this write and leave half-written JSON, which readGate then reads as "no gate". Write to gate-last.json.tmp and renameSync over the file so the swap is atomic.

40 }
41
42 /** Only the forge's own repository's gate is the record /resilience reads. */
43 export function keepForgeGate(
44 dataDir: string,
45 forgeRepo: string | undefined,
46 repo: string,
47 lines: readonly string[],
48 ): void {
49 if (forgeRepo === repo) keepGate(dataDir, lines);
50 }
51
52 export function readGate(dataDir: string): Record<string, FileResult> {
53 const out: Record<string, FileResult> = {};
54 try {
55 const parsed: unknown = JSON.parse(readFileSync(fileOf(dataDir), "utf8"));
56 if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return out;
57 for (const [file, result] of Object.entries(parsed))
58 if (result === "pass" || result === "fail") out[file] = result;
59 } catch {
60 // no gate has run yet
61 }
62 return out;
63 }
src/checks/landing.ts +2
@@ -27,6 +27,7 @@ import type { Repo } from "../core/repos.ts";
2727 import { runGit } from "../git/env.ts";
2828 import { writeMergeTree } from "../git/merge.ts";
2929 import { failuresOf } from "./failures.ts";
30 import { keepForgeGate } from "./gate_last.ts";
3031 import { addLearned } from "./learned.ts";
3132 import { checksFor } from "./service.ts";
3233 import { landVerdict, type LandVerdict } from "./tiers.ts";
@@ -67,6 +68,7 @@ export async function gateOnLand(
6768 );
6869 if (!commit.ok) return { lands: true };
6970 const done = await checks.gate(repo, pull, commit.value.trim());
71 keepForgeGate(app.config.dataDir, app.config.forgeRepo, `${repo.ownerName}/${repo.name}`, done.lines);
outdated

High: this runs for every repository's land gate and overwrites one forge-wide file. Another repo's results replace the forge's, or poison them when file names collide (and private repos' results show up on a public page). Keep it only for the forge's own repo, or key it by repo id.

Medium (correctness, no red noise): this keeps every gate, including a pull's merge that the gate then refuses. A pull that breaks test/tokens.test.ts and never lands still turns that protection red on /resilience while main is fine, until the next gate. Fix: keep the record only when verdict.lands, i.e. move the call below landVerdict, so the page reflects what main passed. A test should cover a refused gate leaving the record unchanged.

7072 const failed = failuresOf(done.lines);
7173 const verdict = landVerdict(done.ok, failed.tests, failed.files);
7274 if (!verdict.lands) addLearned(app.config.dataDir, pull.id, verdict.learn);
src/checks/resilience.ts added +26
@@ -0,0 +1,26 @@
1 /**
2 * The forge's protections as the Resilience page and the terminal say them:
3 * each line of docs/THREATS.md with its standing in the last full gate.
4 */
5
6 import { readFileSync } from "node:fs";
7 import { join } from "node:path";
8 import { parseThreats, standing, type Protection, type Standing } from "../core/threats.ts";
9 import { readGate } from "./gate_last.ts";
10
11 const DOC = join(import.meta.dir, "..", "..", "docs", "THREATS.md");
12
13 export type Row = { p: Protection; s: Standing };
14
15 function doc(): string {
16 try {
17 return readFileSync(DOC, "utf8");
18 } catch {
19 return "";
20 }
21 }
22
23 export function readResilience(dataDir: string): Row[] {
24 const gate = readGate(dataDir);
25 return parseThreats(doc()).map((p) => ({ p, s: standing(p, gate) }));
26 }
src/core/reserved.ts +1
@@ -85,6 +85,7 @@ export const SYSTEM_NAMES: readonly string[] = [
8585 "oauth",
8686 "og",
8787 "organizations",
88 "resilience",
8889 "origins",
8990 "robots.txt",
9091 "llms.txt",
src/core/threats.ts added +67
@@ -0,0 +1,67 @@
1 /**
2 * The protections in docs/THREATS.md, as plain data: each `Test:` line (after a
3 * bullet or a paragraph) and each table row naming a call, with the test file
4 * cited, or none.
5 */
6
7 export interface Protection {
8 readonly adversary: string;
9 readonly text: string;
10 /** The cited `test/*.test.ts`, or undefined for "none yet". */
11 readonly test: string | undefined;
12 }
13
14 const CITED = /^`(test\/[A-Za-z0-9_.-]+\.test\.ts)`$/;
15
16 export function parseThreats(md: string): Protection[] {
17 const out: Protection[] = [];
18 let adversary = "";
19 let bullet: string[] | undefined;
20 let para: string[] = [];
21 const close = (cited: string): void => {
22 if (bullet === undefined) return;
23 out.push({
24 adversary,
25 text: bullet.join(" ").replace(/\s+/g, " ").trim(),
26 test: CITED.exec(cited)?.[1],
27 });
28 bullet = undefined;
29 };
30 for (const line of md.split("\n")) {
31 if (line.startsWith("## ")) {
32 bullet = undefined;
33 para = [];
34 adversary = line.slice(3).trim();
outdated

High: only an indented Test: line after an open bullet closes a protection. The FORGE_SELF_SWAP paragraph in docs/THREATS.md (unindented Test: test/release-swap.test.ts, line 126) is dropped without a trace. Accept ^\s*Test: , and when no bullet is open, use the preceding paragraph as the text.

35 } else if (line.startsWith("- ")) {
36 bullet = [line.slice(2)];
37 } else if (/^\s*Test: /.test(line)) {
38 if (bullet === undefined && para.length > 0) bullet = para;
39 para = [];
40 close(line.replace(/^\s*Test: /, "").trim());
41 } else if (/^\s+\S/.test(line) && bullet !== undefined) {
42 bullet.push(line.trim());
43 } else if (line.startsWith("| ") && line.includes("→")) {
44 const cells = line.split("|").map((c) => c.trim());
45 bullet = [cells[1] ?? ""];
46 close(cells.at(-2) ?? "");
47 } else if (line.trim() === "") {
48 bullet = undefined;
49 para = [];
50 } else if (bullet === undefined && !line.startsWith("|")) {
51 para.push(line.trim());
52 }
53 }
54 return out;
55 }
56
57 export type Standing = "green" | "red" | "untested" | "no test yet";
58
59 /** One protection against the last gate: a cited file that did not run is untested. */
60 export function standing(
61 p: Protection,
62 gate: Readonly<Record<string, "pass" | "fail">>,
63 ): Standing {
64 if (p.test === undefined) return "no test yet";
65 const result = gate[p.test];
66 return result === "pass" ? "green" : result === "fail" ? "red" : "untested";
67 }
src/http/server.ts +3
@@ -10,6 +10,7 @@ import { unsealReleased } from "../actions/sealed.ts";
1010 import { accessPage } from "../web/pages/access.ts";
1111 import { capacityPage, capacitySet } from "../web/pages/capacity.ts";
1212 import { flowPage } from "../web/pages/flow.ts";
13 import { resiliencePage } from "../web/pages/resilience.ts";
1314 import { limitsPage, limitsSet } from "../web/pages/admin-limits.ts";
1415 import { boardsPage } from "../web/pages/boards.ts";
1516 import { boardNew, boardPin, boardPost } from "../web/pages/boards-edit.ts";
@@ -640,6 +641,8 @@ export function buildRouter(): Router<Handler> {
640641 router.add("GET", "/map", globalMapPage);
641642 // The pipeline's stages and its bottleneck, for a forge admin (#1554); literal, above /:owner.
642643 router.add("GET", "/flow", flowPage);
644 // Each THREATS.md protection against the last gate; literal, above /:owner.
645 router.add("GET", "/resilience", resiliencePage);
643646 // A selection's spend and runs over time; literal, above /:owner.
644647 router.add("GET", "/fleet/graph", fleetGraphPage);
645648 router.add("GET", "/fleet/graph/live", fleetGraphLive);
src/tui/forge.ts +9
@@ -37,6 +37,7 @@ import { pullScreen } from "./screens/pull.ts";
3737 import { blobScreen } from "./screens/repo.ts";
3838 import { dashboardScreen } from "./screens/dashboard.ts";
3939 import { accessScreen } from "./screens/access.ts";
40 import { resilienceLines, resilienceScreen } from "./screens/resilience.ts";
4041 import { flowLines, flowScreen } from "./screens/flow.ts";
4142 import { stalledScreen } from "./screens/stalled.ts";
4243 import { issuesScreen, newIssueScreen } from "./screens/issues.ts";
@@ -215,6 +216,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
215216 hint: "the pipeline's bottleneck and throughput",
216217 run: () => ctx.push(flowScreen(ctx, flowLines(session.db))),
217218 });
219 // Each THREATS.md protection's standing, as /resilience says it: a site admin's.
220 if (session.user?.isAdmin === true)
221 commands.push({
222 id: "resilience",
223 title: "Resilience",
224 hint: "each protection green, red or untested",
225 run: () => ctx.push(resilienceScreen(ctx, resilienceLines(session.app.config.dataDir))),
226 });
218227 // Stalled flow steps the viewer can unblock, as the home's cards show them (#1807).
219228 if (session.signedIn)
220229 commands.push({
src/tui/screens/resilience.ts added +40
@@ -0,0 +1,40 @@
1 /**
2 * The Resilience command's dialog, a forge admin's: one line per THREATS.md
3 * protection with its standing in the last gate, as /resilience says it.
4 * Read-only; Enter, Esc or q closes it.
5 */
6
7 import { readResilience } from "../../checks/resilience.ts";
8 import type { Ctx, Screen } from "../app/app.ts";
9 import { binding } from "../app/keys.ts";
10 import { pad, wrap } from "../term/width.ts";
11 import { dialog } from "../ui/widgets.ts";
12
13 const WIDTH = 72;
14
15 export function resilienceLines(dataDir: string): string[] {
16 const rows = readResilience(dataDir);
17 const inner = WIDTH - 6;
18 return rows.flatMap(({ p, s }) => wrap(`${pad(s, 11)} ${p.text}`, inner));
19 }
20
21 export function resilienceScreen(ctx: Ctx, lines: readonly string[]): Screen {
22 return {
23 title: "Resilience",
24 keys: [binding(["enter", "esc", "q"], "close")],
25 render(area, s) {
26 const buttons = dialog(s, area, ctx.theme.c, {
27 title: "Resilience",
28 body: lines,
29 actions: [{ label: "Close" }],
30 selected: 0,
31 width: WIDTH,
32 });
33 buttons.forEach((rect) => ctx.hit(rect, () => ctx.pop()));
34 },
35 key(e) {
36 if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop();
37 return true;
38 },
39 };
40 }
src/web/pages/flow.ts +2 −1
@@ -107,7 +107,8 @@ export function flowPage(ctx: Ctx): Response {
107107 From a ready issue to a live deploy: what is in each stage now, how
108108 long its oldest has waited, and what limits it. The limits are set on
109109 <a href="/settings/capacity">Capacity</a> and in each repository's
110 autonomy settings.
110 autonomy settings. How the forge stands against its threats is on
111 <a href="/resilience">Resilience</a>.
111112 </p>
112113 </div>
113114 <p id="bottleneck" data-stage="${worst?.key ?? ""}">
src/web/pages/resilience.ts added +51
@@ -0,0 +1,51 @@
1 /**
2 * /resilience: each protection in docs/THREATS.md as one line — green, red
3 * or "no test yet" — naming the test file it cites, judged by the last full
4 * gate's per-file results (`checks/gate_last.ts`). A cited file the last
5 * gate did not run reads "untested", never green.
6 */
7
8 import { readResilience } from "../../checks/resilience.ts";
9 import type { Standing } from "../../core/threats.ts";
10 import type { Ctx } from "../../http/context.ts";
11 import { notFoundPage, signInFirst } from "../chrome.ts";
12 import { html } from "../html.ts";
13 import { page } from "../layout.ts";
14 import { badge, card } from "../ui.ts";
15
16 const TONE = { green: "success", red: "danger", untested: "default", "no test yet": "default" } as const;
17
18 // GET /resilience: a forge admin's, since live red lines name what to attack.
19 export function resiliencePage(ctx: Ctx): Response {
20 if (!ctx.auth.user) return signInFirst(ctx);
21 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
22 const rows = readResilience(ctx.app.config.dataDir);
23 const count = (s: Standing): number => rows.filter((r) => r.s === s).length;
24 const body = html`<div class="page-head">
25 <h1>Resilience</h1>
26 <p class="muted">
27 Each protection in THREATS.md, judged by
28 the test file it cites in the last full gate.
29 </p>
30 </div>
outdated

High: there's no auth check. Live red/green on security protections tells an attacker which defenses are down right now. Gate it like flowPage: if (!ctx.auth.user) return signInFirst(ctx); if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);, and add a test for it.

31 <p id="tally">
32 ${String(count("green"))} green · ${String(count("red"))} red ·
outdated

Nit: as Standing is redundant; standing() already returns Standing.

33 ${String(count("untested"))} untested · ${String(count("no test yet"))} with no test yet
34 </p>
35 ${card(
36 html`<ul class="list">
37 ${rows.map(
38 ({ p, s }) =>
39 html`<li class="row" data-standing="${s}">
40 <span class="row-main">
41 <span class="row-title">${p.text}</span>
42 <span class="row-meta">${p.adversary}${p.test === undefined ? "" : " · "}${p.test === undefined ? "" : html`<code>${p.test}</code>`}</span>
43 </span>
44 <span class="row-trail">${badge(s, { tone: TONE[s] })}</span>
45 </li>`,
46 )}
47 </ul>`,
48 { pad: false },
49 )}`;
50 return page({ ctx, title: "Resilience", body });
51 }
test/resilience.test.ts added +163
@@ -0,0 +1,163 @@
1 /**
2 * The Resilience page: each THREATS.md protection green, red, untested or
3 * "no test yet", from the last gate's per-file results.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdirSync, writeFileSync } from "node:fs";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { fileResults, keepForgeGate, keepGate, readGate } from "../src/checks/gate_last.ts";
12 import { parseThreats, standing } from "../src/core/threats.ts";
13 import { createUser } from "../src/core/users.ts";
14 import type { Ctx, Screen } from "../src/tui/app/app.ts";
15 import { forgeCommands, Session } from "../src/tui/forge.ts";
16 import { resilienceLines } from "../src/tui/screens/resilience.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18
19 const DOC = `# Threats
20
21 ## A stolen token
22
23 - Tokens carry a scope.
24 Test: \`test/tokens.test.ts\`
25 - Revoking every token at once.
26 Test: none yet
27
28 | call | note | test |
29 | --- | --- | --- |
30 | runner → forge | bearer | \`test/passport.test.ts\` |
31 `;
32
33 describe("resilience", () => {
34 let dataDir: string;
35 let app: App;
36 let servers: Servers;
37
38 beforeAll(async () => {
39 dataDir = await mkdtemp(join(tmpdir(), "forge-resilience-"));
40 app = createApp({ dataDir, port: 0 });
41 servers = await serve(app);
42 });
43 afterAll(async () => {
44 servers.stop();
45 app.close();
46 await rm(dataDir, { recursive: true, force: true });
47 });
48
49 test("bullets and table rows parse, a missing citation is undefined", () => {
50 const got = parseThreats(DOC).map((p) => [p.text, p.test]);

These cases only use a synthetic DOC. Add one against the real docs/THREATS.md that checks the number of protections equals the number of Test: lines plus the number of → table rows, so a protection the parser misses fails the gate.

51 expect(got).toEqual([
52 ["Tokens carry a scope.", "test/tokens.test.ts"],
53 ["Revoking every token at once.", undefined],
54 ["runner → forge", "test/passport.test.ts"],
55 ]);
56 });
57
58 test("the real docs/THREATS.md yields one protection per Test: line and per → row", async () => {
59 const md = await Bun.file(join(import.meta.dir, "..", "docs", "THREATS.md")).text();
60 const tests = md.split("\n").filter((l) => /^\s*Test: /.test(l)).length;
61 const rows = md.split("\n").filter((l) => l.startsWith("| ") && l.includes("→")).length;
62 const got = parseThreats(md);
63 expect(got.length).toBe(tests + rows);
64 expect(got.some((p) => p.test === "test/release-swap.test.ts" && p.text.includes("SO_REUSEPORT"))).toBe(true);
65 });
66
67 test("an unindented Test: line closes the paragraph above it", () => {
68 const got = parseThreats("## X\n\nA prose protection.\nTest: `test/a.test.ts`\n");
69 expect(got.map((p) => [p.text, p.test])).toEqual([["A prose protection.", "test/a.test.ts"]]);
70 });
71
72 test("a failed cited file is red, a passed one green, an unrun one untested, none is 'no test yet'", () => {
73 const gate = fileResults([
74 "test/tokens.test.ts:",
75 "(pass) a",
76 "(fail) b",
77 "test/passport.test.ts:",
78 "(pass) c",
79 ]);
80 expect(gate).toEqual({ "test/tokens.test.ts": "fail", "test/passport.test.ts": "pass" });
81 const [a, b, c] = parseThreats(DOC);
82 expect(standing(a!, gate)).toBe("red");
83 expect(standing(b!, gate)).toBe("no test yet");
84 expect(standing(c!, gate)).toBe("green");
85 expect(standing(a!, {})).toBe("untested");
86 });
87
88 test("the last gate is kept and read back; garbage reads as nothing", () => {
89 expect(readGate(dataDir)).toEqual({});
90 keepGate(dataDir, ["test/a.test.ts:", "(fail) x"]);
91 expect(readGate(dataDir)).toEqual({ "test/a.test.ts": "fail" });
92 writeFileSync(join(dataDir, "checks", "gate-last.json"), "[1]");
93 expect(readGate(dataDir)).toEqual({});
94 });
95
96 test("another repository's gate leaves the forge's record unchanged", () => {
97 keepGate(dataDir, ["test/tokens.test.ts:", "(fail) x"]);
98 keepForgeGate(dataDir, "o/forge", "o/shop", ["test/tokens.test.ts:", "(pass) y"]);
99 keepForgeGate(dataDir, undefined, "o/shop", ["test/other.test.ts:", "(pass) y"]);
100 expect(readGate(dataDir)).toEqual({ "test/tokens.test.ts": "fail" });
101 keepForgeGate(dataDir, "o/forge", "o/forge", ["test/tokens.test.ts:", "(pass) y"]);
102 expect(readGate(dataDir)).toEqual({ "test/tokens.test.ts": "pass" });
103 });
104
105 const login = async (username: string): Promise<string> => {
106 const res = await fetch(`${servers.url}/signin`, {
107 method: "POST",
108 redirect: "manual",
109 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
110 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
111 });
112 return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
113 };
114
115 test("a guest is sent to sign in, a non-admin gets 404, an admin sees the rows", async () => {
116 for (const [name, admin] of [["alice", true], ["bob", false]] as const) {
117 const made = await createUser(app.db, {
118 trusted: true,
119 username: name,
120 email: `${name}@forge.local`,
121 password: "a-good-passphrase",
122 ...(admin ? { adminIfFirst: true } : {}),
123 });
124 if (!made.ok) throw new Error(made.error);
125 }
126 mkdirSync(join(dataDir, "checks"), { recursive: true });
127 keepGate(dataDir, ["test/tokens.test.ts:", "(fail) tokens are shown once"]);
128
129 const guest = await fetch(`${servers.url}/resilience`, { redirect: "manual" });
130 expect(guest.status).not.toBe(200);
131 expect(guest.status).not.toBe(404);
132 const bob = await fetch(`${servers.url}/resilience`, { headers: { cookie: await login("bob") } });
133 expect(bob.status).toBe(404);
134 expect(await bob.text()).not.toContain("data-standing");
135
136 const res = await fetch(`${servers.url}/resilience`, { headers: { cookie: await login("alice") } });
137 expect(res.status).toBe(200);
138 const text = await res.text();
139 const row = text.split("<li").find((r) => r.includes("test/tokens.test.ts") && r.includes("data-standing"));
140 expect(row).toContain('data-standing="red"');
141 expect(text).toContain('data-standing="no test yet"');
142 });
143
144 test("in the terminal, an admin's Resilience command lists the standings; the others are not offered it", () => {
145 const command = (admin: boolean) => {
146 const pushed: Screen[] = [];
147 const ctx = { push: (x: Screen) => pushed.push(x) } as unknown as Ctx;
148 const user = app.db.query("SELECT * FROM user WHERE username = ?1").get(admin ? "alice" : "bob");
149 const session = new Session(app, {
150 sshUsername: "git",
151 remoteAddress: "127.0.0.1",
152 fingerprint: "SHA256:test",
153 user: { ...(user as object), isAdmin: admin } as never,
154 });
155 return { found: forgeCommands(session)(ctx).find((c) => c.id === "resilience"), pushed };
156 };
157 const { found, pushed } = command(true);
158 found!.run();
159 expect(pushed.map((x) => x.title)).toEqual(["Resilience"]);
160 expect(resilienceLines(dataDir).join("\n")).toContain("red");
161 expect(command(false).found).toBeUndefined();
162 });
163 });
test/tui-tell.test.ts +1 −1
@@ -31,7 +31,7 @@ let bob: User;
3131 let repo: Repo;
3232
3333 async function palette(user: User, keys: string[]): Promise<string> {
34 const terminal = headlessTerminal(120, 30);
34 const terminal = headlessTerminal(120, 40);
3535 const { app: tui, session } = createForgeApp(
3636 app,
3737 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs