import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster"; import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types"; const BLENDER_CLI_APP: CliAppInfo = { name: "blender", display_name: "Blender", category: "3d", description: "3D creation", requires: "", source: "harness", entry_point: "cli-anything-blender", install_supported: true, installed: true, available: true, status: "installed", logo_url: "https://example.invalid/blender.svg", brand_color: "#E87D0D", skill_installed: true, }; const BROWSERBASE_MCP: McpPresetInfo = { name: "browserbase", display_name: "Browserbase", category: "browser", description: "Cloud browser automation", docs_url: "https://docs.browserbase.com", transport: "streamableHttp", requires: "Browserbase API key", note: "", install_supported: true, installed: true, configured: true, available: true, status: "configured", logo_url: "https://example.invalid/browserbase.svg", brand_color: "#111827", required_fields: [], connection_summary: "https://mcp.browserbase.com/mcp", }; function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] { const rows: UIMessage[] = [ { id: "r1", role: "assistant", content: "", reasoning: `thinking${extraReasoning}`, reasoningStreaming: true, isStreaming: true, createdAt: 1, }, { id: "t1", role: "tool", kind: "trace", content: "search()", traces: ["search()"], createdAt: 2, }, ]; if (extraTool) rows.push(extraTool); return rows; } function installAnimationFrameQueue() { const originalRequest = window.requestAnimationFrame; const originalCancel = window.cancelAnimationFrame; const callbacks = new Map(); let nextId = 1; window.requestAnimationFrame = ((callback: FrameRequestCallback) => { const id = nextId; nextId += 1; callbacks.set(id, callback); return id; }) as typeof window.requestAnimationFrame; window.cancelAnimationFrame = ((id: number) => { callbacks.delete(id); }) as typeof window.cancelAnimationFrame; return { flush() { const pending = Array.from(callbacks.entries()); callbacks.clear(); for (const [, callback] of pending) callback(0); }, restore() { window.requestAnimationFrame = originalRequest; window.cancelAnimationFrame = originalCancel; }, }; } function setScrollGeometry( element: HTMLElement, geometry: { scrollHeight: number; clientHeight: number; scrollTop?: number }, ) { Object.defineProperties(element, { scrollHeight: { configurable: true, value: geometry.scrollHeight }, clientHeight: { configurable: true, value: geometry.clientHeight }, scrollTop: { configurable: true, value: geometry.scrollTop ?? element.scrollTop, writable: true, }, }); } function installReducedMotion() { const original = window.matchMedia; Object.defineProperty(window, "matchMedia", { configurable: true, value: () => ({ matches: true, media: "(prefers-reduced-motion: reduce)", addEventListener: () => {}, removeEventListener: () => {}, }), }); return () => { Object.defineProperty(window, "matchMedia", { configurable: true, value: original, }); }; } describe("AgentActivityCluster", () => { it("jumps to the latest activity when opened", () => { const raf = installAnimationFrameQueue(); try { render( , ); const scrollport = screen.getByTestId("agent-activity-scroll"); setScrollGeometry(scrollport, { scrollHeight: 1000, clientHeight: 120, scrollTop: 0, }); act(() => { raf.flush(); }); expect(scrollport.scrollTop).toBe(880); } finally { raf.restore(); } }); it("follows new reasoning and tool activity while the user is at the bottom", () => { const raf = installAnimationFrameQueue(); try { const { rerender } = render( , ); const scrollport = screen.getByTestId("agent-activity-scroll"); setScrollGeometry(scrollport, { scrollHeight: 1000, clientHeight: 120, scrollTop: 0, }); act(() => { raf.flush(); }); rerender( , ); setScrollGeometry(scrollport, { scrollHeight: 1500, clientHeight: 120, scrollTop: scrollport.scrollTop, }); act(() => { raf.flush(); }); expect(scrollport.scrollTop).toBe(1380); } finally { raf.restore(); } }); it("does not pull the user down after they scroll up inside the activity pane", () => { const raf = installAnimationFrameQueue(); try { const { rerender } = render( , ); const scrollport = screen.getByTestId("agent-activity-scroll"); setScrollGeometry(scrollport, { scrollHeight: 1000, clientHeight: 120, scrollTop: 0, }); act(() => { raf.flush(); }); scrollport.scrollTop = 100; fireEvent.scroll(scrollport); rerender( , ); setScrollGeometry(scrollport, { scrollHeight: 1500, clientHeight: 120, scrollTop: scrollport.scrollTop, }); act(() => { raf.flush(); }); expect(scrollport.scrollTop).toBe(100); } finally { raf.restore(); } }); it("turns the live reasoning marker into an animated check when thinking completes", async () => { const liveReasoning: UIMessage = { id: "r-check", role: "assistant", content: "", reasoning: "checking a source", reasoningStreaming: true, isStreaming: true, createdAt: 1, }; const { rerender } = render( , ); expect(screen.getByTestId("activity-reasoning-marker")).toHaveAttribute("data-state", "thinking"); rerender( , ); const marker = screen.getByTestId("activity-reasoning-marker"); expect(marker).toHaveAttribute("data-state", "done"); expect(marker.querySelector("svg")).toBeInTheDocument(); await waitFor(() => expect(marker).toHaveClass("animate-in")); }); it("briefly shows completed activity, then auto-collapses before the answer", () => { vi.useFakeTimers(); const liveReasoning: UIMessage = { id: "r-collapse", role: "assistant", content: "", reasoning: "checking files", reasoningStreaming: true, isStreaming: true, createdAt: 1, }; try { const { rerender } = render( , ); expect(screen.getByTestId("agent-activity-scroll")).toBeInTheDocument(); rerender( , ); expect(screen.getByTestId("agent-activity-scroll")).toBeInTheDocument(); act(() => { vi.advanceTimersByTime(901); }); expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: /1 steps/i })).toHaveAttribute( "aria-expanded", "false", ); } finally { vi.useRealTimers(); } }); it("renders file edit totals and a compact expanded file list", async () => { const restoreMotion = installReducedMotion(); try { render( , ); expect(screen.getByRole("button", { name: /edited app\.tsx/i })).toBeInTheDocument(); expect(screen.getByTestId("activity-header-file-reference")).toHaveTextContent("app.tsx"); expect(screen.getByTestId("activity-header-file-reference")).toHaveAttribute( "aria-label", "/Users/renxubin/project/src/app.tsx", ); fireEvent.click(screen.getByRole("button", { name: /edited app\.tsx/i })); expect(screen.queryByText("Edited files")).not.toBeInTheDocument(); const fileRef = screen.getByTestId("activity-file-reference"); expect(fileRef).toHaveTextContent("src/app.tsx"); expect(fileRef).toHaveAttribute("aria-label", "/Users/renxubin/project/src/app.tsx"); for (const diffPair of screen.getAllByTestId("activity-diff-pair")) { expect(diffPair).toHaveClass("items-baseline"); expect(diffPair).toHaveClass("leading-[inherit]"); expect(diffPair.className).not.toContain("translate-y"); } await waitFor(() => { expect(screen.getAllByText("+12").length).toBeGreaterThan(0); expect(screen.getAllByText("-3").length).toBeGreaterThan(0); }); } finally { restoreMotion(); } }); it("renders CLI app runs as dedicated activity rows", () => { const line = 'run_cli_app({"name":"blender","args":["--background","scene.blend"],"json":true})'; render( , ); const cliRuns = screen.getByTestId("activity-cli-runs"); expect(cliRuns).toHaveTextContent("Using"); expect(cliRuns).toHaveTextContent("@blender"); expect(cliRuns).toHaveTextContent("--json --background scene.blend"); expect(screen.getByTestId("activity-cli-logo-blender")).toBeInTheDocument(); expect(screen.queryByText(/run_cli_app/)).not.toBeInTheDocument(); }); it("keeps CLI rows in chronological trace order", () => { const cliArgs = { name: "blender", args: ["project", "new"], json: true }; const cliLine = `run_cli_app(${JSON.stringify(cliArgs)})`; render( , ); const searchRow = screen.getByText("Searching").closest("li"); const cliRow = screen.getByText("@blender").closest("li"); const fetchRow = screen.getByText("Reading").closest("li"); expect(searchRow).not.toBeNull(); expect(cliRow).not.toBeNull(); expect(fetchRow).not.toBeNull(); expect(searchRow!.compareDocumentPosition(cliRow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(cliRow!.compareDocumentPosition(fetchRow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); it("labels rejected CLI app calls as failed instead of ran", () => { render( , ); fireEvent.click(screen.getByRole("button", { name: /failed @github/i })); expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("Failed"); expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("@github"); expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("Error: CLI app 'github' not found"); expect(screen.queryByText("Ran CLI")).not.toBeInTheDocument(); }); it("renders MCP preset tool calls as branded activity rows", () => { render( , ); const mcpRuns = screen.getByTestId("activity-mcp-runs"); expect(mcpRuns).toHaveTextContent("Using"); expect(mcpRuns).toHaveTextContent("Browserbase"); expect(mcpRuns).toHaveTextContent("browser_navigate"); expect(mcpRuns).toHaveTextContent("url: https://example.com"); expect(screen.getByTestId("activity-mcp-logo-browserbase")).toBeInTheDocument(); expect(screen.queryByText(/mcp_browserbase_browser_navigate/)).not.toBeInTheDocument(); }); it("renders public web fetch traces with the site favicon", () => { render( , ); const favicon = screen.getByTestId("activity-web-favicon-auth0.com"); expect(favicon.querySelector("img")?.getAttribute("src")).toContain("auth0.com"); expect(screen.getByText("Reading")).toBeInTheDocument(); expect(screen.getByText("auth0.com/blog/jwt-security-best-practices")).toBeInTheDocument(); }); it("renders plain-text fetch progress with the site favicon", () => { render( , ); expect(screen.getByTestId("activity-web-favicon-auth0.com")).toBeInTheDocument(); expect(screen.getByText("Reading")).toBeInTheDocument(); expect(screen.getByText("auth0.com/blog/jwt-security-best-practices")).toBeInTheDocument(); }); it("does not request favicons for private web fetch targets", () => { render( , ); expect(screen.queryByTestId("activity-web-favicon-localhost")).not.toBeInTheDocument(); expect(screen.getByText("url: http://localhost:3000/dashboard")).toBeInTheDocument(); }); it("summarizes long shell traces instead of dumping scripts", () => { const command = [ "cat << 'EOF' | bash", "SECRET_TOKEN=sk-test", "for id in m1 m2 m3; do", " echo done $id", "done", "EOF", ].join("\n"); const line = `exec(${JSON.stringify({ command })})`; render( , ); fireEvent.click(screen.getByRole("button", { name: /1 tool calls/i })); expect(screen.getByText("Shell")).toBeInTheDocument(); expect(screen.getByText(/cat << 'EOF' \| bash · script, 6 lines/)).toBeInTheDocument(); expect(screen.queryByText(/SECRET_TOKEN/)).not.toBeInTheDocument(); expect(screen.queryByText(/for id in/)).not.toBeInTheDocument(); expect(screen.queryByText(/^Done$/)).not.toBeInTheDocument(); }); it("does not render zero diff counters for completed edits", () => { render( , ); expect(screen.getByRole("button", { name: /edited app\.tsx/i })).toBeInTheDocument(); expect(screen.queryByText("+0")).not.toBeInTheDocument(); expect(screen.queryByText("-0")).not.toBeInTheDocument(); }); it("drops stale pathless pending edits after the turn completes", () => { render( , ); expect(screen.queryByRole("button", { name: /preparing edit/i })).not.toBeInTheDocument(); expect(screen.queryByText("+98")).not.toBeInTheDocument(); expect(screen.queryByText("0 tool calls")).not.toBeInTheDocument(); }); it("renders pending file edit placeholders before the path is known", () => { render( , ); expect(screen.getByRole("button", { name: /preparing edit/i })).toBeInTheDocument(); expect(screen.getByText("Preparing file edit…")).toBeInTheDocument(); }); it("merges repeated edits for the same path and lets successful edits win over failures", async () => { const restoreMotion = installReducedMotion(); try { render( , ); expect(screen.getByRole("button", { name: /edited index\.html/i })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /failed index\.html/i })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /edited index\.html/i })); const fileRefs = screen.getAllByTestId("activity-file-reference"); expect(fileRefs).toHaveLength(1); expect(fileRefs[0]).toHaveTextContent("minecraft-fps/index.html"); expect(screen.queryByText("Failed")).not.toBeInTheDocument(); await waitFor(() => { expect(screen.getAllByText("+8").length).toBeGreaterThan(0); expect(screen.getAllByText("-7").length).toBeGreaterThan(0); }); } finally { restoreMotion(); } }); });