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

A test pins the crew scorecard in the terminal: a forge admin's only, the same figures as Settings → Crew #6998

Merged opened this

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

Merged

scout/forge:scout/6980-a-test-pins-the-crew-scorecard-in-the-te → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/6980-a-test-pins-the-crew-scorecard-in-the-te was merged into main as 75418a2.

Review

Sign in to review this change.
1 file changed +173 −0
test/tui-scorecard.test.ts added +173
@@ -0,0 +1,173 @@
1 /** The crew scorecard in the terminal (#2677): a forge admin's only, the figures Settings → Crew shows. */
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, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createAgent } from "../src/actions/crew.ts";
9 import { createIssue } from "../src/core/issues.ts";
10 import { claimRun, createRun, finishRun, noteRunProgress } from "../src/core/runs.ts";
11 import { crewScorecards, crewScorecardSplits, recentWindow, spanText } from "../src/core/scorecard.ts";
12 import { createUser, getUserByName } from "../src/core/users.ts";
13 import { createForgeApp } from "../src/tui/forge.ts";
14 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { settleApp } from "./helpers/tui.ts";
17
18 quietLogs();
19
20 const TITLE = "Crew scorecard per agent";
21 /** The palette entry's hint: on screen only when the command is listed (the query echoes the title). */
22 const HINT = "each agent's pulls landed";
23 const DAY = 86_400_000;
24 let dirs: string[] = [];
25 let apps: App[] = [];
26
27 async function forge(): Promise<App> {
28 const dir = await mkdtemp(join(tmpdir(), "forge-tui-scorecard-"));
29 const app = createApp({ dataDir: dir, port: 0 });
30 dirs.push(dir);
31 apps.push(app);
32 for (const [username, adminIfFirst] of [["ada", true], ["bob", false]] as const) {
33 const made = await createUser(app.db, {
34 origin: "seed",
35 trusted: true,
36 username,
37 email: `${username}@forge.local`,
38 password: "a-good-passphrase",
39 adminIfFirst,
40 });
41 if (!made.ok) throw new Error(made.error);
42 }
43 return app;
44 }
45
46 async function agent(app: App, username: string): Promise<string> {
47 const made = await createAgent(app, getUserByName(app.db, "ada"), { username });
48 if (!made.ok) throw new Error(String(made.error));
49 return made.value.user.id;
50 }
51 afterAll(async () => {
52 for (const a of apps) a.close();
53 for (const d of dirs) await rm(d, { recursive: true, force: true });
54 });
55
56 function terminal(app: App, username: string) {
57 const user = getUserByName(app.db, username)!;
58 const { app: tui } = createForgeApp(
59 app,
60 { sshUsername: "t", remoteAddress: "127.0.0.1", user },
61 { terminal: headlessTerminal(200, 40), exitHooks: false },
62 );
63 tui.start();
64 const key = (k: string, ctrl = false) =>
65 tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
66 const t = {
67 text: () => tui.screen.toText(),
68 search: async () => {
69 key(":");
70 for (const ch of TITLE) key(ch);
71 await settleApp(tui);
72 },
73 open: async () => {
74 await t.search();
75 key("enter");
76 await settleApp(tui);
77 },
78 stop: () => tui.stop(),
79 };
80 return t;
81 }
82
83 let app: App;
84 let repoId = "";
85 const agentIds: Record<string, string> = {};
86
87 function pull(authorId: string, title: string, model: string, ageDays: number, merged: boolean): void {
88 const issue = createIssue(app.db, { repoId, authorId, title, kind: "pull" });
89 if (!issue.ok) throw new Error(issue.error);
90 const opened = new Date(Date.now() - ageDays * DAY).toISOString();
91 app.db.query("UPDATE issue SET created_at = ?2 WHERE id = ?1").run(issue.value.id, opened);
92 app.db
93 .query(
94 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at)
95 VALUES (?1, 'main', ?2, 'a', 'b', ?3)`,
96 )
97 .run(
98 issue.value.id,
99 `h${issue.value.number}`,
100 merged ? new Date(Date.parse(opened) + 3 * 3_600_000).toISOString() : null,
101 );
102 const run = createRun(app.db, { repoId, kind: "agent", actorId: authorId, issueId: issue.value.id });
103 claimRun(app.db, run.id);
104 noteRunProgress(app.db, run.id, { model, promptVersion: "v1" });
105 finishRun(app.db, run.id, { state: "ok", summary: "" });
106 const check = createRun(app.db, { repoId, kind: "check", issueId: issue.value.id });
107 claimRun(app.db, check.id);
108 finishRun(app.db, check.id, { state: merged ? "ok" : "failed", summary: "" });
109 }
110
111 beforeAll(async () => {
112 app = await forge();
113 const repo = await createRepo(app, getUserByName(app.db, "ada"), { name: "hello", visibility: "public" });
114 if (!repo.ok) throw new Error(String(repo.error));
115 repoId = repo.value.id;
116 const smith = await agent(app, "smith");
117 const jones = await agent(app, "jones");
118 agentIds.smith = smith;
119 agentIds.jones = jones;
120 pull(smith, "s1", "opus", 40, true);
121 pull(smith, "s2", "opus", 2, true);
122 pull(smith, "s3", "haiku", 1, false);
123 pull(jones, "j1", "opus", 3, true);
124 pull(jones, "j2", "opus", 60, false);
125 });
126
127 test("an admin's terminal shows the figures crewScorecards gives, recent and all time, with a row per model", async () => {
128 const all = crewScorecards(app.db);
129 const recent = crewScorecards(app.db, recentWindow());
130 const splits = crewScorecardSplits(app.db);
131 expect(all.size).toBe(2);
132 const t = terminal(app, "ada");
133 await t.open();
134 const text = t.text();
135 for (const name of ["smith", "jones"]) {
136 const id = agentIds[name]!;
137 const a = all.get(id)!;
138 const r = recent.get(id)!;
139 expect(text).toContain(`landed 30d ${r.landed} of ${r.opened} · all ${a.landed} of ${a.opened}`);
140 expect(text).toContain(`reverts ${a.reverts}`);
141 expect(text).toContain(`time to land ${spanText(a.meanLandMs!)}`);
142 expect(text).toContain(`red checks 30d ${Math.round(100 * r.redCheckRate!)}% · all ${Math.round(100 * a.redCheckRate!)}%`);
143 expect(text).toContain(`all ${a.reviewRounds!.toFixed(1)}`);
144 for (const p of splits.get(id)!) expect(text).toContain(`↳ ${p.model} · ${p.promptVersion}`);
145 }
146 expect(splits.get(agentIds.smith!)!.map((p) => p.model).sort()).toEqual(["haiku", "opus"]);

bob.open() presses enter on an empty or unrelated palette result. The not.toContain checks hold, but they would also pass if the screen broke for another reason. Add one positive assertion, for example that the palette shows no match for the title, so the test only passes for the right reason.

147 t.stop();
outdated

Pass-either-way: without the isAdmin gate this still passes. Assert the palette lacks the command for bob and has it for ada. " of 3" is an arbitrary string.

148 });
149
150 test("the palette lists the scorecard for a forge admin and not for anyone else", async () => {
151 const ada = terminal(app, "ada");
152 await ada.search();
153 expect(ada.text()).toContain(HINT);
154 ada.stop();
155 const bob = terminal(app, "bob");
156 await bob.search();
outdated

Use a local const empty = await forge() instead of reassigning the shared app.

157 expect(bob.text()).not.toContain(HINT);
158 await bob.open();
159 expect(bob.text()).not.toContain("smith");
160 expect(bob.text()).not.toContain("landed 30d");
161 bob.stop();
162 });
163
164 test("with no crew at all it says so in one line, with no table", async () => {
165 const empty = await forge();
166 const t = terminal(empty, "ada");
167 await t.open();
168 const text = t.text();
169 expect(text).toContain("No agents yet.");
170 expect(text).not.toContain("landed 30d");
171 expect(text).not.toContain("no pull yet");
172 t.stop();
173 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs