// @vitest-environment jsdom import { act, fireEvent, render, screen, waitFor, type RenderResult, } from "@testing-library/react"; import { QueryClient, QueryClientProvider, } from "@tanstack/react-query"; import { api } from "@multica/core/api"; import { chatKeys, mergeTaskMessagesBySeq, } from "@multica/core/chat/queries"; import type { AgentTask } from "@multica/core/types/agent"; import type { TaskMessagePayload } from "@multica/core/types/events"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TranscriptButton } from "./transcript-button"; import type { TimelineItem } from "./build-timeline"; vi.mock("@multica/core/api", () => ({ api: { listTaskMessages: vi.fn(), }, })); // Render the timeline items so tests can assert the dialog grows in place. // `tool_use` / `tool_result` entries don't coalesce, so each message stays a // distinct row — unlike adjacent text/thinking, which buildTimeline merges. vi.mock("./agent-transcript-dialog", () => ({ AgentTranscriptDialog: ({ open, onOpenChange, items, }: { open: boolean; onOpenChange: (open: boolean) => void; items: TimelineItem[]; }) => open ? (
{items.map((item) => (
))}
) : null, })); const LIVE_TASK_ID = "4a2e8d1c-7f9b-4e2a-9c1d-123456789abc"; const baseTask: AgentTask = { id: LIVE_TASK_ID, agent_id: "agent-1", runtime_id: "", issue_id: "issue-1", status: "running", priority: 0, dispatched_at: "2026-05-15T10:00:05.000Z", started_at: "2026-05-15T10:00:06.000Z", completed_at: null, result: null, error: null, created_at: "2026-05-15T10:00:00.000Z", }; const msg = (seq: number, tool: string): TaskMessagePayload => ({ task_id: LIVE_TASK_ID, issue_id: "issue-1", seq, type: "tool_use", tool, input: { i: String(seq) }, }); function newClient() { return new QueryClient({ defaultOptions: { queries: { retry: false } }, }); } function renderWith(qc: QueryClient, ui: React.ReactNode): RenderResult { return render({ui}); } const listTaskMessages = vi.mocked(api.listTaskMessages); beforeEach(() => { listTaskMessages.mockReset(); listTaskMessages.mockResolvedValue([]); }); afterEach(() => { vi.clearAllMocks(); }); describe("TranscriptButton", () => { it("closes the transcript dialog when desktop navigation starts", async () => { const items: TimelineItem[] = [{ seq: 1, type: "text", content: "hello" }]; const qc = newClient(); renderWith( qc, , ); fireEvent.click(screen.getByRole("button", { name: "View transcript" })); expect(screen.getByRole("dialog")).toBeInTheDocument(); act(() => { window.dispatchEvent( new CustomEvent("multica:navigate", { detail: { path: "/acme/inbox?issue=MUL-123" }, }), ); }); await waitFor(() => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); }); it("live mode: the open dialog grows as the shared cache receives new messages", async () => { const qc = newClient(); qc.setQueryData(chatKeys.taskMessages(LIVE_TASK_ID), [msg(1, "Bash")]); listTaskMessages.mockResolvedValue([msg(1, "Bash")]); renderWith(qc, ); fireEvent.click(screen.getByRole("button", { name: "View transcript" })); await waitFor(() => expect(screen.getAllByTestId("event")).toHaveLength(1), ); // Simulate a WS `task:message` append into the shared cache. act(() => { qc.setQueryData( chatKeys.taskMessages(LIVE_TASK_ID), (old = []) => mergeTaskMessagesBySeq(old, [msg(2, "Read")]), ); }); await waitFor(() => expect(screen.getAllByTestId("event")).toHaveLength(2), ); }); it("live mode: forces a backfill on open even when the cache already has data", async () => { const qc = newClient(); qc.setQueryData(chatKeys.taskMessages(LIVE_TASK_ID), [ msg(1, "Bash"), msg(2, "Read"), ]); listTaskMessages.mockResolvedValue([msg(1, "Bash"), msg(2, "Read")]); renderWith(qc, ); fireEvent.click(screen.getByRole("button", { name: "View transcript" })); await waitFor(() => expect(listTaskMessages).toHaveBeenCalledWith(LIVE_TASK_ID), ); }); it("terminal mode: fetches once on open and does not subscribe to the cache", async () => { const qc = newClient(); listTaskMessages.mockResolvedValue([msg(1, "Bash")]); renderWith( qc, , ); fireEvent.click(screen.getByRole("button", { name: "View transcript" })); await waitFor(() => expect(screen.getAllByTestId("event")).toHaveLength(1), ); // A later cache write must NOT reach the terminal dialog: it renders a // one-shot local snapshot, never an observer of the shared cache. act(() => { qc.setQueryData(chatKeys.taskMessages(LIVE_TASK_ID), [ msg(1, "Bash"), msg(2, "Read"), ]); }); expect(screen.getAllByTestId("event")).toHaveLength(1); expect(listTaskMessages).toHaveBeenCalledTimes(1); }); it("running→terminal: keeps the dialog populated and takes a final backfill", async () => { const qc = newClient(); qc.setQueryData(chatKeys.taskMessages(LIVE_TASK_ID), [msg(1, "Bash")]); listTaskMessages.mockResolvedValue([msg(1, "Bash")]); const { rerender } = renderWith( qc, , ); fireEvent.click(screen.getByRole("button", { name: "View transcript" })); await waitFor(() => expect(screen.getAllByTestId("event")).toHaveLength(1), ); await waitFor(() => expect(listTaskMessages).toHaveBeenCalledTimes(1)); // Task finishes: parent flips isLive→false and the status to terminal. rerender( , ); // Dialog stays mounted (latched), and the terminal transition triggers a // second authoritative backfill rather than blanking to local state. expect(screen.getByTestId("transcript-dialog")).toBeInTheDocument(); await waitFor(() => expect(listTaskMessages).toHaveBeenCalledTimes(2)); // The final tail message still flows in through the shared cache. act(() => { qc.setQueryData( chatKeys.taskMessages(LIVE_TASK_ID), (old = []) => mergeTaskMessagesBySeq(old, [msg(2, "Read")]), ); }); await waitFor(() => expect(screen.getAllByTestId("event")).toHaveLength(2), ); }); });