Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-dashboard/test/dashboard.test.ts
Snapshot at 34e3175
1import { installDom } from "./dom.ts";
2import assert from "node:assert/strict";
3import { createRequire } from "node:module";
4import { test } from "node:test";
5import { mountDashboard } from "../src/dashboard.ts";
6import { createHttpTransport, ApiError } from "../src/transport.ts";
7import { renderDashboard } from "../src/render.ts";
8import { assertState, reviewGate, stateView } from "../src/model.ts";
9import { DIGEST, fixtureActivity, fixtureState, NEXT_HEAD } from "./fixtures.ts";
10import { getByRole } from "./roles.ts";
11
12const require = createRequire(import.meta.url);
13// jsdom is used only for local DOM acceptance; production has no runtime dependency.
14const { JSDOM } = require("jsdom") as {
15 JSDOM: new (
16 html: string,
17 options?: { url: string },
18 ) => {
19 window: {
20 document: Document;
21 MouseEvent: typeof MouseEvent;
22 Event: typeof Event;
23 close(): void;
24 };
25 };
26};
27const brand = { name: "Fixture product", tagline: "Fixture collaboration" };
28const origin = "https://workspace.idiot.tax";
29
30function apiFixture() {
31 const state = fixtureState();
32 const calls: { url: URL; options: RequestInit }[] = [];
33 let failedAction = false;
34 let unauthorized = false;
35 const fetcher: typeof fetch = async (input, options = {}) => {
36 const url = new URL(String(input));
37 calls.push({ url, options });
38 if (unauthorized) return Response.json({ error: "Unauthorized" }, { status: 403 });
39 if (url.pathname === "/projects/quiet/state") return Response.json(fixtureState("quiet"));
40 if (url.pathname === "/projects/quiet/activity")
41 return Response.json({ activity: [], nextAfter: 0 });
42 if (url.pathname === "/projects/demo/state") return Response.json(state);
43 if (url.pathname === "/projects/demo/activity")
44 return Response.json({ activity: fixtureActivity(), nextAfter: 3 });
45 if (options.method === "POST") {
46 if (failedAction) {
47 state.reviews[0].stale = true;
48 return Response.json({ error: "stale base, rebase" }, { status: 409 });
49 }
50 const review = state.reviews[0];
51 if (url.pathname === "/projects/demo/reviews/review-1/approve") {
52 review.approval = { sha: review.sha, receiptDigest: review.receiptDigest };
53 return Response.json({ review });
54 }
55 if (url.pathname === "/projects/demo/reviews/review-1/promote") {
56 assert.ok(review.approval);
57 review.promoted = true;
58 state.ledger.push({
59 id: "merge-1",
60 review: review.id,
61 repoName: state.project.repoName,
62 ref: "refs/heads/main",
63 before: review.base,
64 after: review.sha,
65 receiptDigest: review.receiptDigest,
66 recordedAt: "2026-10-12T00:20:00.000Z",
67 });
68 return Response.json({ sha: review.sha });
69 }
70 }
71 return Response.json({ error: "Not found" }, { status: 404 });
72 };
73 return {
74 state,
75 calls,
76 fetcher,
77 failAction() {
78 failedAction = true;
79 },
80 expireSession() {
81 unauthorized = true;
82 },
83 };
84}
85
86async function until(check: () => boolean) {
87 for (let attempt = 0; attempt < 200; attempt++) {
88 if (check()) return;
89 await new Promise((resolve) => setTimeout(resolve, 5));
90 }
91 assert.fail("DOM did not reach expected state");
92}
93
94function setup() {
95 const dom = new JSDOM('<div id="dashboard"></div>');
96 installDom(dom.window as unknown as Window & typeof globalThis);
97 const root = dom.window.document.getElementById("dashboard")!;
98 const fixture = apiFixture();
99 const app = mountDashboard(root, {
100 brand,
101 fixture: true,
102 projects: [
103 { id: "demo", label: "Demo" },
104 { id: "quiet", label: "Quiet" },
105 ],
106 transport: createHttpTransport({ origin, fetch: fixture.fetcher }),
107 });
108 return {
109 dom,
110 root,
111 fixture,
112 app,
113 cleanup() {
114 app.dispose();
115 dom.window.close();
116 },
117 };
118}
119
120const button = (root: HTMLElement, action: string, review = "review-1") =>
121 root.querySelector<HTMLButtonElement>(
122 `button[data-action="${action}"][data-review="${review}"]`,
123 )!;
124
125test("fixture DOM renders every packet view with fork-bound claims and evidence", async () => {
126 const context = setup();
127 try {
128 await until(() => Boolean(context.root.querySelector("#reviews")));
129 for (const section of ["lanes", "packets", "blockers", "activity"])
130 assert.ok(context.root.querySelector(`#${section}`));
131 assert.match(context.root.textContent!, /FIXTURE · OFFLINE/);
132 assert.match(context.root.textContent!, /demo--builder-1/);
133 assert.match(context.root.textContent!, /Passed/);
134 assert.match(context.root.textContent!, /Fork moved/);
135 assert.equal(button(context.root, "approve").disabled, false);
136 assert.equal(button(context.root, "promote"), null);
137 assert.equal(button(context.root, "approve", "review-2"), null);
138 assert.equal(button(context.root, "approve", "review-3"), null);
139 const blocked = Array.from(context.root.querySelectorAll(".review-card")).find((row) =>
140 row.textContent!.includes("api-02"),
141 )!;
142 assert.match(blocked.textContent!, /Waiting for checks/);
143 } finally {
144 context.cleanup();
145 }
146});
147
148test("review rows explain blocker gates and activity is empty only after a successful snapshot", () => {
149 const state = stateView(fixtureState());
150 const blockedReview = state.reviews.find((review) => review.packet === "api-02")!;
151 blockedReview.receiptDigest = DIGEST;
152 blockedReview.receipt = state.reviews[0].receipt;
153 const base = { brand, projects: [], project: "demo", fixture: true, activity: [], busy: false };
154 for (const snapshot of [
155 { ...base, state },
156 { ...base, busy: true },
157 { ...base, error: "Snapshot unavailable" },
158 ]) {
159 const dom = new JSDOM(renderDashboard(snapshot));
160 try {
161 const activity = dom.window.document.querySelector("#activity")!;
162 assert.equal(activity.textContent!.includes("No pushes yet."), "state" in snapshot);
163 if ("state" in snapshot) {
164 const blocked = Array.from(dom.window.document.querySelectorAll(".review-card")).find(
165 (row) => row.textContent!.includes("api-02"),
166 )!;
167 assert.match(blocked.textContent!, /Blocked: this fork pushed into api-02's claimed paths/);
168 assert.equal(activity.querySelectorAll("p").length, 1);
169 } else {
170 assert.equal(activity.textContent, "");
171 }
172 } finally {
173 dom.window.close();
174 }
175 }
176});
177
178test("owner DOM Approve calls the review endpoint once; Promote refreshes head and ledger", async () => {
179 const context = setup();
180 try {
181 await until(() => Boolean(context.root.querySelector("#reviews")));
182 const approve = button(context.root, "approve");
183 approve.click();
184 approve.click();
185 await until(() => context.dom.window.document.body.textContent!.includes("Approved bbbbbbb"));
186 assert.ok(
187 [...context.dom.window.document.querySelectorAll("code")].some(
188 (code) =>
189 code.textContent === "bbbbbbb" && code.parentElement?.textContent === "Approved bbbbbbb",
190 ),
191 "Approved toast renders SHA as code",
192 );
193 assert.equal(context.fixture.calls.filter((call) => call.options.method === "POST").length, 1);
194 const approvalCall = context.fixture.calls.find((entry) => entry.options.method === "POST")!;
195 assert.equal(approvalCall.url.pathname, "/projects/demo/reviews/review-1/approve");
196 assert.equal(approvalCall.options.credentials, "same-origin");
197 assert.equal(approvalCall.options.body, "{}");
198 assert.deepEqual(approvalCall.options.headers, { "Content-Type": "application/json" });
199 await until(() => !!button(context.root, "promote"));
200 button(context.root, "promote").click();
201 await until(
202 () => !!context.dom.window.document.querySelector('[data-action="confirm-promote"]'),
203 );
204 assert.equal(
205 context.fixture.calls.filter((call) => call.options.method === "POST").length,
206 1,
207 "opening confirmation performs no write",
208 );
209 context.dom.window.document
210 .querySelector<HTMLButtonElement>('[data-action="confirm-promote"]')!
211 .click();
212 await until(() => context.dom.window.document.body.textContent!.includes("Promoted to main"));
213 assert.equal(context.fixture.state.ledger[0].after, NEXT_HEAD);
214 assert.equal(context.fixture.state.ledger.length, 1);
215 assert.ok(context.fixture.calls.some((entry) => entry.url.pathname.endsWith("/promote")));
216 assert.equal(button(context.root, "approve"), null);
217 } finally {
218 context.cleanup();
219 }
220});
221
222test("a stale-base conflict is surfaced and refreshed without retrying the owner write", async () => {
223 const context = setup();
224 try {
225 await until(() => Boolean(context.root.querySelector("#reviews")));
226 context.fixture.failAction();
227 button(context.root, "approve").click();
228 await until(
229 () =>
230 context.root
231 .querySelector('[role="alert"]')
232 ?.textContent?.includes("stale base, rebase") === true,
233 );
234 assert.equal(button(context.root, "approve"), null);
235 assert.equal(context.fixture.calls.filter((call) => call.options.method === "POST").length, 1);
236 } finally {
237 context.cleanup();
238 }
239});
240
241test("expired session clears old actionable state; project switching shows empty state", async () => {
242 const context = setup();
243 try {
244 await until(() => Boolean(context.root.querySelector("#reviews")));
245 const combo = context.root.querySelector<HTMLButtonElement>('[role="combobox"]')!;
246 combo.focus();
247 combo.dispatchEvent(
248 new context.root.ownerDocument.defaultView!.KeyboardEvent("keydown", {
249 key: "ArrowDown",
250 bubbles: true,
251 }),
252 );
253 await until(() => !!context.dom.window.document.querySelector('[role="option"]'));
254 const option = [
255 ...context.dom.window.document.querySelectorAll<HTMLElement>('[role="option"]'),
256 ].find((entry) => entry.textContent === "Quiet")!;
257 option.focus();
258 option.dispatchEvent(
259 new context.root.ownerDocument.defaultView!.MouseEvent("pointerdown", {
260 bubbles: true,
261 button: 0,
262 }),
263 );
264 option.dispatchEvent(
265 new context.root.ownerDocument.defaultView!.MouseEvent("mouseup", {
266 bubbles: true,
267 button: 0,
268 }),
269 );
270 option.click();
271 await until(() => context.root.textContent!.includes("No agents yet"));
272 assert.equal(context.root.querySelectorAll(".review-card").length, 0);
273 context.fixture.expireSession();
274 await context.app.refresh();
275 assert.match(context.root.textContent!, /Couldn't refresh\. Last updated/);
276 assert.ok(context.root.querySelector("#reviews"), "the last loaded project stays visible");
277 } finally {
278 context.cleanup();
279 }
280});
281
282test("all API/config text is escaped, including review IDs, paths and error messages", () => {
283 const state = stateView(fixtureState());
284 state.packets[0].title = '<img src=x onerror="alert(1)">';
285 state.reviews[0].id = '" onclick="alert(2)';
286 const html = renderDashboard({
287 brand,
288 projects: [],
289 project: "demo",
290 fixture: true,
291 state,
292 activity: [],
293 busy: false,
294 error: "<script>bad()</script>",
295 });
296 const dom = new JSDOM(html);
297 assert.equal(dom.window.document.querySelector("img,script"), null);
298 assert.equal(dom.window.document.querySelector("[onclick]"), null);
299 assert.match(dom.window.document.body.textContent!, /<img src=x/);
300 dom.window.close();
301});
302
303test("head, receipt and blocker gates never authorize stale or incomplete reviews", () => {
304 const state = stateView(fixtureState());
305 const review = state.reviews[0];
306 assert.equal(reviewGate(review, state).approve, true);
307 state.forks[0].head = "0".repeat(40);
308 assert.equal(reviewGate(review, state).approve, false);
309 state.forks[0].head = review.sha;
310 review.receiptDigest = null;
311 assert.equal(reviewGate(review, state).approve, false);
312 review.receiptDigest = DIGEST;
313 state.blockers[0].repoName = review.repoName;
314 state.blockers[0].pushingPacket = review.packet;
315 assert.equal(reviewGate(review, state).approve, false);
316 assert.throws(() => assertState({ ...state, reviews: undefined } as never), /contract/);
317});
318
319test("HTTP transport encodes IDs, uses activity cursor, refuses login HTML and redirects", async () => {
320 const urls: string[] = [];
321 const transport = createHttpTransport({
322 origin,
323 fetch: async (url, options) => {
324 assert.equal(options?.redirect, "error");
325 assert.equal(options?.cache, "no-store");
326 urls.push(String(url));
327 return Response.json({ activity: [], nextAfter: 7 });
328 },
329 });
330 await transport.getActivity("a/b", 7);
331 assert.equal(urls[0], `${origin}/projects/a%2Fb/activity?after=7&limit=100`);
332 const htmlTransport = createHttpTransport({
333 origin,
334 fetch: async () =>
335 new Response("<html>login</html>", { headers: { "Content-Type": "text/html" } }),
336 });
337 await assert.rejects(
338 htmlTransport.getState("demo"),
339 (error: unknown) => error instanceof ApiError && error.status === 502,
340 );
341});
342
343test("optional WebSocket invalidation is same-origin, receives messages and closes on dispose", () => {
344 const events = new Map<string, () => void>();
345 let closed = false;
346 let updates = 0;
347 const socket = {
348 addEventListener(type: string, handler: () => void) {
349 events.set(type, handler);
350 },
351 removeEventListener(type: string) {
352 events.delete(type);
353 },
354 close() {
355 closed = true;
356 },
357 } as unknown as WebSocket;
358 const transport = createHttpTransport({
359 origin,
360 socketUrl: (id) => `/events/${id}`,
361 openSocket(url) {
362 assert.equal(url, "wss://workspace.idiot.tax/events/demo");
363 return socket;
364 },
365 });
366 const dispose = transport.subscribe!("demo", () => updates++);
367 events.get("message")!();
368 assert.equal(updates, 1);
369 dispose();
370 assert.equal(closed, true);
371 assert.equal(events.size, 0);
372 const foreign = createHttpTransport({
373 origin,
374 socketUrl: () => "wss://foreign.idiot.tax/events",
375 openSocket() {
376 assert.fail("must reject before opening socket");
377 },
378 });
379 assert.throws(() => foreign.subscribe!("demo", () => {}), /origin/);
380});
381
382test("wire normalization preserves live fork bindings and refuses a changed approval digest", () => {
383 const wire = fixtureState();
384 wire.reviews[0].approval = { sha: wire.reviews[0].sha, receiptDigest: "e".repeat(64) };
385 wire.promotionPending = {
386 id: "pending-1",
387 review: "review-1",
388 repoName: "demo",
389 ref: "refs/heads/main",
390 before: wire.reviews[0].base,
391 after: wire.reviews[0].sha,
392 receiptDigest: DIGEST,
393 recordedAt: "2026-10-12T00:20:00.000Z",
394 };
395 const view = stateView(wire);
396 assert.equal(view.claims[0].repoName, "demo--builder-1");
397 assert.equal(view.claims[0].active, true);
398 assert.equal(reviewGate(view.reviews[0], view).promote, false);
399 assert.equal(view.reviews[0].status, "stale");
400 assert.equal(view.canonicalHead, undefined);
401 assert.deepEqual(view.promotionPending, wire.promotionPending);
402 assert.equal(view.reconcilerAlerts.length, 0);
403});
404
405test("activity request follows the recent snapshot cursor instead of showing the first page", async () => {
406 const context = setup();
407 try {
408 await until(() => Boolean(context.root.querySelector("#reviews")));
409 const activity = context.fixture.calls.find((call) => call.url.pathname.endsWith("/activity"))!;
410 assert.equal(activity.url.searchParams.get("after"), "3");
411 assert.equal(context.root.querySelectorAll(".activity").length, 3);
412 } finally {
413 context.cleanup();
414 }
415});
416
417test("top tabs select one visible view and preserve focus across refreshes", async () => {
418 const context = setup();
419 try {
420 await until(() => Boolean(context.root.querySelector("#reviews")));
421 const tabs = [
422 getByRole(context.root, "tab", { name: "Overview" }),
423 getByRole(context.root, "tab", { name: /^Work\s*,\s+\d+$/ }),
424 getByRole(context.root, "tab", { name: /^Review\s*,\s+\d+$/ }),
425 getByRole(context.root, "tab", { name: "Agents" }),
426 getByRole(context.root, "tab", { name: /^Races(\s*,\s+\d+)?$/ }),
427 ];
428 assert.equal(context.root.querySelectorAll('[role="tab"]').length, tabs.length);
429 tabs[1].focus();
430 tabs[1].click();
431 assert.equal(context.root.querySelector<HTMLElement>("#view-work")!.hidden, false);
432 assert.equal(context.root.querySelector<HTMLElement>("#view-overview")!.hidden, true);
433 await context.app.refresh();
434 assert.equal(
435 context.dom.window.document.activeElement,
436 tabs[1],
437 "refresh preserves the same focused Kumo tab",
438 );
439 } finally {
440 context.cleanup();
441 }
442});
443
444test("promotion confirmation stays bound to the displayed head when a fresh snapshot moves the fork", async () => {
445 const context = setup();
446 try {
447 await until(() => Boolean(context.root.querySelector("#reviews")));
448 button(context.root, "approve").click();
449 await until(() => !!button(context.root, "promote"));
450 button(context.root, "promote").click();
451 await until(
452 () => !!context.dom.window.document.querySelector('[data-action="confirm-promote"]'),
453 );
454 context.fixture.state.forks[0].head = "f".repeat(40);
455 await context.app.refresh();
456 const confirm = context.dom.window.document.querySelector<HTMLButtonElement>(
457 '[data-action="confirm-promote"]',
458 )!;
459 assert.equal(confirm.disabled, true);
460 confirm.click();
461 assert.equal(
462 context.fixture.calls.filter((entry) => entry.url.pathname.endsWith("/promote")).length,
463 0,
464 );
465 } finally {
466 context.cleanup();
467 }
468});
469
470test("tabs preserve fragment selection when the sandbox rejects History API URLs", async () => {
471 const context = setup();
472 try {
473 await until(() => Boolean(context.root.querySelector("#reviews")));
474 const window = context.root.ownerDocument.defaultView!;
475 window.history.replaceState = () => {
476 throw new window.DOMException("Opaque origin", "SecurityError");
477 };
478 context.root.querySelectorAll<HTMLButtonElement>('[role="tab"]')[1].click();
479 assert.equal(window.location.hash, "#work");
480 await context.app.refresh();
481 assert.equal(context.root.querySelector<HTMLElement>("#view-work")!.hidden, false);
482 } finally {
483 context.cleanup();
484 }
485});
486
487test("packet states follow blocker, promotion, approval, stale review and claim precedence", async () => {
488 const { packetStatus } = await import("../src/view.tsx");
489 const state = stateView(fixtureState());
490 const id = state.packets[0].id;
491 const review = state.reviews[0];
492 assert.equal(packetStatus(id, state).label, "In review");
493 review.status = "stale";
494 review.stale = true;
495 assert.equal(packetStatus(id, state).label, "Out of date");
496 review.stale = false;
497 review.status = "approved";
498 assert.equal(packetStatus(id, state).label, "Approved");
499 review.status = "promoted";
500 assert.equal(packetStatus(id, state).label, "Promoted");
501 state.blockers[0].pushingPacket = id;
502 assert.equal(packetStatus(id, state).label, "Blocked");
503 state.blockers = [];
504 state.reviews = [];
505 assert.equal(packetStatus(id, state).label, "In progress");
506 state.claims = [];
507 assert.equal(packetStatus(id, state).label, "Ready");
508});
509
510test("optional events and real claim times are preserved; absent metadata is omitted", () => {
511 const wire = fixtureState();
512 wire.project.events = "active";
513 wire.forks[0].events = "pending";
514 const state = stateView(wire);
515 assert.equal(state.project.events, "active");
516 assert.equal(state.forks[0].events, "pending");
517 assert.equal(state.claims[0].createdAt, wire.claims[0].createdAt);
518 assert.equal(state.blockers[0].claimingAgent, wire.blockers[0].claimingAgent);
519 delete state.project.events;
520 for (const fork of state.forks) delete fork.events;
521 const html = renderDashboard({
522 brand,
523 project: "demo",
524 projects: [],
525 fixture: false,
526 state,
527 activity: [],
528 busy: false,
529 });
530 assert.doesNotMatch(
531 html,
532 /Events pending|Unavailable|Details pending|Return to agent|Load older|Exact identifiers/,
533 );
534});
535
536test("path summaries use true totals, first-push and deletion copy; details limit and expand only supplied paths", async () => {
537 const { createElement } = await import("react");
538 const { renderToStaticMarkup } = await import("react-dom/server");
539 const { createRoot } = await import("react-dom/client");
540 const { flushSync } = await import("react-dom");
541 const { Paths, PathDetails } = await import("../src/view.tsx");
542 for (const [scenario, text] of [
543 ["large", "+449 more"],
544 ["first", "First push · 450 files"],
545 ["partial", "+449 more"],
546 ["deleted", "Deleted"],
547 ]) {
548 const event = fixtureState(scenario).activity[0];
549 const html = renderToStaticMarkup(createElement(Paths, { paths: event.changedPaths, event }));
550 assert.match(html, new RegExp(text.replace(/[+·]/g, "\\$&")));
551 }
552 const dom = new JSDOM('<div id="dashboard"></div>');
553 installDom(dom.window as unknown as Window & typeof globalThis);
554 const root = dom.window.document.getElementById("dashboard")!;
555 const react = createRoot(root);
556 try {
557 const event = fixtureState("partial").activity[0];
558 flushSync(() =>
559 react.render(
560 createElement(PathDetails, {
561 paths: event.changedPaths,
562 total: event.changedPathCount,
563 partial: true,
564 }),
565 ),
566 );
567 assert.equal(root.querySelectorAll("code").length, 20);
568 assert.match(root.textContent!, /Show all 30 paths/);
569 assert.match(root.textContent!, /Showing 30 of 450 paths/);
570 root.querySelector<HTMLButtonElement>("button")!.click();
571 await until(() => root.querySelectorAll("code").length === 30);
572 assert.equal(root.querySelector("button"), null);
573 } finally {
574 flushSync(() => react.unmount());
575 dom.window.close();
576 }
577});
578
579test("promotion confirmation refuses a changed base even when the fork head and digest stay equal", async () => {
580 const context = setup();
581 try {
582 await until(() => !!context.root.querySelector("#reviews"));
583 button(context.root, "approve").click();
584 await until(() => !!button(context.root, "promote"));
585 button(context.root, "promote").click();
586 await until(
587 () => !!context.dom.window.document.querySelector('[data-action="confirm-promote"]'),
588 );
589 context.fixture.state.reviews[0].base = "9".repeat(40);
590 await context.app.refresh();
591 assert.equal(
592 context.dom.window.document.querySelector<HTMLButtonElement>(
593 '[data-action="confirm-promote"]',
594 )!.disabled,
595 true,
596 );
597 assert.equal(
598 context.fixture.calls.filter((call) => call.url.pathname.endsWith("/promote")).length,
599 0,
600 );
601 } finally {
602 context.cleanup();
603 }
604});
605
606test("superseded stale reviews leave the queue; unsuperseded stale rows do not count", async () => {
607 const { reviewQueue } = await import("../src/model.ts");
608 const state = stateView(fixtureState("superseded"));
609 assert.equal(reviewQueue(state).length, 0);
610 state.reviews[0].status = "pending";
611 assert.deepEqual(
612 reviewQueue(state).map((r) => r.id),
613 ["review-1"],
614 );
615 state.reviews[0].requestedAt = state.reviews[1].requestedAt;
616 assert.equal(reviewQueue(state).length, 2);
617 const html = renderDashboard({
618 brand,
619 project: "demo",
620 projects: [],
621 fixture: true,
622 state,
623 activity: [],
624 busy: false,
625 });
626 assert.match(html, /Fork moved/);
627 assert.match(html, /In review/);
628});
629
630test("panel defaults use first frame width and remember only a session toggle", async () => {
631 const dom = new JSDOM('<div id="dashboard"></div>', { url: origin });
632 installDom(dom.window as unknown as Window & typeof globalThis);
633 const window = dom.window as unknown as Window & typeof globalThis;
634 const root = window.document.getElementById("dashboard")!;
635 function mount(width: number) {
636 window.innerWidth = width;
637 return mountDashboard(root, {
638 brand,
639 transport: createHttpTransport({ origin, fetch: apiFixture().fetcher }),
640 initialProject: "demo",
641 });
642 }
643 try {
644 for (const width of [1340, 1340, 1100, 1100]) {
645 const app = mount(width);
646 assert.equal(
647 root.querySelector("[data-action=toggle-activity]")!.getAttribute("aria-expanded"),
648 String(width >= 1280),
649 );
650 app.dispose();
651 }
652 const app = mount(1340);
653 root.querySelector<HTMLButtonElement>("[data-action=toggle-activity]")!.click();
654 await until(
655 () =>
656 root.querySelector("[data-action=toggle-activity]")!.getAttribute("aria-expanded") ===
657 "false",
658 );
659 app.dispose();
660 const reopened = mount(1340);
661 assert.equal(
662 root.querySelector("[data-action=toggle-activity]")!.getAttribute("aria-expanded"),
663 "false",
664 );
665 reopened.dispose();
666 } finally {
667 window.close();
668 }
669});
670
671test("live pause preserves the snapshot and tabs; Reconnect clears the warning", async () => {
672 const dom = new JSDOM('<div id="dashboard"></div>');
673 installDom(dom.window as unknown as Window & typeof globalThis);
674 const root = dom.window.document.getElementById("dashboard")!;
675 const transport = createHttpTransport({ origin, fetch: apiFixture().fetcher });
676 let notify!: (paused: boolean) => void;
677 let stopped = false;
678 transport.live = {
679 subscribe(listener) {
680 notify = listener;
681 return () => {
682 stopped = true;
683 };
684 },
685 async reconnect() {
686 notify(false);
687 },
688 };
689 const app = mountDashboard(root, { brand, transport, initialProject: "demo" });
690 try {
691 await until(() => !!root.querySelector("#reviews"));
692 notify(true);
693 assert.match(
694 root.querySelector(".live-paused")!.textContent!,
695 /Live updates paused. Last updated/,
696 );
697 root.querySelectorAll<HTMLButtonElement>("[role=tab]")[3].click();
698 await until(() => !root.querySelector<HTMLElement>("#view-agents")!.hidden);
699 assert.ok(root.querySelector("#lanes table"));
700 root.querySelector<HTMLButtonElement>(".live-paused button")!.click();
701 await until(() => !root.querySelector(".live-paused"));
702 } finally {
703 app.dispose();
704 dom.window.close();
705 }
706 assert.equal(stopped, true);
707});
708