import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster"; import type { UIMessage } from "@/lib/types"; 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( , ); fireEvent.click(screen.getByRole("button", { name: /working/i })); 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( , ); fireEvent.click(screen.getByRole("button", { name: /working/i })); 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( , ); fireEvent.click(screen.getByRole("button", { name: /working/i })); 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("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"); await waitFor(() => { expect(screen.getAllByText("+12").length).toBeGreaterThan(0); expect(screen.getAllByText("-3").length).toBeGreaterThan(0); }); } finally { restoreMotion(); } }); it("renders pending file edit placeholders before the path is known", () => { render( , ); expect(screen.getByRole("button", { name: /preparing edit/i })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /preparing edit/i })); 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(); } }); });