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([]);
});
});