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

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

Snapshot at 34e3175

Containing directory

26,224 bytes · typescriptView raw
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