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>
358 lines
13 KiB
TypeScript
358 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { render, screen, act } from "@testing-library/react";
|
|
import type { LiveSnapshot } from "@fusion/core";
|
|
|
|
// Mock the api() helper so the panel fetches deterministic snapshots.
|
|
const apiMock = vi.fn();
|
|
vi.mock("../../../api/legacy", () => ({
|
|
fetchCodebaseMetrics: vi.fn().mockResolvedValue({ tokenEstimate: 0, sourceFileCount: 0, sourceByteCount: 0, diskBytes: 0, diskFileCount: 0, method: "local", truncated: false }),
|
|
api: (path: string, opts?: RequestInit) => apiMock(path, opts),
|
|
withProjectId: (path: string, projectId?: string) =>
|
|
projectId ? `${path}${path.includes("?") ? "&" : "?"}projectId=${encodeURIComponent(projectId)}` : path,
|
|
}));
|
|
|
|
// Mock the SSE bus, capturing the subscription so tests can fire events and
|
|
// assert subscribe/unsubscribe behavior.
|
|
type SseEvents = Record<string, (e: unknown) => void>;
|
|
let sseHandlers: SseEvents = {};
|
|
let sseOnReconnect: (() => void) | undefined;
|
|
const unsubscribeMock = vi.fn();
|
|
const subscribeMock = vi.fn((_url: string, sub: { events?: SseEvents; onReconnect?: () => void }) => {
|
|
sseHandlers = sub.events ?? {};
|
|
sseOnReconnect = sub.onReconnect;
|
|
return unsubscribeMock;
|
|
});
|
|
vi.mock("../../../sse-bus", () => ({
|
|
subscribeSse: (url: string, sub: { events?: SseEvents; onReconnect?: () => void }) => subscribeMock(url, sub),
|
|
}));
|
|
|
|
import { MissionControlPanel, LIVE_POLL_INTERVAL_MS, NODE_STALE_THRESHOLD_MS } from "../MissionControlPanel";
|
|
|
|
function snapshot(overrides: Partial<LiveSnapshot> = {}): LiveSnapshot {
|
|
return {
|
|
capturedAt: "2026-06-15T12:00:00.000Z",
|
|
activeSessions: 0,
|
|
activeRuns: 0,
|
|
activeNodes: 0,
|
|
sessions: [],
|
|
runs: [],
|
|
columns: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function activeSession(id: string, overrides: Partial<LiveSnapshot["sessions"][number]> = {}) {
|
|
return {
|
|
id,
|
|
taskId: `task-${id}`,
|
|
purpose: `purpose-${id}`,
|
|
adapterId: "claude-local",
|
|
agentState: "active",
|
|
worktreePath: `/repo/wt-${id}`,
|
|
updatedAt: "2026-06-15T12:00:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Drive the real `document.visibilityState` + `visibilitychange` pair the panel listens on.
|
|
* jsdom's visibilityState is read-only, so it is redefined per call.
|
|
*/
|
|
function setVisibility(state: "visible" | "hidden"): void {
|
|
Object.defineProperty(document, "visibilityState", { value: state, configurable: true });
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
apiMock.mockReset();
|
|
subscribeMock.mockClear();
|
|
unsubscribeMock.mockClear();
|
|
sseHandlers = {};
|
|
sseOnReconnect = undefined;
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
setVisibility("visible");
|
|
vi.runOnlyPendingTimers();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
/** Flush microtasks (awaited promises) under fake timers. */
|
|
async function flush() {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
}
|
|
|
|
describe("MissionControlPanel — KTD5 push + poll convergence", () => {
|
|
it("renders an active session and removes it when it ends", async () => {
|
|
// Initial: one active session.
|
|
apiMock.mockResolvedValueOnce(
|
|
snapshot({ activeSessions: 1, activeNodes: 1, sessions: [activeSession("s1")] }),
|
|
);
|
|
render(<MissionControlPanel />);
|
|
await flush();
|
|
|
|
expect(screen.getByTestId("mission-control-session-s1")).toBeTruthy();
|
|
expect(screen.getByTestId("mission-control-active-sessions").textContent).toContain("1");
|
|
|
|
// Next fetch (via SSE push): the session ended → empty snapshot.
|
|
apiMock.mockResolvedValueOnce(snapshot());
|
|
await act(async () => {
|
|
sseHandlers["session:completed"]?.({});
|
|
});
|
|
await flush();
|
|
|
|
expect(screen.queryByTestId("mission-control-session-s1")).toBeNull();
|
|
expect(screen.getByTestId("mission-control-idle")).toBeTruthy();
|
|
});
|
|
|
|
it("appends projectId to the live snapshot request when supplied, and omits it when not", async () => {
|
|
apiMock.mockResolvedValue(snapshot());
|
|
const { unmount } = render(<MissionControlPanel />);
|
|
await flush();
|
|
expect(apiMock.mock.calls.at(-1)?.[0]).toBe("/command-center/live");
|
|
unmount();
|
|
|
|
apiMock.mockClear();
|
|
apiMock.mockResolvedValue(snapshot());
|
|
render(<MissionControlPanel projectId="proj-abc" />);
|
|
await flush();
|
|
expect(apiMock.mock.calls.at(-1)?.[0]).toBe("/command-center/live?projectId=proj-abc");
|
|
});
|
|
|
|
it("does NOT schedule a poll interval when idle (zero active sessions)", async () => {
|
|
apiMock.mockResolvedValue(snapshot()); // idle from the start
|
|
const setInterval = vi.spyOn(globalThis, "setInterval");
|
|
render(<MissionControlPanel />);
|
|
await flush();
|
|
|
|
// No interval was ever scheduled because there is no work in-flight.
|
|
expect(setInterval).not.toHaveBeenCalled();
|
|
|
|
// Advancing well past the poll cadence triggers no further fetches.
|
|
apiMock.mockClear();
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS * 3);
|
|
});
|
|
await flush();
|
|
expect(apiMock).not.toHaveBeenCalled();
|
|
|
|
setInterval.mockRestore();
|
|
});
|
|
|
|
it("schedules a poll interval only while work is in-flight and stops it when idle", async () => {
|
|
// In-flight snapshot → interval should arm.
|
|
apiMock.mockResolvedValueOnce(
|
|
snapshot({ activeSessions: 1, activeNodes: 1, sessions: [activeSession("s1")] }),
|
|
);
|
|
render(<MissionControlPanel />);
|
|
await flush();
|
|
|
|
// Poll tick fires while in-flight → another fetch (now idle).
|
|
apiMock.mockResolvedValueOnce(snapshot());
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS);
|
|
});
|
|
await flush();
|
|
expect(screen.getByTestId("mission-control-idle")).toBeTruthy();
|
|
|
|
// Now idle: further ticks must NOT fetch (interval was cleared).
|
|
apiMock.mockClear();
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS * 3);
|
|
});
|
|
await flush();
|
|
expect(apiMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refetches immediately on an SSE push, even between poll ticks", async () => {
|
|
apiMock.mockResolvedValueOnce(snapshot()); // idle → no poll interval
|
|
render(<MissionControlPanel />);
|
|
await flush();
|
|
apiMock.mockClear();
|
|
|
|
// A push event arrives with NO timer advance: it must trigger a refetch.
|
|
apiMock.mockResolvedValueOnce(
|
|
snapshot({ activeSessions: 1, activeNodes: 1, sessions: [activeSession("s2")] }),
|
|
);
|
|
await act(async () => {
|
|
sseHandlers["run:created"]?.({});
|
|
});
|
|
await flush();
|
|
|
|
expect(apiMock).toHaveBeenCalledTimes(1);
|
|
expect(screen.getByTestId("mission-control-session-s2")).toBeTruthy();
|
|
});
|
|
|
|
it("marks a node with no recent heartbeat as inactive rather than dropping it", async () => {
|
|
const capturedAt = "2026-06-15T12:00:00.000Z";
|
|
const capturedMs = Date.parse(capturedAt);
|
|
const staleAt = new Date(capturedMs - NODE_STALE_THRESHOLD_MS - 5_000).toISOString();
|
|
const freshAt = new Date(capturedMs - 1_000).toISOString();
|
|
|
|
apiMock.mockResolvedValueOnce(
|
|
snapshot({
|
|
capturedAt,
|
|
activeSessions: 2,
|
|
activeNodes: 2,
|
|
sessions: [
|
|
activeSession("fresh", { worktreePath: "/repo/fresh-node", updatedAt: freshAt }),
|
|
activeSession("stale", { worktreePath: "/repo/stale-node", updatedAt: staleAt }),
|
|
],
|
|
}),
|
|
);
|
|
render(<MissionControlPanel />);
|
|
await flush();
|
|
|
|
// Both nodes are still present (stale one not dropped).
|
|
const freshNode = screen.getByTestId("mission-control-node-fresh-node");
|
|
const staleNode = screen.getByTestId("mission-control-node-stale-node");
|
|
expect(freshNode.getAttribute("data-inactive")).toBe("false");
|
|
expect(staleNode.getAttribute("data-inactive")).toBe("true");
|
|
});
|
|
|
|
it("subscribes to the SSE bus on mount and unsubscribes on unmount", async () => {
|
|
apiMock.mockResolvedValue(snapshot());
|
|
const { unmount } = render(<MissionControlPanel />);
|
|
await flush();
|
|
|
|
expect(subscribeMock).toHaveBeenCalledTimes(1);
|
|
expect(subscribeMock.mock.calls[0][0]).toBe("/api/events");
|
|
expect(typeof sseOnReconnect).toBe("function");
|
|
|
|
unmount();
|
|
expect(unsubscribeMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("renders the live SDLC funnel from current column counts", async () => {
|
|
apiMock.mockResolvedValueOnce(
|
|
snapshot({
|
|
activeSessions: 1,
|
|
sessions: [activeSession("s1")],
|
|
columns: [
|
|
{ column: "triage", count: 4 },
|
|
{ column: "in-progress", count: 2 },
|
|
{ column: "done", count: 7 },
|
|
{ column: "custom-column", count: 3 },
|
|
],
|
|
}),
|
|
);
|
|
render(<MissionControlPanel />);
|
|
await flush();
|
|
|
|
const funnel = screen.getByTestId("mission-control-funnel");
|
|
expect(funnel).toBeTruthy();
|
|
// The unmapped "custom-column" folds into an "Other" stage, not dropped.
|
|
expect(funnel.textContent).toContain("3");
|
|
expect(funnel.textContent).toContain("7");
|
|
});
|
|
|
|
it("surfaces a hard error when the first fetch fails with no prior data", async () => {
|
|
apiMock.mockRejectedValueOnce(new Error("boom"));
|
|
render(<MissionControlPanel />);
|
|
await flush();
|
|
expect(screen.getByTestId("mission-control-error")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
/*
|
|
FNXC:MobileTabRetention 2026-07-26-17:05:
|
|
`useLiveSnapshot` does NOT use the shared `useVisibilityAwarePoll` primitive — it hand-rolls its own
|
|
`visibilitychange` listener because the poll is self-managing (each response decides whether to re-arm).
|
|
Consequence: it is invisible to `visibility-poll-gating.test.ts`'s sampled surfaces AND to the shared
|
|
hook's unit tests, so until this block existed a regression that reintroduced background polling on a
|
|
backgrounded tab would have passed the entire suite. A 5s fetch loop on a hidden tab is a primary iOS
|
|
Safari / Chrome Android page-discard signal, and the discard is the white-splash reload on return.
|
|
|
|
Two independent behaviors are asserted, because there are two independent ways to reintroduce the leak:
|
|
1. The hidden edge must CLEAR the running interval (the listener's `stopPolling`).
|
|
2. A refetch that completes while hidden must not RE-ARM it (the `documentVisible` gate in `load`'s
|
|
`finally`). Deleting either one alone leaves the other passing, so both are tested separately.
|
|
The visible edge must then refetch exactly once and resume polling — suspension that never resumes is
|
|
the same silent-staleness defect wearing different clothes.
|
|
*/
|
|
describe("MissionControlPanel — hidden-tab poll gating", () => {
|
|
it("stops polling while the document is hidden and fires no background fetches", async () => {
|
|
apiMock.mockResolvedValue(
|
|
snapshot({ activeSessions: 1, activeNodes: 1, sessions: [activeSession("s1")] }),
|
|
);
|
|
render(<MissionControlPanel />);
|
|
await flush();
|
|
|
|
// In-flight work: the interval is armed and a tick fetches.
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS);
|
|
});
|
|
await flush();
|
|
expect(apiMock.mock.calls.length).toBeGreaterThan(1);
|
|
|
|
setVisibility("hidden");
|
|
await flush();
|
|
|
|
apiMock.mockClear();
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS * 3);
|
|
});
|
|
await flush();
|
|
expect(apiMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not re-arm the poll interval from a refetch that completes while hidden", async () => {
|
|
apiMock.mockResolvedValue(
|
|
snapshot({ activeSessions: 1, activeNodes: 1, sessions: [activeSession("s1")] }),
|
|
);
|
|
render(<MissionControlPanel />);
|
|
await flush();
|
|
|
|
setVisibility("hidden");
|
|
await flush();
|
|
|
|
// An SSE push (or the reconnect resync) can still land in the window before the bus itself
|
|
// suspends. Its response reports work in-flight — but a hidden tab must not schedule a timer.
|
|
await act(async () => {
|
|
sseHandlers["run:created"]?.({});
|
|
});
|
|
await flush();
|
|
|
|
apiMock.mockClear();
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS * 3);
|
|
});
|
|
await flush();
|
|
expect(apiMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refetches exactly once on the hidden -> visible edge and re-arms polling", async () => {
|
|
apiMock.mockResolvedValue(
|
|
snapshot({ activeSessions: 1, activeNodes: 1, sessions: [activeSession("s1")] }),
|
|
);
|
|
render(<MissionControlPanel />);
|
|
await flush();
|
|
|
|
setVisibility("hidden");
|
|
await flush();
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS * 3);
|
|
});
|
|
await flush();
|
|
|
|
apiMock.mockClear();
|
|
setVisibility("visible");
|
|
await flush();
|
|
|
|
// Exactly one refresh on the edge — not zero (stale panel) and not a burst.
|
|
expect(apiMock).toHaveBeenCalledTimes(1);
|
|
|
|
// ...and polling is live again, so the panel keeps converging while in view.
|
|
apiMock.mockClear();
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS);
|
|
});
|
|
await flush();
|
|
expect(apiMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|