// @vitest-environment jsdom import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useNativeRunTranscripts } from "./useNativeRunTranscripts"; import { TRANSCRIPT_REQUEST_TIMEOUT_MS } from "./read-transcript-request"; const eventsMock = vi.hoisted(() => vi.fn()); const conversationEventsMock = vi.hoisted(() => vi.fn()); vi.mock("@/api/heartbeats", () => ({ heartbeatsApi: { events: eventsMock, conversationEvents: conversationEventsMock }, })); function Probe() { const { errorsByRun } = useNativeRunTranscripts([ { id: "native-run", status: "succeeded", runtimeMode: "native" }, ]); return (
{[...errorsByRun.keys()].join(",")}
); } function MultiRunProbe() { useNativeRunTranscripts([ { id: "failed-run", status: "succeeded", runtimeMode: "native" }, { id: "healthy-run", status: "succeeded", runtimeMode: "native" }, ]); return null; } describe("useNativeRunTranscripts", () => { let container: HTMLDivElement; let root: Root; beforeEach(() => { vi.useFakeTimers(); eventsMock.mockReset(); container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); container.remove(); vi.useRealTimers(); }); it("expands from sequence zero without duplicating the preview or withholding new runs", async () => { let state: ReturnType; const event = (seq: number, text: string) => ({ id: `event-${seq}`, runId: "old", companyId: "company", seq, eventType: "item.completed", createdAt: new Date().toISOString(), payload: { prpEvent: { schema: "paperclip.prp.event.v1", eventType: "item.completed", runId: "old", schemaVersion: 1, emittedAt: new Date().toISOString(), itemId: `item-${seq}`, payload: { kind: "agentMessage", text } } }, }); conversationEventsMock.mockResolvedValue([event(3, "Latest answer")]); eventsMock.mockImplementation((id) => Promise.resolve(id === "old" ? [event(1, "Earlier answer"), event(3, "Latest answer")] : [])); function Window({ compact }: { compact: boolean }) { state = useNativeRunTranscripts([ { id: "old", runtimeMode: "native", status: "succeeded", conversationOnly: compact }, { id: "new", runtimeMode: "native", status: "succeeded" }, ]); return null; } await act(async () => { root.render(); }); expect(conversationEventsMock).toHaveBeenCalledWith("old", 0, 1000, expect.any(Object)); expect(eventsMock).toHaveBeenCalledWith("new", 0, 1000, expect.any(Object)); await act(async () => { root.render(); }); expect(eventsMock).toHaveBeenCalledWith("old", 0, 1000, expect.any(Object)); expect(state!.transcriptByRun.get("old")?.filter((entry) => entry.kind === "assistant").map((entry) => entry.text)).toEqual(["Earlier answer", "Latest answer"]); }); it("exposes event transport failures and retries terminal runs until recovery", async () => { eventsMock .mockRejectedValueOnce(new Error("event endpoint unavailable")) .mockResolvedValue([]); await act(async () => { root.render(); await Promise.resolve(); }); expect(container.textContent).toBe("native-run"); await act(async () => { await vi.advanceTimersByTimeAsync(2_000); }); expect(eventsMock).toHaveBeenCalledTimes(2); expect(container.textContent).toBe(""); }); it("retries only terminal runs whose event request failed", async () => { eventsMock.mockImplementation((runId: string) => ( runId === "failed-run" ? Promise.reject(new Error("event endpoint unavailable")) : Promise.resolve([]) )); await act(async () => { root.render(); await Promise.resolve(); }); expect(eventsMock).toHaveBeenCalledTimes(2); await act(async () => { await vi.advanceTimersByTimeAsync(2_000); }); expect(eventsMock).toHaveBeenCalledTimes(3); expect(eventsMock.mock.calls.at(-1)?.[0]).toBe("failed-run"); }); }); // Initial readiness is separate from an empty transcript: the task shell must // not reveal empty history while the first durable page is still in flight. describe("native history readiness and stable projection", () => { let container: HTMLDivElement; let root: Root; let latest: ReturnType; function StateProbe({ runs = [{ id: "one", status: "running", runtimeMode: "native" as const }], enabled = true }) { latest = useNativeRunTranscripts(runs, { enabled }); return null; } beforeEach(() => { vi.useFakeTimers(); eventsMock.mockReset(); container = document.createElement("div"); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); vi.useRealTimers(); }); it("distinguishes pending, loaded-empty and failed, and supports explicit retry", async () => { let resolve!: (rows: never[]) => void; eventsMock.mockImplementationOnce(() => new Promise((done) => { resolve = done; })); await act(async () => { root.render(); }); expect(latest.isInitialHydrating).toBe(true); await act(async () => { resolve([]); }); expect(latest.isInitialHydrating).toBe(false); expect(latest.hydratedRunIds.has("one")).toBe(true); eventsMock.mockRejectedValueOnce(new Error("offline")); await act(async () => { await vi.advanceTimersByTimeAsync(2_000); }); expect(latest.errorsByRun.has("one")).toBe(true); expect(latest.isInitialHydrating).toBe(false); eventsMock.mockResolvedValue([]); await act(async () => { latest.retry(); }); expect(latest.errorsByRun.size).toBe(0); }); it("loads newly revealed terminal runs without rereading settled siblings, and still refreshes on retry", async () => { eventsMock.mockResolvedValue([]); const one = { id: "one", status: "succeeded", runtimeMode: "native" as const }; const two = { ...one, id: "two" }; await act(async () => { root.render(); }); await act(async () => { root.render(); }); expect(eventsMock.mock.calls.map(([id]) => id)).toEqual(["one", "two"]); await act(async () => { latest.retry(); }); expect(eventsMock.mock.calls.map(([id]) => id)).toEqual(["one", "two", "one", "two"]); }); it("restarts an interrupted explicit refresh when history membership changes", async () => { eventsMock.mockResolvedValueOnce([]).mockImplementationOnce(() => new Promise(() => {})).mockResolvedValue([]); const one = { id: "one", status: "succeeded", runtimeMode: "native" as const }; await act(async () => { root.render(); }); await act(async () => { latest.retry(); }); const interruptedSignal = eventsMock.mock.calls[1][3].signal; await act(async () => { root.render(); }); expect(interruptedSignal.aborted).toBe(true); expect(eventsMock.mock.calls.map(([id]) => id)).toEqual(["one", "one", "one", "two"]); }); it("revalidates settled history after a disabled view resumes", async () => { eventsMock.mockResolvedValue([]); const runs = [{ id: "one", status: "succeeded", runtimeMode: "native" as const }]; await act(async () => { root.render(); }); await act(async () => { root.render(); }); expect(eventsMock).toHaveBeenCalledTimes(1); await act(async () => { root.render(); }); expect(eventsMock).toHaveBeenCalledTimes(2); }); it("does not rebuild unchanged history on empty polls", async () => { eventsMock.mockResolvedValueOnce([{ seq: 1, payload: {}, eventType: "log" }]).mockResolvedValue([]); await act(async () => { root.render(); }); const projection = latest.transcriptByRun; const rows = projection.get("one"); await act(async () => { await vi.advanceTimersByTimeAsync(2_000); }); expect(latest.transcriptByRun).toBe(projection); expect(latest.transcriptByRun.get("one")).toBe(rows); }); it("waits for every page of a successful terminal refresh before allowing a fallback", async () => { eventsMock.mockResolvedValueOnce([{ seq: 1, payload: {}, eventType: "log" }]); await act(async () => { root.render(); }); expect(latest.terminalHydratedRunIds.has("one")).toBe(false); let resolvePage!: (rows: unknown[]) => void; eventsMock.mockImplementationOnce(() => new Promise(resolve => { resolvePage = resolve; })); await act(async () => { root.render(); }); expect(latest.terminalHydratedRunIds.has("one")).toBe(false); let resolveLastPage!: (rows: unknown[]) => void; eventsMock.mockImplementationOnce(() => new Promise(resolve => { resolveLastPage = resolve; })); await act(async () => { resolvePage(Array.from({ length: 1_000 }, (_, index) => ({ seq: index + 2, payload: {}, eventType: "log" }))); }); expect(latest.terminalHydratedRunIds.has("one")).toBe(false); expect(eventsMock.mock.calls.at(-1)?.[1]).toBe(1_001); await act(async () => { resolveLastPage([]); }); expect(latest.terminalHydratedRunIds.has("one")).toBe(true); }); it("keeps the fallback pending after a failed terminal refresh until retry succeeds", async () => { eventsMock.mockResolvedValueOnce([]); await act(async () => { root.render(); }); eventsMock.mockRejectedValueOnce(new Error("offline")); await act(async () => { root.render(); }); expect(latest.terminalHydratedRunIds.has("one")).toBe(false); eventsMock.mockResolvedValueOnce([]); await act(async () => { await vi.advanceTimersByTimeAsync(2_000); }); expect(latest.terminalHydratedRunIds.has("one")).toBe(true); }); it("times out stalled history, aborts its request, and ignores a late response before retry", async () => { let resolveLate!: (rows: never[]) => void; eventsMock.mockImplementationOnce(() => new Promise((resolve) => { resolveLate = resolve; })); await act(async () => { root.render(); }); const signal = eventsMock.mock.calls[0][3].signal as AbortSignal; expect(latest.isInitialHydrating).toBe(true); await act(async () => { await vi.advanceTimersByTimeAsync(TRANSCRIPT_REQUEST_TIMEOUT_MS); }); expect(signal.aborted).toBe(true); expect(latest.isInitialHydrating).toBe(false); expect(latest.errorsByRun.get("one")?.message).toContain("too long"); await act(async () => { resolveLate([]); }); expect(latest.errorsByRun.has("one")).toBe(true); eventsMock.mockResolvedValue([]); await act(async () => { latest.retry(); }); expect(latest.errorsByRun.size).toBe(0); expect(eventsMock.mock.calls[1][3].signal.aborted).toBe(false); }); it("commits a resolved run independently of a stalled sibling and retains its cursor", async () => { const rows = [{ seq: 7, payload: {}, eventType: "log" }]; eventsMock.mockImplementation((id) => id === "slow" ? new Promise(() => {}) : Promise.resolve(rows)); const one = { id: "one", status: "succeeded", runtimeMode: "native" as const }; await act(async () => { root.render(); }); expect(latest.hydratedRunIds.has("one")).toBe(true); expect(latest.hydratedRunIds.has("slow")).toBe(false); expect(latest.transcriptByRun.has("one")).toBe(true); await act(async () => { root.render(); }); expect(eventsMock.mock.calls.filter(([id]) => id === "one").map(([, cursor]) => cursor)).toEqual([0]); await act(async () => { latest.retry(); }); expect(eventsMock.mock.calls.filter(([id]) => id === "one").map(([, cursor]) => cursor)).toEqual([0, 7]); expect(latest.isInitialHydrating).toBe(false); }); });