Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-dashboard/test/agent-status.test.ts
Snapshot at 34e3175
1import assert from "node:assert/strict";
2import { test } from "node:test";
3import { createElement } from "react";
4import { renderToStaticMarkup } from "react-dom/server";
5import { TooltipProvider } from "@cloudflare/kumo";
6import { createRequire } from "node:module";
7import { installDom } from "./dom.ts";
8import { mountDashboard } from "../src/dashboard.ts";
9import { createHttpTransport } from "../src/transport.ts";
10import { agentStatusRows } from "../src/agent-status-model.ts";
11import { AgentStatusTable, ResumeCard } from "../src/agent-status.tsx";
12import { stateView } from "../src/model.ts";
13import { AGENT_STATUS_NOW as now, agentStatusFixtureState } from "./agent-status-fixtures.ts";
14
15const fixture = (id = "agents-status") => stateView(agentStatusFixtureState(id, now));
16const rows = (id = "agents-status") => agentStatusRows(fixture(id), undefined, now);
17const brand = { name: "Fixture product", tagline: "Fixture collaboration" };
18const { JSDOM } = createRequire(import.meta.url)("jsdom") as {
19 JSDOM: new (html: string, options: { url: string }) => { window: Window & typeof globalThis };
20};
21const markup = (element: ReturnType<typeof createElement>) => renderToStaticMarkup(createElement(TooltipProvider, null, element));
22
23test("FH-078 first-match statuses use existing words and urgency", () => {
24 assert.deepEqual(rows().map((row) => row.status?.label ?? "Idle"), [
25 "Idle", "Blocked", "Returned", "Checks failed", "Timed out", "Couldn't run", "Checks running", "In review", "Approved", "In progress", "Racing", "Claimed, no recent push", "In progress", "Idle",
26 ]);
27 const state = fixture();
28 const blocked = state.claims[0];
29 state.claims.push({ ...blocked, packet: "fixture-second", agent: "fixture-agent", repoName: "fixture--fixture-agent" });
30 state.blockers[0].pushingPacket = "fixture-second";
31 state.blockers[0].repoName = "fixture--fixture-agent";
32 const row = agentStatusRows(state, undefined, now).find((entry) => entry.agent.name === "fixture-agent")!;
33 assert.equal(row.status?.label, "Blocked");
34 assert.equal(row.claims.length, 2);
35 assert.deepEqual(agentStatusRows(state, undefined, now).map((entry) => entry.identity?.number), rows().map((entry) => entry.identity?.number));
36});
37
38test("FH-078 held claims need 30 minutes of loaded activity before a quiet label", () => {
39 const held = rows().find((row) => row.agent.name === "held")!;
40 assert.equal(held.status?.label, "Claimed, no recent push");
41 assert.equal(held.status?.quietSince, now - 45 * 60_000);
42 const short = rows("agents-short-activity").find((row) => row.agent.name === "held")!;
43 assert.equal(short.status?.label, "In progress");
44 assert.equal(short.lastPush, undefined);
45 assert.equal(short.missingPush, "Not in loaded activity");
46 assert.equal(rows().find((row) => row.agent.name === "new")?.missingPush, "No push yet");
47});
48
49test("FH-078 a fresh claim without a push stays In progress through the 30-minute boundary", () => {
50 const state = fixture();
51 const fresh = (at: number) => agentStatusRows(state, undefined, at).find((row) => row.agent.name === "new")!;
52 assert.equal(fresh(now).status?.label, "In progress");
53 assert.equal(fresh(now).lastPush, undefined);
54 assert.equal(fresh(now).missingPush, "No push yet");
55 assert.equal(fresh(now + 29 * 60_000).status?.label, "In progress");
56 assert.equal(fresh(now + 29 * 60_000 + 1).status?.label, "Claimed, no recent push");
57 assert.equal(rows().find((row) => row.agent.name === "held")?.status?.quietSince, now - 45 * 60_000);
58 assert.equal(rows().find((row) => row.agent.name === "returned")?.status?.tone, "danger");
59});
60
61test("FH-078 a collision blocks the pushing fork, not the claimant's packet", () => {
62 const state = fixture();
63 state.blockers[0].packet = "fixture-agent";
64 const derived = agentStatusRows(state, undefined, now);
65 assert.equal(derived.find((row) => row.agent.name === "blocked")?.status?.label, "Blocked");
66 assert.equal(derived.find((row) => row.agent.name === "fixture-agent")?.status?.label, "In progress");
67 state.blockers[0].pushingPacket = null;
68 assert.equal(agentStatusRows(state, undefined, now).find((row) => row.agent.name === "blocked")?.status?.label, "Blocked");
69});
70
71test("FH-078 old reviews and unrelated pushes do not impersonate current work", () => {
72 const state = fixture();
73 const review = state.reviews.find((entry) => entry.agent === "returned")!;
74 const push = state.recentActivity!.find((entry) => entry.agent === "returned")!;
75 push.recordedAt = new Date(now - 60_000).toISOString();
76 assert.equal(agentStatusRows(state, undefined, now).find((row) => row.agent.name === "returned")?.status?.label, "In progress");
77 push.repoName = "unrelated-fork";
78 assert.equal(agentStatusRows(state, undefined, now).find((row) => row.agent.name === "returned")?.status?.label, "Returned");
79 review.requestedAt = new Date(now - 100 * 60_000).toISOString();
80 assert.equal(agentStatusRows(state, undefined, now).find((row) => row.agent.name === "returned")?.status?.label, "Claimed, no recent push");
81});
82
83test("FH-078 missing receipt settings do not imply checks are optional; approvals must be current", () => {
84 const state = fixture();
85 state.reviews.find((entry) => entry.agent === "review")!.receipt = null;
86 delete state.project.settings;
87 assert.equal(agentStatusRows(state, undefined, now).find((row) => row.agent.name === "review")?.status?.label, "In progress");
88 state.project.settings = { requireReceipt: false };
89 assert.equal(agentStatusRows(state, undefined, now).find((row) => row.agent.name === "review")?.status?.label, "In review");
90 state.forks.find((entry) => entry.agent === "approved")!.head = "9".repeat(40);
91 assert.equal(agentStatusRows(state, undefined, now).find((row) => row.agent.name === "approved")?.status?.label, "In progress");
92});
93
94test("FH-078 table collapses idle identities, keeps legacy live claims and changes owner copy", () => {
95 const state = fixture();
96 const props = { state, activity: state.recentActivity!, brand, openPacket() {} };
97 const owner = markup(createElement(AgentStatusTable, { ...props, owner: true }));
98 assert.match(owner, /Idle agents \(2\)/);
99 assert.doesNotMatch(owner, /data-agent="legacy"/);
100 assert.match(owner, /Resolve the collision/);
101 const agent = markup(createElement(AgentStatusTable, { ...props, owner: false }));
102 assert.match(agent, /the owner/);
103 assert.doesNotMatch(agent, /Resolve the collision|>Promote</);
104 state.agents.find((entry) => entry.name === "held")!.createdAt = null;
105 assert.match(markup(createElement(AgentStatusTable, { ...props, owner: true })), /Legacy/);
106});
107
108test("FH-078 reduced Resume card is read-only, absent for unknown callers and has no Next row", () => {
109 const state = fixture();
110 const props = { state, activity: state.recentActivity! };
111 assert.equal(markup(createElement(ResumeCard, props)), "");
112 assert.equal(markup(createElement(ResumeCard, { ...props, agent: "unknown" })), "");
113 const card = markup(createElement(ResumeCard, { ...props, agent: "fixture-agent" }));
114 assert.match(card, /Resume where you left off/);
115 assert.match(card, /fixture--fixture-agent/);
116 assert.match(card, /<dt>Packet<\/dt>|<dt>Last push<\/dt>|<dt>Status<\/dt>/);
117 assert.doesNotMatch(card, /<button|<dt>Next<\/dt>|unavailable/);
118 const idle = fixture("agent-idle");
119 assert.match(markup(createElement(ResumeCard, { state: idle, activity: idle.recentActivity!, agent: "fixture-agent" })), /No live claim\./);
120 const incomplete = fixture("agents-short-activity");
121 assert.match(markup(createElement(ResumeCard, { state: incomplete, activity: incomplete.recentActivity!, agent: "held" })), /Not in loaded activity/);
122});
123
124test("RV-rt3-1 the no-push placeholder in the table carries the wrapping class", () => {
125 const incomplete = fixture("agents-short-activity");
126 const table = markup(createElement(AgentStatusTable, { state: incomplete, activity: incomplete.recentActivity!, brand, owner: true, openPacket() {} }));
127 assert.match(table, /<span class="agent-missing-push caption muted">(Not in loaded activity|No push yet)<\/span>/);
128});
129
130async function until(check: () => boolean) {
131 for (let attempt = 0; attempt < 200; attempt++) {
132 if (check()) return;
133 await new Promise((resolve) => setTimeout(resolve, 5));
134 }
135 assert.fail("Agent status DOM did not settle");
136}
137
138test("FH-078 idle disclosure expands by keyboard-capable button and closes", async () => {
139 const dom = new JSDOM('<div id="dashboard"></div>', { url: "https://fixture.idiot.tax/#agents" });
140 installDom(dom.window);
141 const root = dom.window.document.getElementById("dashboard")!;
142 const wire = agentStatusFixtureState();
143 const calls: string[] = [];
144 const transport = createHttpTransport({ origin: "https://fixture.idiot.tax", fetch: async (input) => {
145 const path = new URL(String(input)).pathname;
146 calls.push(path);
147 return Response.json(path.endsWith("/state") ? wire : { activity: [], nextAfter: 0 });
148 } });
149 const app = mountDashboard(root, { brand, transport, initialProject: wire.project.id });
150 try {
151 await until(() => !!root.querySelector('[aria-controls="idle-agents"]'));
152 assert.equal(root.querySelector("#lanes table")?.getAttribute("aria-labelledby"), "agents-heading");
153 const packet = root.querySelector<HTMLButtonElement>('[data-agent="couldnt-run"] .agent-packet-link')!;
154 assert.equal(packet.textContent, "fixture-couldnt-run");
155 assert.equal(packet.getAttribute("aria-label"), "View packet fixture-couldnt-run");
156 const lastPush = root.querySelector('[data-agent="couldnt-run"] .agent-last-push-stacked')!;
157 assert.equal(lastPush.querySelector("code")?.textContent, "5555555");
158 assert.match(lastPush.querySelector("time.caption.muted")?.textContent ?? "", /min ago/);
159 const disclosure = root.querySelector<HTMLButtonElement>('[aria-controls="idle-agents"]')!;
160 assert.equal(disclosure.getAttribute("aria-expanded"), "false");
161 assert.equal(root.querySelector('[data-agent="legacy"]'), null);
162 disclosure.click();
163 await until(() => !!root.querySelector('[data-agent="legacy"]'));
164 assert.equal(disclosure.getAttribute("aria-expanded"), "true");
165 assert.match(root.querySelector('[data-agent="legacy"]')!.textContent!, /Idle.*Legacy|Legacy.*Idle/);
166 disclosure.click();
167 await until(() => !root.querySelector('[data-agent="legacy"]'));
168 assert.ok(calls.every((path) => /\/(state|activity)$/.test(path)));
169 } finally {
170 app.dispose();
171 dom.window.close();
172 }
173});
174
175test("FH-078 Resume is first in the agent Overview and absent for owner or unknown caller", async () => {
176 for (const [owner, agent, expected] of [[false, "fixture-agent", true], [false, undefined, false], [true, "fixture-agent", false]] as const) {
177 const dom = new JSDOM('<div id="dashboard"></div>', { url: "https://fixture.idiot.tax/#overview" });
178 installDom(dom.window);
179 const root = dom.window.document.getElementById("dashboard")!;
180 const wire = agentStatusFixtureState();
181 const transport = createHttpTransport({ origin: "https://fixture.idiot.tax", fetch: async (input) =>
182 Response.json(String(input).endsWith("/state") ? wire : { activity: [], nextAfter: 0 }) });
183 const app = mountDashboard(root, { brand, transport, initialProject: wire.project.id, owner, agent });
184 try {
185 await until(() => !!root.querySelector("#view-overview"));
186 assert.equal(!!root.querySelector(".resume-card"), expected);
187 if (expected) assert.ok(root.querySelector("#view-overview")!.firstElementChild?.classList.contains("resume-card"));
188 assert.equal(root.querySelector(".resume-card button"), null);
189 assert.equal(root.querySelector(".page-description")!.textContent!.trim(),
190 `Agents work in their own forks. ${owner ? "You review and promote." : "The owner reviews and promotes."}`);
191 } finally {
192 app.dispose();
193 dom.window.close();
194 }
195 }
196});
197