// @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 { TaskChatThreadView } from "./TaskChatThreadView"; import { ThemeProvider } from "@/context/ThemeContext"; import { MemoryRouter } from "@/lib/router"; import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup"; import { TaskChatBubble } from "./TaskChatBubble"; import { TaskChatExpansionState } from "./expansion-state"; import type { TaskChatActivityPhaseItem, TaskChatProviderActivityItem, TaskChatToolItem, } from "./task-chat-model"; const motion = vi.hoisted(() => ({ reduced: false })); vi.mock("motion/react", async (original) => ({ ...(await original()), useReducedMotion: () => motion.reduced, })); const tool = ( id: string, status: TaskChatToolItem["status"] = "in_progress", ): TaskChatToolItem => ({ id, kind: "tool", name: "exec_command", target: `command-${id}`, status, detail: `output-${id}`, }); const providerItem = ( id: string, family: TaskChatProviderActivityItem["family"], status: TaskChatProviderActivityItem["status"], details: TaskChatProviderActivityItem["details"], extra: Partial = {}, ): TaskChatProviderActivityItem => ({ id, kind: "protocol", surface: "provider_activity", family, eventType: `${family}.activity`, status, title: family, details, steps: [], links: [], children: [], ...extra, }); describe("TaskChatRunnerActivityGroup", () => { let container: HTMLDivElement; let root: Root; let memory: Map; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); memory = new Map(); motion.reduced = false; }); afterEach(() => { act(() => root.unmount()); container.remove(); }); const render = ( items: TaskChatActivityPhaseItem["items"], host = "live", active = true, ) => act(() => root.render( , ), ); const toggle = () => container.querySelector( '[data-testid="task-chat-activity-phase-toggle"]', )!; const viewport = () => container.querySelector('[data-testid="task-chat-activity-viewport"]')!; it("aligns runner commentary with the following agent reply", () => { act(() => root.render( , )); const commentary = container.querySelector('[data-testid="task-chat-phase-interstitial"]'); const reply = container.querySelector('[data-testid="task-chat-agent-bubble"]'); expect(commentary?.classList.contains("px-1")).toBe(true); expect(reply?.classList.contains("px-1")).toBe(true); }); it("rolls to each new item once while status and token updates keep the current row mounted", () => { render([tool("one")]); const first = viewport().querySelector('[data-activity-row="one"]'); render([tool("one", "completed")]); expect(viewport().querySelector('[data-activity-row="one"]')).toBe(first); expect(container.querySelector(".runner-activity-roll-in")).toBeNull(); render([tool("one", "completed"), tool("two")]); expect(viewport().querySelectorAll("[data-activity-row]")).toHaveLength(2); const outgoing = container.querySelector(".runner-activity-roll-out")!; expect(outgoing.getAttribute("aria-hidden")).toBe("true"); const current = container.querySelector(".runner-activity-roll-in"); render([ tool("one", "completed"), { ...tool("two"), detail: "more output" }, ]); expect(container.querySelector(".runner-activity-roll-in")).toBe(current); act(() => outgoing.dispatchEvent( new Event("webkitAnimationEnd", { bubbles: true }), ), ); expect(viewport().querySelectorAll("[data-activity-row]")).toHaveLength(1); expect(viewport().textContent).toContain("command-two"); }); it("updates provider reasoning text in place without restarting motion", () => { render([ { id: "thought", kind: "thinking", lines: ["First", "Check"], streaming: true, }, ]); const line = viewport().querySelector("[data-activity-row]"); render([ { id: "thought", kind: "thinking", lines: ["First", "Checking the file"], streaming: true, }, ]); expect(viewport().querySelector("[data-activity-row]")).toBe(line); expect(viewport().textContent).toContain("Checking the file"); expect(viewport().textContent).not.toContain("First"); expect(container.querySelector(".runner-activity-roll-in")).toBeNull(); }); it("preserves expanded history and item detail across appends and live-to-history remount", () => { render([tool("one", "completed")]); act(() => toggle().click()); const row = container.querySelector("li button")!; act(() => row.click()); expect(container.textContent).toContain("output-one"); render([tool("one", "completed"), tool("two")]); expect(container.querySelectorAll("li")).toHaveLength(2); expect(container.querySelector("li button")).toBe(row); render( [tool("one", "completed"), tool("two", "completed")], "history", false, ); expect(toggle().getAttribute("aria-expanded")).toBe("true"); expect(container.querySelectorAll("li")).toHaveLength(2); expect(container.textContent).toContain("output-one"); act(() => toggle().click()); expect(toggle().textContent).toContain("Ran commands"); expect(container.textContent).not.toContain("command-two"); }); it("keeps failures discoverable after later activity, with neutral detail and no X", () => { render([tool("failed", "failed"), tool("next")]); expect(toggle().textContent).not.toMatch(/\d+ failed/); act(() => toggle().click()); expect(container.querySelector("li")?.textContent).toContain("failed"); act(() => container.querySelector("li button")!.click()); expect(container.textContent).toContain("output-failed"); expect(container.querySelector(".lucide-x, .text-destructive")).toBeNull(); }); it("uses the same group and expansion memory in the real persisted thread renderer", () => { render([tool("one", "failed"), tool("two")]); act(() => toggle().click()); act(() => container.querySelector("li button")!.click()); act(() => root.render( , ), ); expect(toggle().getAttribute("aria-expanded")).toBe("true"); expect(container.querySelectorAll("li")).toHaveLength(2); expect(container.textContent).toContain("output-one"); expect(container.textContent).toContain("Finished"); expect(container.querySelector(".text-destructive,.lucide-x")).toBeNull(); act(() => toggle().click()); expect(toggle().textContent).toContain("Ran commands"); expect(container.textContent).not.toContain("command-two"); expect(toggle().textContent).not.toMatch(/\d+ failed/); }); it("uses the compact runner group for a legacy persisted turn", () => { act(() => root.render( , ), ); act(() => container .querySelector('[data-testid="task-chat-turn-summary"]')! .click(), ); expect(container.querySelector('[data-testid="task-chat-activity-phase-toggle"]')).not.toBeNull(); expect(container.querySelector('[data-testid="task-chat-phase-summary"]')).toBeNull(); expect(container.textContent).toContain("Ran command"); expect(container.textContent).not.toContain("command-legacy"); }); it("does not offer empty disclosures for sparse activities", () => { render([ { id: "thinking-empty", kind: "thinking", lines: [], streaming: true }, { id: "tool-empty", kind: "tool", name: "read", status: "completed" }, { id: "provider-empty", kind: "protocol", surface: "provider_activity", family: "wait", status: "running", title: "Wait", eventType: "wait.started", details: [], steps: [], links: [], children: [], }, ]); act(() => toggle().click()); expect(container.querySelectorAll("li")).toHaveLength(3); expect( container.querySelectorAll( "li button, li [aria-expanded], li [aria-controls]", ), ).toHaveLength(0); expect( container.querySelector( '[data-testid="task-chat-runner-activity-detail"]', ), ).toBeNull(); }); it("settles to a summary and can resume without losing the current activity", () => { const items = [tool("one", "failed"), tool("two", "completed")]; render(items); expect(viewport().textContent).toContain("command-two"); render(items, "live", false); expect( container.querySelector('[data-testid="task-chat-activity-viewport"]'), ).toBeNull(); expect(toggle().textContent).toBe("Ran commands"); expect(toggle().getAttribute("aria-label")).toContain("ran commands"); expect(container.textContent).not.toContain("command-two"); act(() => toggle().click()); expect(container.querySelectorAll("li")).toHaveLength(2); expect(toggle().textContent).not.toMatch(/\d+ failed/); act(() => toggle().click()); render([...items, tool("three")]); expect(viewport().textContent).toContain("command-three"); }); it("replaces immediately with reduced motion", () => { motion.reduced = true; render([tool("one")]); render([tool("one", "completed"), tool("two")]); expect(viewport().querySelectorAll("[data-activity-row]")).toHaveLength(1); expect( container.querySelector( ".runner-activity-roll-in, .runner-activity-roll-out", ), ).toBeNull(); }); it("shows research sources, check outcomes and a wait countdown in expanded history", () => { const search = providerItem("search", "research", "completed", [ { label: "Action", value: "search" }, { label: "Query", value: "temporal week dates" }, ], { links: [ { label: "MDN", href: "https://developer.mozilla.org/a" }, { label: "MDN again", href: "https://developer.mozilla.org/b" }, { label: "TC39", href: "https://tc39.es/c" }, ], }); const passed = providerItem("check-1", "verification", "completed", [ { label: "Status", value: "passed" }, { label: "Detail", value: "6 tests passed" }, ], { summary: "node --test" }); const failed = providerItem("check-2", "verification", "failed", [ { label: "Status", value: "failed" }, ], { summary: "pnpm typecheck" }); const skipped = providerItem("check-3", "verification", "informational", [ { label: "Status", value: "not_run" }, { label: "Reason Code", value: "policy_restricted" }, ], { summary: "pnpm test:e2e" }); const longFailure = "x".repeat(300); const failedLong = providerItem("check-4", "verification", "failed", [ { label: "Status", value: "failed" }, { label: "Detail", value: longFailure }, ], { summary: "pnpm typecheck" }); const wait = providerItem("wait", "wait", "running", [ { label: "Planned Duration Ms", value: "30000" }, ], { startedAt: new Date(Date.now() - 12_000).toISOString() }); render([search, passed, failed, skipped, failedLong, wait], "live", false); act(() => toggle().click()); const strip = container.querySelector('[data-testid="task-chat-research-sources"]'); expect([...strip!.querySelectorAll("a")].map((link) => link.textContent)).toEqual(["developer.mozilla.org", "tc39.es"]); expect(strip?.textContent).toContain("+1 more"); expect(container.textContent).toContain("Searched the web“temporal week dates”"); const passedRow = container.querySelector('[data-activity-row="check-1"]')!; expect(passedRow.textContent).toContain("Check passed"); expect(passedRow.textContent).toContain("6 tests passed"); expect(passedRow.querySelector("[data-activity-icon] svg")?.getAttribute("class")).toContain("--status-task-icon-done"); const failedRow = container.querySelector('[data-activity-row="check-2"]')!; expect(failedRow.textContent).toContain("Check failed"); expect(failedRow.querySelector("[data-activity-icon] svg")?.getAttribute("class")).toContain("text-destructive"); expect(failedRow.closest("button")).toBeNull(); expect(container.querySelector('[data-activity-row="check-3"]')?.textContent).toContain("Policy restricted"); const longRow = container.querySelector('[data-activity-item-id="check-4"] button')!; act(() => longRow.click()); const detail = container.querySelector('[data-activity-item-id="check-4"] [data-testid="task-chat-verification-details"]'); expect(detail?.textContent).toContain("pnpm typecheck"); expect(detail?.textContent).toContain(longFailure); expect(container.querySelector('[data-activity-row="wait"]')?.textContent).toContain("Waiting 30s"); expect(container.querySelector('[data-testid="task-chat-wait-countdown"]')?.textContent).toMatch(/^1[78]s left$/); }); });