// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/ChatView.spec.tsx import React from "react" import { render, waitFor, act, fireEvent } from "@/utils/test-utils" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" import { vscode } from "@src/utils/vscode" import ChatView, { ChatViewProps } from "../ChatView" // Define minimal types needed for testing interface ClineMessage { type: "say" | "ask" say?: string ask?: string ts: number text?: string partial?: boolean } interface ExtensionState { version: string clineMessages: ClineMessage[] taskHistory: any[] shouldShowAnnouncement: boolean allowedCommands: string[] alwaysAllowExecute: boolean [key: string]: any } // Mock vscode API vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn(), }, })) // Mock use-sound hook const mockPlayFunction = vi.fn() vi.mock("use-sound", () => ({ default: vi.fn().mockImplementation(() => { return [mockPlayFunction] }), })) // Mock components that use ESM dependencies vi.mock("../BrowserSessionRow", () => ({ default: function MockBrowserSessionRow({ messages }: { messages: ClineMessage[] }) { return
{JSON.stringify(messages)}
}, })) vi.mock("../ChatRow", () => ({ default: function MockChatRow({ message }: { message: ClineMessage }) { return
{JSON.stringify(message)}
}, })) vi.mock("../AutoApproveMenu", () => ({ default: () => null, })) // Mock react-virtuoso to render items directly without virtualization // This allows tests to verify items rendered in the chat list vi.mock("react-virtuoso", () => ({ Virtuoso: function MockVirtuoso({ data, itemContent, }: { data: ClineMessage[] itemContent: (index: number, item: ClineMessage) => React.ReactNode }) { return (
{data.map((item, index) => (
{itemContent(index, item)}
))}
) }, })) // Mock VersionIndicator - returns null by default to prevent rendering in tests vi.mock("../../common/VersionIndicator", () => ({ default: vi.fn(() => null), })) // Get the mock function after the module is mocked const mockVersionIndicator = vi.mocked((await import("../../common/VersionIndicator")).default) vi.mock("../Announcement", () => ({ default: function MockAnnouncement({ hideAnnouncement }: { hideAnnouncement: () => void }) { // eslint-disable-next-line @typescript-eslint/no-require-imports const React = require("react") return React.createElement( "div", { "data-testid": "announcement-modal" }, React.createElement("div", null, "What's New"), React.createElement("button", { onClick: hideAnnouncement }, "Close"), ) }, })) // Mock DismissibleUpsell component vi.mock("@/components/common/DismissibleUpsell", () => ({ default: function MockDismissibleUpsell({ children }: { children: React.ReactNode }) { return
{children}
}, })) // Mock QueuedMessages component vi.mock("../QueuedMessages", () => ({ QueuedMessages: function MockQueuedMessages({ queue = [], onRemove, }: { queue?: Array<{ id: string; text: string; images?: string[] }> onRemove?: (index: number) => void onUpdate?: (index: number, newText: string) => void }) { if (!queue || queue.length === 0) { return null } return (
{queue.map((msg, index) => (
{msg.text}
))}
) }, })) // Mock RooTips component vi.mock("@src/components/welcome/RooTips", () => ({ default: function MockRooTips() { return
Tips content
}, })) // Mock RooHero component vi.mock("@src/components/welcome/RooHero", () => ({ default: function MockRooHero() { return
Hero content
}, })) // Mock TelemetryBanner component vi.mock("../common/TelemetryBanner", () => ({ default: function MockTelemetryBanner() { return null // Don't render anything to avoid interference }, })) // Mock i18n vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string, options?: any) => { if (key === "chat:versionIndicator.ariaLabel" && options?.version) { return `Version ${options.version}` } return key }, }), initReactI18next: { type: "3rdParty", init: () => {}, }, Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => { return <>{children || i18nKey} }, })) interface ChatTextAreaProps { onSend: () => void inputValue?: string setInputValue?: (value: string) => void sendingDisabled?: boolean placeholderText?: string selectedImages?: string[] shouldDisableImages?: boolean } const mockInputRef = React.createRef() const mockFocus = vi.fn() vi.mock("../ChatTextArea", () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const mockReact = require("react") const ChatTextAreaComponent = mockReact.forwardRef(function MockChatTextArea( props: ChatTextAreaProps, ref: React.ForwardedRef<{ focus: () => void }>, ) { // Use useImperativeHandle to expose the mock focus method mockReact.useImperativeHandle(ref, () => ({ focus: mockFocus, })) return (
{ // Use parent's setInputValue if available if (props.setInputValue) { props.setInputValue(e.target.value) } }} onKeyDown={(e) => { // Only call onSend when Enter is pressed (simulating real behavior) if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() props.onSend() } }} data-sending-disabled={props.sendingDisabled} />
) }) return { default: ChatTextAreaComponent, ChatTextArea: ChatTextAreaComponent, // Export as named export too } }) // Mock VSCode components vi.mock("@vscode/webview-ui-toolkit/react", () => ({ VSCodeButton: function MockVSCodeButton({ children, onClick, appearance, }: { children: React.ReactNode onClick?: () => void appearance?: string }) { return ( ) }, VSCodeTextField: function MockVSCodeTextField({ value, onInput, placeholder, }: { value?: string onInput?: (e: { target: { value: string } }) => void placeholder?: string }) { return ( onInput?.({ target: { value: e.target.value } })} placeholder={placeholder} /> ) }, VSCodeLink: function MockVSCodeLink({ children, href }: { children: React.ReactNode; href?: string }) { return {children} }, })) // Mock window.postMessage to trigger state hydration const mockPostMessage = (state: Partial) => { window.postMessage( { type: "state", state: { version: "1.0.0", clineMessages: [], taskHistory: [], shouldShowAnnouncement: false, allowedCommands: [], alwaysAllowExecute: false, cloudIsAuthenticated: false, telemetrySetting: "enabled", ...state, }, }, "*", ) } const defaultProps: ChatViewProps = { isHidden: false, showAnnouncement: false, hideAnnouncement: () => {}, } const queryClient = new QueryClient() const renderChatView = (props: Partial = {}) => { return render( , ) } describe("ChatView - Sound Playing Tests", () => { beforeEach(() => vi.clearAllMocks()) it("plays celebration sound for completion results", async () => { renderChatView() // First hydrate state with initial task mockPostMessage({ soundEnabled: true, // Enable sound clineMessages: [ { type: "say", say: "task", ts: Date.now() - 2000, text: "Initial task", }, ], }) // Clear any initial calls mockPlayFunction.mockClear() // Add completion result mockPostMessage({ soundEnabled: true, // Enable sound clineMessages: [ { type: "say", say: "task", ts: Date.now() - 2000, text: "Initial task", }, { type: "ask", ask: "completion_result", ts: Date.now(), text: "Task completed successfully", partial: false, // Ensure it's not partial }, ], }) // Wait for sound to be played await waitFor(() => { expect(mockPlayFunction).toHaveBeenCalled() }) }) it("plays progress_loop sound for api failures", async () => { renderChatView() // First hydrate state with initial task mockPostMessage({ soundEnabled: true, // Enable sound clineMessages: [ { type: "say", say: "task", ts: Date.now() - 2000, text: "Initial task", }, ], }) // Clear any initial calls mockPlayFunction.mockClear() // Add API failure mockPostMessage({ soundEnabled: true, // Enable sound clineMessages: [ { type: "say", say: "task", ts: Date.now() - 2000, text: "Initial task", }, { type: "ask", ask: "api_req_failed", ts: Date.now(), text: "API request failed", partial: false, // Ensure it's not partial }, ], }) // Wait for sound to be played await waitFor(() => { expect(mockPlayFunction).toHaveBeenCalled() }) }) it("does not play sound when resuming a task from history", () => { renderChatView() // Clear any initial calls mockPlayFunction.mockClear() // Hydrate state with a task that has a resumeTaskId (indicating it's resumed from history) mockPostMessage({ resumeTaskId: "task-123", clineMessages: [ { type: "say", say: "task", ts: Date.now() - 2000, text: "Resumed task", }, { type: "ask", ask: "tool", ts: Date.now(), text: JSON.stringify({ tool: "readFile", path: "test.txt" }), }, ], }) // Should not play sound when resuming from history expect(mockPlayFunction).not.toHaveBeenCalled() }) it("does not play sound when resuming a completed task from history", () => { renderChatView() // Clear any initial calls mockPlayFunction.mockClear() // Hydrate state with a completed task that has a resumeTaskId mockPostMessage({ resumeTaskId: "task-123", clineMessages: [ { type: "say", say: "task", ts: Date.now() - 2000, text: "Resumed task", }, { type: "ask", ask: "completion_result", ts: Date.now(), text: "Task completed", }, ], }) // Should not play sound for completion when resuming from history expect(mockPlayFunction).not.toHaveBeenCalled() }) }) describe("ChatView - Focus Grabbing Tests", () => { beforeEach(() => vi.clearAllMocks()) it("does not grab focus when follow-up question presented", async () => { const { getByTestId } = renderChatView() // First hydrate state with initial task mockPostMessage({ clineMessages: [ { type: "say", say: "task", ts: Date.now() - 2000, text: "Initial task", }, ], }) // Wait for the component to fully render and settle before clearing mocks await waitFor(() => { expect(getByTestId("chat-textarea")).toBeInTheDocument() }) // Wait for the debounced focus effect to fire (50ms debounce + buffer for CI variability) await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)) }) // Clear any initial calls after state has settled mockFocus.mockClear() // Add follow-up question mockPostMessage({ clineMessages: [ { type: "say", say: "task", ts: Date.now() - 2000, text: "Initial task", }, { type: "ask", ask: "followup", ts: Date.now(), text: "Should I continue?", }, ], }) // Wait for state update to complete await waitFor(() => { expect(getByTestId("chat-textarea")).toBeInTheDocument() }) // Should not grab focus for follow-up questions expect(mockFocus).not.toHaveBeenCalled() }) }) describe("ChatView - Version Indicator Tests", () => { beforeEach(() => { vi.clearAllMocks() // Reset the mock to return null by default mockVersionIndicator.mockReturnValue(null) }) it("displays version indicator button", () => { // Mock VersionIndicator to return a button mockVersionIndicator.mockReturnValue( React.createElement("button", { "data-testid": "version-indicator", "aria-label": "Version 1.0.0", className: "version-indicator-button", }), ) const { getByTestId } = renderChatView() // Hydrate state with no active task mockPostMessage({ version: "1.0.0", clineMessages: [], }) // Should display version indicator expect(getByTestId("version-indicator")).toBeInTheDocument() }) it("opens announcement modal when version indicator is clicked", async () => { // Mock VersionIndicator to return a button with onClick mockVersionIndicator.mockImplementation(({ onClick }: { onClick?: () => void }) => React.createElement("button", { "data-testid": "version-indicator", onClick, }), ) const { getByTestId, queryByTestId } = renderChatView({ showAnnouncement: false }) // Hydrate state mockPostMessage({ version: "1.0.0", clineMessages: [], }) // Wait for component to render await waitFor(() => { expect(getByTestId("version-indicator")).toBeInTheDocument() }) // Click version indicator const versionIndicator = getByTestId("version-indicator") act(() => { versionIndicator.click() }) // Wait for announcement modal to appear await waitFor(() => { expect(queryByTestId("announcement-modal")).toBeInTheDocument() }) }) it("version indicator has correct styling classes", () => { // Mock VersionIndicator to return a button with specific classes mockVersionIndicator.mockReturnValue( React.createElement("button", { "data-testid": "version-indicator", className: "version-indicator-button absolute top-2 right-2", }), ) const { getByTestId } = renderChatView() // Hydrate state mockPostMessage({ version: "1.0.0", clineMessages: [], }) const versionIndicator = getByTestId("version-indicator") expect(versionIndicator.className).toContain("version-indicator-button") expect(versionIndicator.className).toContain("absolute") expect(versionIndicator.className).toContain("top-2") expect(versionIndicator.className).toContain("right-2") }) it("version indicator has proper accessibility attributes", () => { // Mock VersionIndicator to return a button with aria-label mockVersionIndicator.mockReturnValue( React.createElement("button", { "data-testid": "version-indicator", "aria-label": "Version 1.0.0", role: "button", }), ) const { getByTestId } = renderChatView() // Hydrate state mockPostMessage({ version: "1.0.0", clineMessages: [], }) const versionIndicator = getByTestId("version-indicator") expect(versionIndicator.getAttribute("aria-label")).toBe("Version 1.0.0") expect(versionIndicator.getAttribute("role")).toBe("button") }) it("does not display version indicator when there is an active task", () => { // Mock VersionIndicator to return null (simulating hidden state) mockVersionIndicator.mockReturnValue(null) const { queryByTestId } = renderChatView() // Hydrate state with active task mockPostMessage({ version: "1.0.0", clineMessages: [ { type: "say", say: "task", ts: Date.now(), text: "Active task", }, ], }) // Should not display version indicator during active task expect(queryByTestId("version-indicator")).not.toBeInTheDocument() }) it("displays version indicator only on welcome screen (no task)", () => { // Mock VersionIndicator to return a button mockVersionIndicator.mockReturnValue(React.createElement("button", { "data-testid": "version-indicator" })) const { queryByTestId } = renderChatView() // Hydrate state with no active task mockPostMessage({ version: "1.0.0", clineMessages: [], }) // Should display version indicator on welcome screen expect(queryByTestId("version-indicator")).toBeInTheDocument() }) }) describe("ChatView - DismissibleUpsell Display Tests", () => { beforeEach(() => vi.clearAllMocks()) it("does not show DismissibleUpsell when user is authenticated to Cloud", () => { const { queryByTestId } = renderChatView() // Hydrate state with user authenticated to cloud mockPostMessage({ cloudIsAuthenticated: true, taskHistory: [ { id: "1", ts: Date.now() - 3000 }, { id: "2", ts: Date.now() - 2000 }, { id: "3", ts: Date.now() - 1000 }, { id: "4", ts: Date.now() }, ], clineMessages: [], // No active task }) // Should not show DismissibleUpsell when authenticated expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument() }) it("does not show DismissibleUpsell when user has only run 3 tasks in their history", () => { const { queryByTestId } = renderChatView() // Hydrate state with user not authenticated but only 3 tasks mockPostMessage({ cloudIsAuthenticated: false, taskHistory: [ { id: "1", ts: Date.now() - 2000 }, { id: "2", ts: Date.now() - 1000 }, { id: "3", ts: Date.now() }, ], clineMessages: [], // No active task }) // Should not show DismissibleUpsell with less than 4 tasks expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument() }) it("shows DismissibleUpsell when user is not authenticated and has run 6 or more tasks", async () => { const { getByTestId } = renderChatView() // Hydrate state with user not authenticated and 4 tasks mockPostMessage({ cloudIsAuthenticated: false, taskHistory: [ { id: "1", ts: Date.now() - 6000 }, { id: "2", ts: Date.now() - 5000 }, { id: "3", ts: Date.now() - 4000 }, { id: "4", ts: Date.now() - 3000 }, { id: "5", ts: Date.now() - 2000 }, { id: "6", ts: Date.now() - 1000 }, { id: "7", ts: Date.now() }, ], clineMessages: [], // No active task }) // Wait for component to render and show DismissibleUpsell await waitFor(() => { expect(getByTestId("dismissible-upsell")).toBeInTheDocument() }) }) it("does not show DismissibleUpsell when there is an active task (regardless of auth status)", async () => { const { queryByTestId } = renderChatView() // Hydrate state with active task mockPostMessage({ cloudIsAuthenticated: false, taskHistory: [ { id: "1", ts: Date.now() - 3000 }, { id: "2", ts: Date.now() - 2000 }, { id: "3", ts: Date.now() - 1000 }, { id: "4", ts: Date.now() }, ], clineMessages: [ { type: "say", say: "task", ts: Date.now(), text: "Active task", }, ], }) // Wait for component to render with active task await waitFor(() => { // Should not show DismissibleUpsell during active task expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument() // Should not show RooTips either since the entire welcome screen is hidden during active tasks expect(queryByTestId("roo-tips")).not.toBeInTheDocument() // Should not show RooHero either since the entire welcome screen is hidden during active tasks expect(queryByTestId("roo-hero")).not.toBeInTheDocument() }) }) it("shows RooTips when user is authenticated (instead of DismissibleUpsell)", () => { const { queryByTestId, getByTestId } = renderChatView() // Hydrate state with user authenticated to cloud mockPostMessage({ cloudIsAuthenticated: true, taskHistory: [ { id: "1", ts: Date.now() - 3000 }, { id: "2", ts: Date.now() - 2000 }, { id: "3", ts: Date.now() - 1000 }, { id: "4", ts: Date.now() }, ], clineMessages: [], // No active task }) // Should not show DismissibleUpsell but should show RooTips expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument() expect(getByTestId("roo-tips")).toBeInTheDocument() }) it("shows RooTips when user has fewer than 6 tasks (instead of DismissibleUpsell)", () => { const { queryByTestId, getByTestId } = renderChatView() // Hydrate state with user not authenticated but fewer than 4 tasks mockPostMessage({ cloudIsAuthenticated: false, taskHistory: [ { id: "1", ts: Date.now() - 2000 }, { id: "2", ts: Date.now() - 1000 }, { id: "3", ts: Date.now() }, ], clineMessages: [], // No active task }) // Should not show DismissibleUpsell but should show RooTips expect(queryByTestId("dismissible-upsell")).not.toBeInTheDocument() expect(getByTestId("roo-tips")).toBeInTheDocument() }) }) describe("ChatView - Message Queueing Tests", () => { beforeEach(() => { vi.clearAllMocks() // Reset the mock to clear any initial calls vi.mocked(vscode.postMessage).mockClear() }) it("shows sending is disabled when task is active", async () => { const { getByTestId } = renderChatView() // Hydrate state with active task that should disable sending mockPostMessage({ clineMessages: [ { type: "say", say: "task", ts: Date.now() - 1000, text: "Task in progress", }, { type: "ask", ask: "tool", ts: Date.now(), text: JSON.stringify({ tool: "readFile", path: "test.txt" }), partial: true, // Partial messages disable sending }, ], }) // Wait for state to be updated and check that sending is disabled await waitFor(() => { const chatTextArea = getByTestId("chat-textarea") const input = chatTextArea.querySelector("input")! expect(input.getAttribute("data-sending-disabled")).toBe("true") }) }) it("shows sending is enabled when no task is active", async () => { const { getByTestId } = renderChatView() // Hydrate state with completed task mockPostMessage({ clineMessages: [ { type: "ask", ask: "completion_result", ts: Date.now(), text: "Task completed", partial: false, }, ], }) // Wait for state to be updated await waitFor(() => { expect(getByTestId("chat-textarea")).toBeInTheDocument() }) // Check that sending is enabled const chatTextArea = getByTestId("chat-textarea") const input = chatTextArea.querySelector("input")! expect(input.getAttribute("data-sending-disabled")).toBe("false") }) it("queues messages when API request is in progress (spinner visible)", async () => { const { getByTestId } = renderChatView() // First hydrate state with initial task mockPostMessage({ clineMessages: [ { type: "say", say: "task", ts: Date.now() - 2000, text: "Initial task", }, ], }) // Clear any initial calls vi.mocked(vscode.postMessage).mockClear() // Add api_req_started without cost (spinner state - API request in progress) mockPostMessage({ clineMessages: [ { type: "say", say: "task", ts: Date.now() - 2000, text: "Initial task", }, { type: "say", say: "api_req_started", ts: Date.now(), text: JSON.stringify({ apiProtocol: "anthropic" }), // No cost = still streaming }, ], }) // Wait for state to be updated await waitFor(() => { expect(getByTestId("chat-textarea")).toBeInTheDocument() }) // Clear message calls before simulating user input vi.mocked(vscode.postMessage).mockClear() // Simulate user typing and sending a message during the spinner const chatTextArea = getByTestId("chat-textarea") const input = chatTextArea.querySelector("input")! as HTMLInputElement // Trigger message send by simulating typing and Enter key press await act(async () => { // Use fireEvent to properly trigger React's onChange handler fireEvent.change(input, { target: { value: "follow-up question during spinner" } }) // Simulate pressing Enter to send fireEvent.keyDown(input, { key: "Enter", code: "Enter" }) }) // Verify that the message was queued, not sent as askResponse await waitFor(() => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: "queueMessage", text: "follow-up question during spinner", images: [], }) }) // Verify it was NOT sent as a direct askResponse (which would get lost) expect(vscode.postMessage).not.toHaveBeenCalledWith( expect.objectContaining({ type: "askResponse", askResponse: "messageResponse", }), ) }) it("sends messages normally when API request is complete (cost present)", async () => { const { getByTestId } = renderChatView() // Hydrate state with completed API request (cost present) mockPostMessage({ clineMessages: [ { type: "say", say: "task", ts: Date.now() - 2000, text: "Initial task", }, { type: "say", say: "api_req_started", ts: Date.now(), text: JSON.stringify({ apiProtocol: "anthropic", cost: 0.05, // Cost present = streaming complete tokensIn: 100, tokensOut: 50, }), }, { type: "say", say: "text", ts: Date.now(), text: "Response from API", }, ], }) // Wait for state to be updated await waitFor(() => { expect(getByTestId("chat-textarea")).toBeInTheDocument() }) // Clear message calls before simulating user input vi.mocked(vscode.postMessage).mockClear() // Simulate user sending a message when API is done const chatTextArea = getByTestId("chat-textarea") const input = chatTextArea.querySelector("input")! as HTMLInputElement await act(async () => { // Use fireEvent to properly trigger React's onChange handler fireEvent.change(input, { target: { value: "follow-up after completion" } }) // Simulate pressing Enter to send fireEvent.keyDown(input, { key: "Enter", code: "Enter" }) }) // Verify that the message was sent as askResponse, not queued await waitFor(() => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: "askResponse", askResponse: "messageResponse", text: "follow-up after completion", images: [], }) }) // Verify it was NOT queued expect(vscode.postMessage).not.toHaveBeenCalledWith( expect.objectContaining({ type: "queueMessage", }), ) }) it("preserves message order when messages sent during queue drain", async () => { const { getByTestId } = renderChatView() // Hydrate state with API request in progress and existing queue mockPostMessage({ clineMessages: [ { type: "say", say: "task", ts: Date.now() - 2000, text: "Initial task", }, { type: "say", say: "api_req_started", ts: Date.now(), text: JSON.stringify({ apiProtocol: "anthropic" }), // No cost = still streaming }, ], messageQueue: [ { id: "msg1", text: "queued message 1", images: [] }, { id: "msg2", text: "queued message 2", images: [] }, ], }) // Wait for state to be updated await waitFor(() => { expect(getByTestId("chat-textarea")).toBeInTheDocument() }) // Clear message calls before simulating user input vi.mocked(vscode.postMessage).mockClear() // Simulate user sending a new message while queue has items const chatTextArea = getByTestId("chat-textarea") const input = chatTextArea.querySelector("input")! as HTMLInputElement await act(async () => { fireEvent.change(input, { target: { value: "message during queue drain" } }) fireEvent.keyDown(input, { key: "Enter", code: "Enter" }) }) // Verify that the new message was queued (not sent directly) to preserve order await waitFor(() => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: "queueMessage", text: "message during queue drain", images: [], }) }) // Verify it was NOT sent as askResponse (which would break ordering) expect(vscode.postMessage).not.toHaveBeenCalledWith( expect.objectContaining({ type: "askResponse", askResponse: "messageResponse", }), ) }) }) describe("ChatView - Context Condensing Indicator Tests", () => { beforeEach(() => { vi.clearAllMocks() }) it("should add a condensing message to groupedMessages when isCondensing is true", async () => { // This test verifies that when the condenseTaskContextStarted message is received, // the isCondensing state is set to true and a synthetic condensing message is added // to the grouped messages list const { getByTestId, container } = renderChatView() // First hydrate state with an active task mockPostMessage({ clineMessages: [ { type: "say", say: "task", ts: Date.now() - 2000, text: "Initial task", }, { type: "say", say: "api_req_started", ts: Date.now() - 1000, text: JSON.stringify({ apiProtocol: "anthropic" }), }, ], }) // Wait for component to render await waitFor(() => { expect(getByTestId("chat-view")).toBeInTheDocument() }) // Allow time for useEvent hook to register message listener await act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)) }) // Dispatch a MessageEvent directly to trigger the message handler // This simulates the VSCode extension sending a message to the webview await act(async () => { const event = new MessageEvent("message", { data: { type: "condenseTaskContextStarted", text: "test-task-id", }, }) window.dispatchEvent(event) // Wait for React state updates await new Promise((resolve) => setTimeout(resolve, 0)) }) // Check that groupedMessages now includes a condensing message // With Virtuoso mocked, items render directly and we can find the ChatRow with partial condense_context message await waitFor( () => { const rows = container.querySelectorAll('[data-testid="chat-row"]') // Check for the actual message structure: partial condense_context message const condensingRow = Array.from(rows).find((row) => { const text = row.textContent || "" return text.includes('"say":"condense_context"') && text.includes('"partial":true') }) expect(condensingRow).toBeTruthy() }, { timeout: 2000 }, ) }) })