Files
fusion/packages/dashboard/app/hooks/__tests__/log-buffer-caps.test.ts
gsxdsm f157bf7460 fix(dashboard): harden visibility suspension, log caps, and mobile board UX
Suspend poll/SSE work when the tab is hidden, cap log buffers, restore board scroll more reliably, and improve list windowing/live tickers with related tests and a mobile-tab retention changeset.
2026-07-26 09:50:44 -07:00

172 lines
6.4 KiB
TypeScript

/*
FNXC:MobileTabRetention 2026-07-26-11:40:
Regression coverage for the bounded-log-buffer invariant. Mobile browsers discard a backgrounded tab
whose resident set keeps growing, which the operator experiences as a white-splash reload on return;
every live log tail must therefore be a bounded ring that retains the NEWEST entries.
Surface enumeration — the invariant is asserted for every append path changed for this fix, not just
one repro:
- `capLogEntries` itself (the single shared helper the streaming surfaces call),
- `useAgentLogs` live SSE tail (TaskDetailModal per-task log),
- the AgentDetailView / SystemControlsArea tails, which delegate to `capLogEntries` and are covered
through it plus the SystemControlsArea cap constant,
- `appendChatMessageChronologically`, which is deliberately NOT capped (user-visible transcript)
and is asserted to keep every message while still producing chronological order.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { MAX_LOG_ENTRIES, capLogEntries, useAgentLogs } from "../useAgentLogs";
import { appendChatMessageChronologically, type ChatMessageInfo } from "../useChat";
import { LOG_VIEW_CAP } from "../../components/command-center/areas/SystemControlsArea";
import { fetchAgentLogsWithMeta } from "../../api";
import { MockEventSource } from "../../../vitest.setup";
vi.mock("../../api", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
fetchAgentLogsWithMeta: vi.fn().mockResolvedValue({ entries: [], total: 0, hasMore: false }),
}));
const mockFetchAgentLogsWithMeta = vi.mocked(fetchAgentLogsWithMeta);
function getConnection(taskId: string): MockEventSource | undefined {
const url = `/api/tasks/${taskId}/logs/stream`;
const matching = MockEventSource.instances.filter((e) => e.url === url);
return matching[matching.length - 1];
}
function makeEntry(index: number) {
return {
timestamp: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(),
taskId: "FN-001",
text: `entry-${index}`,
type: "text" as const,
};
}
beforeEach(() => {
MockEventSource.instances = [];
mockFetchAgentLogsWithMeta.mockReset().mockResolvedValue({ entries: [], total: 0, hasMore: false });
vi.useRealTimers();
});
afterEach(() => {
for (const instance of MockEventSource.instances) {
instance.close();
}
MockEventSource.instances = [];
vi.useRealTimers();
});
describe("capLogEntries", () => {
it("keeps the newest entries once the cap is exceeded", () => {
const entries = Array.from({ length: MAX_LOG_ENTRIES + 25 }, (_, i) => i);
const capped = capLogEntries(entries);
expect(capped).toHaveLength(MAX_LOG_ENTRIES);
expect(capped[0]).toBe(25);
expect(capped.at(-1)).toBe(MAX_LOG_ENTRIES + 24);
});
it("returns the same array reference when under the cap", () => {
const entries = [1, 2, 3];
expect(capLogEntries(entries)).toBe(entries);
});
it("honors an explicit cap (the Command Center system panel shares this helper)", () => {
expect(LOG_VIEW_CAP).toBe(MAX_LOG_ENTRIES);
const lines = Array.from({ length: LOG_VIEW_CAP + 10 }, (_, i) => i);
const capped = capLogEntries(lines, LOG_VIEW_CAP);
expect(capped).toHaveLength(LOG_VIEW_CAP);
expect(capped.at(-1)).toBe(LOG_VIEW_CAP + 9);
});
});
describe("useAgentLogs live tail", () => {
it("bounds the streamed tail and retains the newest entries", async () => {
const { result } = renderHook(() => useAgentLogs("FN-001", true));
await waitFor(() => {
expect(getConnection("FN-001")).toBeDefined();
});
const es = getConnection("FN-001")!;
const overflow = MAX_LOG_ENTRIES + 40;
act(() => {
for (let index = 0; index < overflow; index++) {
es._emit("agent:log", makeEntry(index));
}
});
await waitFor(() => {
expect(result.current.entries).toHaveLength(MAX_LOG_ENTRIES);
});
// Newest-wins: the oldest 40 frames were dropped, the tail is intact.
expect(result.current.entries[0].text).toBe("entry-40");
expect(result.current.entries.at(-1)?.text).toBe(`entry-${overflow - 1}`);
// The reader must not mistake a trimmed tail for the whole log: older entries
// remain fetchable, so the "load older" affordance stays available.
expect(result.current.hasMore).toBe(true);
});
it("holds a user-paged buffer at its size instead of collapsing it to the cap", async () => {
const paged = Array.from({ length: MAX_LOG_ENTRIES + 100 }, (_, i) => makeEntry(i));
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: paged, total: paged.length, hasMore: false });
const { result } = renderHook(() => useAgentLogs("FN-001", true));
await waitFor(() => {
expect(result.current.entries).toHaveLength(paged.length);
});
const es = getConnection("FN-001")!;
act(() => {
es._emit("agent:log", makeEntry(9999));
});
await waitFor(() => {
expect(result.current.entries.at(-1)?.text).toBe("entry-9999");
});
// Bounded: the buffer stays at the size the user paged to (one oldest entry drops per
// new line) rather than growing forever OR snapping back down to MAX_LOG_ENTRIES.
expect(result.current.entries).toHaveLength(paged.length);
expect(result.current.entries[0].text).toBe("entry-1");
});
});
describe("appendChatMessageChronologically", () => {
const message = (id: string, createdAt: string): ChatMessageInfo => ({
id,
sessionId: "session-1",
role: "user",
content: id,
createdAt,
});
it("never drops user-visible transcript messages", () => {
let transcript: ChatMessageInfo[] = [];
for (let index = 0; index < MAX_LOG_ENTRIES + 50; index++) {
transcript = appendChatMessageChronologically(
transcript,
message(`m-${index}`, new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString()),
);
}
expect(transcript).toHaveLength(MAX_LOG_ENTRIES + 50);
expect(transcript[0].id).toBe("m-0");
expect(transcript.at(-1)?.id).toBe(`m-${MAX_LOG_ENTRIES + 49}`);
});
it("restores chronological order when a message arrives out of order", () => {
const first = message("a", "2026-01-01T00:00:00.000Z");
const third = message("c", "2026-01-01T00:00:02.000Z");
const second = message("b", "2026-01-01T00:00:01.000Z");
const result = appendChatMessageChronologically([first, third], second);
expect(result.map((m) => m.id)).toEqual(["a", "b", "c"]);
});
});