Files
fusion/packages/dashboard/app/hooks/__tests__/sseSplitIntegration.test.ts
gsxdsm f26cbedf4f fix(dashboard): close the code-review findings on the mobile tab-discard work
An 11-reviewer pass over f157bf7460..f5163d8351 found defects in the mobile
tab-discard change set itself. This fixes them.

Silent data loss (the recurring defect class):
- AgentDetailView reconnect refetched limit:100 and replaced wholesale, so 380
  displayed lines vanished with no "Load older" and no indicator; it now
  reconciles through the shared logStreamReconcile helper.
- useActivityLog.loadMore past the cap discarded the page it had just fetched
  while advancing the cursor and leaving hasMore true, so the feed silently
  stopped paginating behind a live-looking button.
- useAgentLogs: loadMore and resyncFromServer had no mutual exclusion, a
  no-overlap resync discarded explicitly paged-back history, a resync outliving
  the reconnect delay left an unmarked gap, and the live-tail trim could evict
  the gap marker itself.
- useLiveTranscript's resync overwrote live entries that raced the refetch.

The premise itself was not fully delivered:
- useProjects, useNodes, and useMeshState never called clearInterval, so they
  polled the whole time the tab was hidden. useProjects is mounted for the
  entire session, so the page never went idle -- the primary mechanism this
  work depends on. All three now use the shared visibility gate.
- sse-bus fired onReconnect twice per reconnect cycle and fanned out ~28
  subscribers in one tick, against a ~6-connection-per-origin cap on a waking
  radio. The successful open is now the single authority, and the fan-out uses
  the same exported stagger primitive as the polling path rather than a second
  copy of the slot formula.
- A channel first subscribed during the hidden window opened a live EventSource
  and keepalive; suspension is now a module-level condition openChannel
  consults, and a channel opened inside the grace window re-arms it.

