Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-dashboard/test/empty-states.test.ts

Snapshot at 34e3175

Containing directory

11,576 bytes 路 typescriptView raw
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