Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-dashboard/test/empty-states.test.ts
Snapshot at 34e3175
1import { installDom } from "./dom.ts";
2import assert from "node:assert/strict";
3import { createRequire } from "node:module";
4import { createElement } from "react";
5import { renderToStaticMarkup } from "react-dom/server";
6import test from "node:test";
7import brand from "../../../brand.json" with { type: "json" };
8import { mountDashboard } from "../src/dashboard.ts";
9import { overviewFirstRun } from "../src/empty.tsx";
10import { stateView, type ViewModel } from "../src/model.ts";
11import { DashboardView, type Section } from "../src/view.tsx";
12import { emptyFixtureState } from "./empty-fixtures.ts";
13import { fixtureState } from "./fixtures.ts";
14import { forkFixtureState } from "./fork-graph-fixtures.ts";
15
16const { JSDOM } = createRequire(import.meta.url)("jsdom");
17const fixtureBrand = { name: "Fixture product", tagline: "Fixture collaboration" };
18function snapshot(scenario: string): ViewModel {
19 return {
20 brand: fixtureBrand,
21 projects: scenario === "empty-projects" ? [] : [{ id: scenario, label: scenario }],
22 project: scenario === "empty-projects" ? "" : scenario,
23 fixture: true,
24 activity: [],
25 busy: false,
26 ...(scenario === "empty-projects" ? {} : { state: stateView(emptyFixtureState(scenario)) }),
27 };
28}
29function html(view: ViewModel, owner = true, section: Section = "overview") {
30 return renderToStaticMarkup(createElement(DashboardView, {
31 view,
32 actions: {
33 section, owner,
34 async selectProject() {},
35 setSection() {},
36 openReview() {},
37 async action() {},
38 },
39 }));
40}
41const cases = [
42 { scenario: "empty-projects", scope: ".content", kind: "projects", title: "No projects yet",
43 command: `${brand.slug} project create <project> --config "$CLI_CONFIG" --agents <agent> --output "$PRIVATE_TOKEN_FILE"` },
44 { scenario: "empty-agents", scope: "#lanes", kind: "agents", title: "No agents yet",
45 command: `${brand.slug} agent enroll <agent> --config "$CLI_CONFIG" --output "$NEW_PRIVATE_TOKEN_FILE"` },
46 { scenario: "empty-packets", scope: "#packets", kind: "packets", title: "No packets yet",
47 command: `${brand.slug} packet create packet.yaml --config "$CLI_CONFIG"` },
48];
49
50test("owner empty states show one placeholder command and the existing Copy control", () => {
51 for (const { scenario, scope, kind, title, command } of cases) {
52 const dom = new JSDOM(html(snapshot(scenario)));
53 try {
54 const hint = dom.window.document.querySelector(`${scope} [data-first-run="${kind}"]`);
55 assert.ok(hint, scenario);
56 assert.equal(hint.querySelector("h2").textContent, title);
57 assert.equal(hint.querySelectorAll("code").length, 1);
58 assert.equal(hint.querySelector("code").textContent, command);
59 assert.equal(hint.querySelectorAll('button[aria-label="Copy to clipboard"]').length, 1);
60 assert.equal(hint.querySelectorAll("button").length, 1);
61 assert.equal(hint.querySelectorAll("a, input, form, svg[role=img]").length, 0);
62 assert.match(dom.window.document.body.textContent, /FIXTURE 路 OFFLINE/);
63 } finally {
64 dom.window.close();
65 }
66 }
67});
68
69test("agent empty states hide commands and actions and identify owner responsibility", () => {
70 for (const scenario of ["empty-projects", "empty-agents", "empty-packets", "empty-forks", "empty-reviews"]) {
71 const dom = new JSDOM(html(snapshot(scenario), false));
72 try {
73 const hints = dom.window.document.querySelectorAll("[data-first-run]");
74 assert.ok(hints.length);
75 for (const hint of hints) {
76 assert.equal(hint.querySelectorAll("code, button, a").length, 0);
77 const kind = hint.getAttribute("data-first-run");
78 if (kind === "reviews") {
79 assert.match(hint.textContent, /When an agent requests review of a commit, it appears here for the owner to approve and promote\.$/);
80 assert.doesNotMatch(hint.textContent, /for you|The owner creates packets/);
81 } else if (kind === "forks") {
82 assert.match(hint.textContent, /Forks appear here when an agent claims a packet\.$/);
83 assert.doesNotMatch(hint.textContent, /The owner creates packets/);
84 } else {
85 assert.match(hint.textContent, /The owner (?:enrolls agents|creates packets|starts races)\.$/);
86 }
87 }
88 } finally {
89 dom.window.close();
90 }
91 }
92});
93
94test("Overview shows one empty state with agents, packets, then forks precedence", () => {
95 for (const [scenario, kind] of [["empty-agents", "agents"], ["empty-packets", "packets"], ["empty-forks", "forks"]]) {
96 const view = snapshot(scenario);
97 const dom = new JSDOM(html(view));
98 try {
99 const overview = dom.window.document.querySelector("#view-overview");
100 assert.equal(overview.querySelectorAll("[data-first-run]").length, 1);
101 const hint = overview.querySelector(`[data-first-run="${kind}"]`);
102 assert.ok(hint);
103 assert.equal(overview.querySelector(".fork-card"), null);
104 assert.equal(hint.querySelectorAll("button").length, kind === "forks" ? 0 : 1);
105 if (kind === "packets") assert.match(hint.textContent, /To race up to three agents on one packet, add mode: race to the packet file\./);
106 } finally {
107 dom.window.close();
108 }
109 }
110 const historical = stateView(forkFixtureState("fork-hero", Date.now()));
111 historical.claims = [];
112 assert.equal(overviewFirstRun(historical), undefined);
113 const dom = new JSDOM(html({ ...snapshot("demo"), state: historical }));
114 try {
115 assert.ok(dom.window.document.querySelector("#view-overview .fork-card"));
116 assert.equal(dom.window.document.querySelector("#view-overview [data-first-run]"), null);
117 } finally {
118 dom.window.close();
119 }
120});
121
122test("zero packets with real fork pushes or promotion history retain the Overview graph", () => {
123 const pushed = fixtureState();
124 pushed.packets = [];
125 pushed.claims = [];
126 pushed.reviews = [];
127 pushed.blockers = [];
128 pushed.activity = [{ ...pushed.activity[0]!, changedPaths: ["README.md"] }];
129 const historical = stateView(forkFixtureState("fork-hero", Date.now()));
130 historical.packets = [];
131 historical.claims = [];
132 historical.recentActivity = [];
133 assert.equal(pushed.ledger.length, 0);
134 assert.ok(historical.ledger.length);
135 for (const state of [stateView(pushed), historical]) {
136 assert.equal(state.packets.length, 0);
137 assert.equal(overviewFirstRun(state), undefined);
138 const dom = new JSDOM(html({ ...snapshot("demo"), state, activity: state.recentActivity ?? [] }));
139 try {
140 const overview = dom.window.document.querySelector("#view-overview");
141 assert.ok(overview.querySelector(".fork-card"));
142 assert.equal(overview.querySelector("[data-first-run]"), null);
143 } finally {
144 dom.window.close();
145 }
146 }
147});
148
149test("loading, failures and populated project views do not offer first-run actions", () => {
150 const empty = snapshot("empty-projects");
151 for (const view of [
152 { ...empty, busy: true },
153 { ...empty, refreshing: true },
154 { ...empty, error: "Snapshot unavailable" },
155 { ...snapshot("demo"), state: stateView(fixtureState()) },
156 ]) {
157 const dom = new JSDOM(html(view));
158 try {
159 // FH-081: a loaded project with zero races keeps its own Races-tab empty state.
160 const populated = !!view.state?.packets.length;
161 assert.equal(dom.window.document.querySelector(populated ? '[data-first-run]:not([data-first-run="races"])' : "[data-first-run]"), null);
162 } finally {
163 dom.window.close();
164 }
165 }
166});
167
168test("archive-only projects and an empty review queue with history retain existing states", () => {
169 const state = stateView(fixtureState());
170 for (const packet of state.packets)
171 packet.archived = { at: "2026-10-14T12:00:00Z", by: "owner" };
172 for (const review of state.reviews) review.status = "promoted";
173 const dom = new JSDOM(html({ ...snapshot("demo"), state }));
174 try {
175 assert.equal(dom.window.document.querySelector('#packets [data-first-run="packets"]'), null);
176 assert.match(dom.window.document.querySelector("#packets").textContent, /Show archived/);
177 assert.equal(dom.window.document.querySelector('#reviews [data-first-run="reviews"]'), null);
178 assert.match(dom.window.document.querySelector("#reviews").textContent, /No reviews waiting for owner action/);
179 } finally {
180 dom.window.close();
181 }
182});
183
184const until = async (check: () => boolean) => {
185 for (let attempt = 0; attempt < 100; attempt++) {
186 if (check()) return;
187 await new Promise((resolve) => setTimeout(resolve, 10));
188 }
189 assert.fail("Expected first-run fixture UI did not appear");
190};
191
192test("owner guidance copies literal placeholders and Go to Work switches tabs without writes", async () => {
193 const dom = new JSDOM('<div id="dashboard"></div>', { url: "https://fixture.idiot.tax/#review" });
194 installDom(dom.window);
195 const priorClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
196 const copied: string[] = [];
197 Object.defineProperty(navigator, "clipboard", {
198 configurable: true,
199 value: { async writeText(text: string) { copied.push(text); } },
200 });
201 const root = dom.window.document.getElementById("dashboard") as HTMLElement;
202 let writes = 0;
203 const wire = emptyFixtureState("empty-reviews");
204 const app = mountDashboard(root, {
205 brand: fixtureBrand, fixture: true,
206 projects: [{ id: "empty-reviews", label: "Empty reviews fixture" }],
207 transport: {
208 async getState() { return stateView(wire); },
209 async getActivity() { return { activity: [], nextAfter: 0 }; },
210 async approve() { writes++; },
211 async promote() { writes++; return { sha: "a".repeat(40) }; },
212 },
213 });
214 try {
215 await until(() => !!root.querySelector('#reviews [data-first-run="reviews"]'));
216 const hint = root.querySelector('#reviews [data-first-run="reviews"]')!;
217 assert.equal(hint.querySelectorAll("button").length, 1);
218 assert.equal(hint.querySelector("code, a"), null);
219 const go = hint.querySelector<HTMLButtonElement>("button")!;
220 assert.equal(go.textContent, "Go to Work");
221 go.click();
222 await until(() => !root.querySelector<HTMLElement>("#view-work")!.hidden);
223 assert.equal(dom.window.location.hash, "#work");
224 // Simulate a later empty-agent snapshot through the existing read transport.
225 wire.agents = [];
226 await app.refresh();
227 const agentsTab = [...root.querySelectorAll<HTMLButtonElement>('[role="tab"]')]
228 .find((tab) => tab.textContent === "Agents")!;
229 agentsTab.click();
230 await until(() => !root.querySelector<HTMLElement>("#view-agents")!.hidden);
231 root.querySelector<HTMLButtonElement>('#lanes [aria-label="Copy to clipboard"]')!.click();
232 await until(() => copied.length === 1);
233 assert.deepEqual(copied, [cases[1]!.command]);
234 assert.equal(writes, 0);
235 } finally {
236 app.dispose();
237 dom.window.close();
238 if (priorClipboard) Object.defineProperty(navigator, "clipboard", priorClipboard);
239 else Reflect.deleteProperty(navigator, "clipboard");
240 }
241});
242
243
244test("zero and unavailable races never invent a separate races empty state", () => {
245 for (const available of [true, false]) {
246 const view = snapshot("empty-races");
247 if (!available) delete view.state!.races;
248 const dom = new JSDOM(html(view, true, "work"));
249 try {
250 assert.equal(dom.window.document.querySelector('#view-work [data-first-run="races"]'), null);
251 assert.doesNotMatch(dom.window.document.querySelector("#packets").textContent, /No races yet/);
252 assert.ok(dom.window.document.querySelector("#packets table"));
253 } finally {
254 dom.window.close();
255 }
256 }
257});
258