Credentials and correctness:
- The service worker persisted every GET /api/* to durable Cache Storage,
  including /api/settings with daemonToken, githubAuthToken, gitlabAuthToken
  and ntfyAccessToken in plaintext, with no exclusion and no purge path --
  "Clear all cached data" only walked localStorage. Now gated, bounded, and
  genuinely purgeable.
- useTasks cleared its own snapshot when the mount revalidation failed on a
  waking radio, so the board blanked and the next restore was empty too.
  Suspension-class failures no longer destroy the cache.
- A single-row SSE update reset lastFetchTimeMs to now while an hours-old
  hydrated snapshot was on screen, re-marking every in-progress card stuck.
- ListView's "Select all visible tasks" acted on the full filtered set while
  only 50 rows rendered, so a bulk delete reached rows the operator could not
  see. Column's search window reset keyed on a boolean, so refining a query
  kept the expanded window.

Tests that could not fail:
- App.test.tsx mocked TerminalModal as isOpen ? <div/> : null, making the
  unmount-on-close invariant unobservable; MockEventSource kept its listeners
  after close(), so cases passed with their onReconnect handlers deleted.
- The SSE resync ratchet scanned only hooks/, exempting ~13 component call
  sites -- the exact regression it exists to prevent.
- MissionControlPanel's bespoke poll and the xterm scrollback constants and
  WebGL disposal had no coverage at all.

Verified: tsc -p tsconfig.app.json clean, pnpm lint clean, pnpm
check:changesets clean, 877 tests passing across 36 scoped files.
Known unrelated red: MailboxView.test.tsx's FN-8407 CSS guard fails at HEAD
too -- this diff adds no @media rule and no .mailbox-view--mobile selector,
the only two things that assertion inspects. Left alone deliberately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:17:52 -07:00

119 lines
4.9 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import type { Task } from "@fusion/core";
interface CapturedSubscription {
url: string;
onReconnect?: () => void;
events: Record<string, (e: MessageEvent) => void>;
}
const { subscriptions } = vi.hoisted(() => ({
subscriptions: [] as CapturedSubscription[],
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn(
(
url: string,
sub: { onReconnect?: () => void; events: Record<string, (e: MessageEvent) => void> },
) => {
subscriptions.push({ url, onReconnect: sub.onReconnect, events: { ...sub.events } });
return () => {};
},
),
}));
const fetchUnreadCount = vi.fn(async () => ({ unreadCount: 0 }));
vi.mock("../../api", () => ({
fetchUnreadCount: (...a: unknown[]) => fetchUnreadCount(...a),
}));
import { useMailboxUnread } from "../useMailboxUnread";
import { useApprovalBanner } from "../useApprovalBanner";
import { msg } from "./sseTestHelpers";
describe("SSE split (KTD4): mailbox-refresh vs approval-banner", () => {
beforeEach(() => {
subscriptions.length = 0;
fetchUnreadCount.mockReset();
fetchUnreadCount.mockResolvedValue({ unreadCount: 0 });
});
it("co-mount keeps mailbox counts on approval events and task plan-approval out of the banner", async () => {
const tasks: Task[] = [];
const onStarPrompt = vi.fn();
// Two independent mounts → two subscribeSse calls captured separately so
// the split handlers never overwrite each other.
renderHook(() => useMailboxUnread("p1"));
const approval = renderHook(() =>
useApprovalBanner({
tasks,
currentProjectId: "p1",
gitHubStarPromptShown: true,
onStarPrompt,
}),
);
// Drain the mailbox hook's mount fetch deterministically — wait for the
// refresh call to fire and settle, so its setState doesn't leak past the
// test. (Replaces a magic 2x microtask flush.)
await act(async () => {
await waitFor(() => expect(fetchUnreadCount).toHaveBeenCalled());
});
// Distinguish the two subscriptions: mailbox listens to message:sent,
// the banner listens to task:updated.
const mailboxSub = subscriptions.find((s) => "message:sent" in s.events);
const approvalSub = subscriptions.find((s) => "task:updated" in s.events);
expect(mailboxSub).toBeTruthy();
expect(approvalSub).toBeTruthy();
/*
FNXC:SSE-Split-Reconnect 2026-07-26-18:05:
The split extends to reconnect handling, but NOT as "only the mailbox resyncs". Per the sse-bus
contract every subscriber must declare a resync path: the mailbox subscription re-fetches counts,
and the approval banner re-reads the authoritative pending list. This test previously asserted the
banner had NO onReconnect, which encoded the exact defect the mobile hidden-tab suspend work fixed —
an approval raised while the tab was backgrounded stayed invisible and its agent blocked forever.
What the split still guarantees is that the two resyncs are SEPARATE functions owned by their own
hooks, not one shared handler.
*/
expect(mailboxSub!.onReconnect).toBeTruthy();
expect(approvalSub!.onReconnect).toBeTruthy();
expect(mailboxSub!.onReconnect).not.toBe(approvalSub!.onReconnect);
// (i) approval:requested sets the banner candidate; the mailbox hook's
// approval:requested handler (count refresh) remains a distinct
// function from the banner's.
act(() => {
approvalSub!.events["approval:requested"]?.(msg({ id: "a1", updatedAt: "2026-01-01T00:00:00Z" }));
});
expect(approval.result.current.candidate?.dedupeKey).toBe("approval:a1");
expect(mailboxSub!.events["approval:requested"]).toBeTruthy();
expect(mailboxSub!.events["approval:requested"]).not.toBe(approvalSub!.events["approval:requested"]);
// (ib) … and the mailbox handler actually refreshes the count (wires to
// fetchUnreadCount), proving it's a live handler — not merely present.
const refreshCallsBefore = fetchUnreadCount.mock.calls.length;
act(() => {
mailboxSub!.events["approval:requested"]?.(msg({ id: "a2", updatedAt: "2026-01-02T00:00:00Z" }));
});
expect(fetchUnreadCount).toHaveBeenCalledTimes(refreshCallsBefore + 1);
act(() => {
approval.result.current.dismissApproval(approval.result.current.candidate!);
});
// (ii) task:updated → awaiting-approval is plan approval, not mailbox
// approval: no banner candidate and no mailbox-count refresh.
const refreshCallsAfterApproval = fetchUnreadCount.mock.calls.length;
act(() => {
approvalSub!.events["task:updated"]?.(
msg({ id: "t1", status: "awaiting-approval", updatedAt: "2026-01-02T00:00:00Z" }),
);
});
expect(approval.result.current.candidate).toBeNull();
expect(fetchUnreadCount).toHaveBeenCalledTimes(refreshCallsAfterApproval);
});
});