Files
fusion/packages/dashboard/app/hooks/__tests__/useActivityLog.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

364 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { useActivityLog } from "../useActivityLog";
import * as apiModule from "../../api";
import type { ActivityFeedEntry } from "../../api";
// Mock the API module
vi.mock("../../api", () => ({
fetchActivityFeed: vi.fn(),
fetchActivityLog: vi.fn(),
}));
const mockFetchActivityFeed = vi.mocked(apiModule.fetchActivityFeed);
const mockFetchActivityLog = vi.mocked(apiModule.fetchActivityLog);
/** Create ActivityFeedEntry[] entries (unified feed format) */
function createFeedEntries(
count: number,
projectId = "proj_123",
projectName = "Test Project",
): ActivityFeedEntry[] {
return Array.from({ length: count }, (_, i) => ({
id: `feed_entry_${i}`,
timestamp: new Date(Date.now() - i * 60000).toISOString(),
type: "task:created" as const,
projectId,
projectName,
taskId: "FN-001",
taskTitle: "Test Task",
details: "Task created",
}));
}
describe("useActivityLog", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
// Default: both mocks return empty arrays
mockFetchActivityFeed.mockResolvedValue([]);
mockFetchActivityLog.mockResolvedValue([]);
});
afterEach(() => {
vi.useRealTimers();
});
// ── Single-project mode (default) ─────────────────────────────────
it("initializes with empty entries and loads on mount", async () => {
mockFetchActivityLog.mockResolvedValue([]);
const { result } = renderHook(() => useActivityLog());
expect(result.current.loading).toBe(true);
expect(result.current.entries).toEqual([]);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.entries).toEqual([]);
// Should use per-project log, not unified feed
expect(mockFetchActivityLog).toHaveBeenCalled();
expect(mockFetchActivityFeed).not.toHaveBeenCalled();
});
it("fetches entries from per-project log in single-project mode", async () => {
const mockEntries = createFeedEntries(1);
mockFetchActivityLog.mockResolvedValue(
mockEntries.map((e) => ({
id: e.id,
timestamp: e.timestamp,
type: e.type,
taskId: e.taskId,
taskTitle: e.taskTitle,
details: e.details,
metadata: e.metadata,
})),
);
const { result } = renderHook(() => useActivityLog());
await waitFor(() => {
expect(result.current.entries).toHaveLength(1);
});
// Hook converts ActivityLogEntry to ActivityFeedEntry with empty project fields
expect(result.current.entries[0].type).toBe("task:created");
expect(mockFetchActivityLog).toHaveBeenCalled();
expect(mockFetchActivityFeed).not.toHaveBeenCalled();
});
it("filters by type via per-project log", async () => {
mockFetchActivityLog.mockResolvedValue([]);
renderHook(() => useActivityLog({ type: "task:created" }));
await waitFor(() => {
expect(mockFetchActivityLog).toHaveBeenCalledWith(
expect.objectContaining({ type: "task:created" }),
);
});
});
it("respects custom limit via per-project log", async () => {
mockFetchActivityLog.mockResolvedValue([]);
renderHook(() => useActivityLog({ limit: 100 }));
await waitFor(() => {
expect(mockFetchActivityLog).toHaveBeenCalledWith(
expect.objectContaining({ limit: 100 }),
);
});
});
it("does not auto-refresh when disabled", async () => {
mockFetchActivityLog.mockResolvedValue([]);
renderHook(() => useActivityLog({ autoRefresh: false }));
await waitFor(() => {
expect(mockFetchActivityLog).toHaveBeenCalledTimes(1);
});
// FNXC:ActivityLogTests 2026-06-27-17:10:
// Prove the negative (no auto-refresh poll fires when autoRefresh:false) with
// fake timers instead of a real wall-clock sleep. The hook polls every
// POLL_INTERVAL_MS (5000ms); advancing well past two intervals deterministically
// exercises the disabled-interval path with zero real wait (FN-5048: no slow tests).
await act(async () => {
await vi.advanceTimersByTimeAsync(12_000);
});
expect(mockFetchActivityLog).toHaveBeenCalledTimes(1);
});
it("refresh function manually refreshes data", async () => {
mockFetchActivityLog.mockResolvedValue([]);
const { result } = renderHook(() => useActivityLog({ autoRefresh: false }));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
act(() => {
result.current.refresh();
});
await waitFor(() => {
expect(mockFetchActivityLog).toHaveBeenCalledTimes(2);
});
});
it("clear removes all entries", async () => {
const mockEntries = createFeedEntries(1);
mockFetchActivityLog.mockResolvedValue(
mockEntries.map((e) => ({
id: e.id,
timestamp: e.timestamp,
type: e.type,
taskId: e.taskId,
taskTitle: e.taskTitle,
details: e.details,
})),
);
const { result } = renderHook(() => useActivityLog());
await waitFor(() => {
expect(result.current.entries).toHaveLength(1);
});
act(() => {
result.current.clear();
});
expect(result.current.entries).toEqual([]);
expect(result.current.hasMore).toBe(false);
});
it("handles errors gracefully", async () => {
mockFetchActivityLog.mockRejectedValue(new Error("Server error"));
const { result } = renderHook(() => useActivityLog());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).not.toBeNull();
});
it("sets hasMore when entries equal limit", async () => {
const mockEntries = createFeedEntries(50);
mockFetchActivityLog.mockResolvedValue(
mockEntries.map((e) => ({
id: e.id,
timestamp: e.timestamp,
type: e.type,
taskId: e.taskId,
taskTitle: e.taskTitle,
details: e.details,
})),
);
const { result } = renderHook(() => useActivityLog({ limit: 50 }));
await waitFor(() => {
expect(result.current.entries).toHaveLength(50);
});
expect(result.current.hasMore).toBe(true);
});
it("sets hasMore to false when fewer entries than limit", async () => {
const mockEntries = createFeedEntries(30);
mockFetchActivityLog.mockResolvedValue(
mockEntries.map((e) => ({
id: e.id,
timestamp: e.timestamp,
type: e.type,
taskId: e.taskId,
taskTitle: e.taskTitle,
details: e.details,
})),
);
const { result } = renderHook(() => useActivityLog({ limit: 50 }));
await waitFor(() => {
expect(result.current.entries).toHaveLength(30);
});
expect(result.current.hasMore).toBe(false);
});
// ── Multi-project mode (useCentralFeed) ───────────────────────────
it("fetches from unified feed when useCentralFeed is true", async () => {
const mockEntries = createFeedEntries(2, "proj_multi", "Multi Project");
mockFetchActivityFeed.mockResolvedValue(mockEntries);
const { result } = renderHook(() =>
useActivityLog({ useCentralFeed: true }),
);
await waitFor(() => {
expect(result.current.entries).toHaveLength(2);
});
expect(result.current.entries[0].projectName).toBe("Multi Project");
expect(mockFetchActivityFeed).toHaveBeenCalled();
expect(mockFetchActivityLog).not.toHaveBeenCalled();
});
it("passes projectId to unified feed when useCentralFeed is true", async () => {
mockFetchActivityFeed.mockResolvedValue([]);
renderHook(() =>
useActivityLog({ projectId: "proj_456", useCentralFeed: true }),
);
await waitFor(() => {
expect(mockFetchActivityFeed).toHaveBeenCalledWith(
expect.objectContaining({ projectId: "proj_456" }),
);
});
});
// ── Retention cap vs. pagination ──────────────────────────────────
/*
FNXC:ActivityLogPaging 2026-07-26-18:45:
Regression coverage for a silent pagination stop. With the 500-entry retention cap applied as
`merged.slice(0, MAX)`, the eleventh "Load more" (limit 50) fetched a page and then threw away exactly
that page, while `lastTimestampRef` still advanced past it and `hasMore` stayed true. The feed stopped
moving, the button kept promising it would, and the skipped entries became unreachable because the
cursor had passed them.
The invariant asserted here is the user-visible one: every click of an offered "Load more" must put
entries on screen that were not there before, at the cap as well as below it.
*/
function paginatedLogPage(page: number, size: number) {
return Array.from({ length: size }, (_, i) => ({
id: `p${page}_${i}`,
timestamp: new Date(Date.UTC(2024, 0, 1) - (page * size + i) * 60_000).toISOString(),
type: "task:created" as const,
taskId: "FN-001",
taskTitle: "Test Task",
details: "Task created",
}));
}
it("keeps the page it just fetched when loadMore crosses the retention cap", async () => {
const PAGE = 50;
let page = 0;
mockFetchActivityLog.mockImplementation(async () => paginatedLogPage(page++, PAGE));
const { result } = renderHook(() => useActivityLog({ limit: PAGE, autoRefresh: false }));
await waitFor(() => expect(result.current.entries).toHaveLength(PAGE));
// Nine clicks fill the buffer exactly to the 500-entry cap.
for (let click = 0; click < 9; click++) {
await act(async () => {
await result.current.loadMore();
});
}
expect(result.current.entries).toHaveLength(500);
expect(result.current.hasMore).toBe(true);
const idsAtCap = result.current.entries.map((entry) => entry.id);
// The tenth click is the one that used to be a no-op.
await act(async () => {
await result.current.loadMore();
});
const idsAfter = result.current.entries.map((entry) => entry.id);
expect(idsAfter).toHaveLength(500);
expect(idsAfter).not.toEqual(idsAtCap);
// The whole freshly fetched (older) page is present…
expect(idsAfter).toContain("p10_0");
expect(idsAfter).toContain("p10_49");
// …paid for from the head, which `refresh` can fetch again from offset 0.
expect(idsAfter).not.toContain("p0_0");
expect(mockFetchActivityLog).toHaveBeenCalledTimes(11);
});
it("continues to page backwards across several clicks past the cap", async () => {
const PAGE = 50;
let page = 0;
mockFetchActivityLog.mockImplementation(async () => paginatedLogPage(page++, PAGE));
const { result } = renderHook(() => useActivityLog({ limit: PAGE, autoRefresh: false }));
await waitFor(() => expect(result.current.entries).toHaveLength(PAGE));
for (let click = 0; click < 12; click++) {
await act(async () => {
await result.current.loadMore();
});
}
// Twelve clicks past a 50-entry first page = pages 0..12; the oldest page must be on screen.
expect(result.current.entries.map((entry) => entry.id)).toContain("p12_49");
expect(result.current.entries).toHaveLength(500);
});
it("passes type filter to unified feed when useCentralFeed is true", async () => {
mockFetchActivityFeed.mockResolvedValue([]);
renderHook(() =>
useActivityLog({ type: "task:failed", useCentralFeed: true }),
);
await waitFor(() => {
expect(mockFetchActivityFeed).toHaveBeenCalledWith(
expect.objectContaining({ type: "task:failed" }),
);
});
});
});