import { act, fireEvent, render, screen, waitFor, } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "./App"; import type { ClientMessage } from "./protocol"; import { dispatch, resetStore } from "./store"; import { resetTheme } from "./theme"; import { resetWorkspace } from "./workspace"; import type { OriSocket } from "./ws"; vi.mock("./api", () => ({ listFiles: vi.fn().mockResolvedValue({ path: "/w", entries: [] }), readFile: vi.fn().mockResolvedValue({ path: "/w/x", content: "" }), writeFile: vi.fn().mockResolvedValue(undefined), searchFiles: vi.fn().mockResolvedValue({ root: "/w", files: [] }), listSkills: vi.fn().mockResolvedValue({ skills: [] }), rawFileUrl: (path: string) => `/api/raw?path=${encodeURIComponent(path)}`, })); // The real terminal drags xterm and a WebSocket along; a stub is enough here. vi.mock("./components/TerminalPane", () => ({ TerminalPane: () =>
, })); function fakeSocket() { const sent: ClientMessage[] = []; const socket: OriSocket = { send: (message) => sent.push(message), close: () => {}, }; return { socket, sent }; } describe("App", () => { beforeEach(() => { resetStore(); resetWorkspace(); resetTheme(); }); it("switches between the light and dark themes from the header", () => { const { socket } = fakeSocket(); render(); expect(document.documentElement.dataset.theme).toBe("light"); fireEvent.click( screen.getByRole("button", { name: "Switch to dark theme" }), ); expect(document.documentElement.dataset.theme).toBe("dark"); fireEvent.click( screen.getByRole("button", { name: "Switch to light theme" }), ); expect(document.documentElement.dataset.theme).toBe("light"); }); it("toggles the workspace panel and switches its tabs", async () => { const { socket } = fakeSocket(); render(); // The panel stays mounted (so its state survives) and hides via CSS. const workspace = screen.getByRole("complementary", { name: "workspace" }); expect(workspace.className).toContain("workspace-hidden"); fireEvent.click(screen.getByRole("button", { name: "⊞ Workspace" })); expect(workspace.className).not.toContain("workspace-hidden"); fireEvent.click(screen.getByRole("button", { name: "Terminal" })); await waitFor(() => expect(screen.getByTestId("terminal-pane")).toBeDefined(), ); fireEvent.click(screen.getByRole("button", { name: "⊞ Workspace" })); expect(workspace.className).toContain("workspace-hidden"); }); it("sends a prompt typed in the composer", () => { const { socket, sent } = fakeSocket(); render(); fireEvent.change(screen.getByLabelText("prompt"), { target: { value: "hello agent" }, }); fireEvent.click(screen.getByRole("button", { name: "Send" })); expect(sent).toEqual([{ type: "prompt", text: "hello agent" }]); }); it("shows the streamed conversation from the store", () => { const { socket } = fakeSocket(); render(); act(() => { dispatch({ type: "server", message: { type: "user_message", text: "my question" }, }); dispatch({ type: "server", message: { type: "session_update", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "my **answer**" }, }, }, }); }); expect(screen.getByText("my question")).toBeDefined(); expect(screen.getByText("answer")).toBeDefined(); // bold inner text }); it("offers Stop instead of Send during a turn, and Stop cancels", () => { const { socket, sent } = fakeSocket(); render(); act(() => { dispatch({ type: "server", message: { type: "turn_started" } }); }); expect(screen.queryByRole("button", { name: "Send" })).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Stop" })); expect(sent).toEqual([{ type: "cancel" }]); }); it("shows the connection status and session id from hello", () => { const { socket } = fakeSocket(); render(); act(() => { dispatch({ type: "server", message: { type: "hello", sessionId: "sess-42" }, }); }); expect(screen.getByText(/online/)).toBeDefined(); expect(screen.getByText(/sess-42/)).toBeDefined(); }); it("renders tool calls, plan and thoughts from updates", () => { const { socket } = fakeSocket(); render(); act(() => { dispatch({ type: "server", message: { type: "session_update", update: { sessionUpdate: "tool_call", toolCallId: "c1", title: "Running tests", kind: "execute", status: "in_progress", }, }, }); dispatch({ type: "server", message: { type: "session_update", update: { sessionUpdate: "plan", entries: [ { content: "write the fix", priority: "high", status: "in_progress", }, ], }, }, }); dispatch({ type: "server", message: { type: "session_update", update: { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "let me think" }, }, }, }); }); expect(screen.getByText("Running tests")).toBeDefined(); expect(screen.getByText(/write the fix/)).toBeDefined(); expect(screen.getByText("let me think")).toBeDefined(); }); it("answers a permission request through the socket", () => { const { socket, sent } = fakeSocket(); render(); act(() => { dispatch({ type: "server", message: { type: "permission_request", requestId: "perm-9", request: { toolCall: { title: "Write main.go" }, options: [ { optionId: "allow-once", name: "Allow once", kind: "allow_once", }, { optionId: "reject-once", name: "Reject", kind: "reject_once" }, ], }, }, }); }); expect(screen.getByText(/Write main\.go/)).toBeDefined(); fireEvent.click(screen.getByRole("button", { name: "Allow once" })); expect(sent).toEqual([ { type: "permission_response", requestId: "perm-9", optionId: "allow-once", }, ]); }); it("keeps a streaming thought open and collapses it when the turn ends", () => { const { socket } = fakeSocket(); const { container } = render(); act(() => { dispatch({ type: "server", message: { type: "session_update", update: { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "streaming thought" }, }, }, }); }); // While streaming: the content is in a live (non-collapsible) block. expect(container.querySelector(".thought-live")).not.toBeNull(); expect(screen.getByText("💭 Thinking…")).toBeDefined(); expect(screen.getByText("streaming thought")).toBeDefined(); act(() => { dispatch({ type: "server", message: { type: "turn_ended", stopReason: "end_turn" }, }); }); // After the turn: a collapsible details block replaces it. expect(container.querySelector(".thought-live")).toBeNull(); expect(container.querySelector("details.thought")).not.toBeNull(); expect(screen.getByText("💭 Thought for a moment")).toBeDefined(); }); it("shows the spinner with a working phrase during a turn", () => { const { socket } = fakeSocket(); const { container } = render(); act(() => { dispatch({ type: "server", message: { type: "turn_started" } }); }); expect(container.querySelector(".spinner")).not.toBeNull(); act(() => { dispatch({ type: "server", message: { type: "turn_ended", stopReason: "end_turn" }, }); }); expect(container.querySelector(".spinner")).toBeNull(); }); it("does not send empty prompts", () => { const { socket, sent } = fakeSocket(); render(); fireEvent.change(screen.getByLabelText("prompt"), { target: { value: " " }, }); fireEvent.keyDown(screen.getByLabelText("prompt"), { key: "Enter" }); expect(sent).toEqual([]); }); });