/** * @file AgentTerminal.test.tsx * @description Unit tests for the AgentTerminal component * * Tests cover: * - Output rendering * - Status display (status indicator + badge) * - ANSI stripping * - Agent header information (type, duration, jobId) * - Auto-scroll behavior * - Copy-to-clipboard * - Error message display * - Empty state rendering */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, act } from "@testing-library/react"; import type { ReactElement } from "react"; import { AgentTerminal } from "./AgentTerminal"; import type { AgentSession } from "@/hooks/useAgentStream"; // ========================================== // Mock navigator.clipboard // ========================================== const mockWriteText = vi.fn(() => Promise.resolve()); Object.defineProperty(navigator, "clipboard", { value: { writeText: mockWriteText }, writable: true, configurable: true, }); // ========================================== // Factory helpers // ========================================== function makeAgent(overrides: Partial = {}): AgentSession { return { agentId: "test-agent-1", agentType: "worker", status: "running", outputLines: [], startedAt: Date.now() - 5000, // 5s ago ...overrides, }; } // ========================================== // Tests // ========================================== describe("AgentTerminal", () => { beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers({ shouldAdvanceTime: false }); }); afterEach(() => { vi.useRealTimers(); }); // ========================================== // Rendering // ========================================== describe("rendering", () => { it("renders the agent terminal container", () => { render(() as ReactElement); expect(screen.getByTestId("agent-terminal")).toBeInTheDocument(); }); it("has region role with agent type label", () => { render(() as ReactElement); expect(screen.getByRole("region", { name: "Agent output: planner" })).toBeInTheDocument(); }); it("sets data-agent-id attribute", () => { render(() as ReactElement); const container = screen.getByTestId("agent-terminal"); expect(container).toHaveAttribute("data-agent-id", "my-agent-123"); }); it("applies className prop to the outer container", () => { render(() as ReactElement); expect(screen.getByTestId("agent-terminal")).toHaveClass("custom-cls"); }); }); // ========================================== // Header // ========================================== describe("header", () => { it("renders the agent type label", () => { render(() as ReactElement); expect(screen.getByTestId("agent-type-label")).toHaveTextContent("coordinator"); }); it("includes jobId in the label when provided", () => { render( ( ) as ReactElement ); expect(screen.getByTestId("agent-type-label")).toHaveTextContent("worker · job-42"); }); it("does not show jobId separator when jobId is absent", () => { render(() as ReactElement); expect(screen.getByTestId("agent-type-label")).not.toHaveTextContent("·"); }); it("renders the duration element", () => { render(() as ReactElement); expect(screen.getByTestId("agent-duration")).toBeInTheDocument(); }); it("shows seconds for short-running agents", () => { const agent = makeAgent({ startedAt: Date.now() - 8000 }); render(() as ReactElement); expect(screen.getByTestId("agent-duration")).toHaveTextContent("s"); }); it("shows minutes for long-running agents", () => { const agent = makeAgent({ startedAt: Date.now() - 125000 }); // 2m 5s render(() as ReactElement); expect(screen.getByTestId("agent-duration")).toHaveTextContent("m"); }); }); // ========================================== // Status indicator // ========================================== describe("status indicator", () => { it("shows a running indicator for running status", () => { render(() as ReactElement); const indicator = screen.getByTestId("status-indicator"); expect(indicator).toHaveAttribute("data-status", "running"); }); it("shows a spawning indicator for spawning status", () => { render(() as ReactElement); const indicator = screen.getByTestId("status-indicator"); expect(indicator).toHaveAttribute("data-status", "spawning"); }); it("shows completed indicator for completed status", () => { render( ( ) as ReactElement ); const indicator = screen.getByTestId("status-indicator"); expect(indicator).toHaveAttribute("data-status", "completed"); }); it("shows error indicator for error status", () => { render( ( ) as ReactElement ); const indicator = screen.getByTestId("status-indicator"); expect(indicator).toHaveAttribute("data-status", "error"); }); }); // ========================================== // Status badge // ========================================== describe("status badge", () => { it("renders the status badge", () => { render(() as ReactElement); expect(screen.getByTestId("status-badge")).toHaveTextContent("running"); }); it("shows 'spawning' badge for spawning status", () => { render(() as ReactElement); expect(screen.getByTestId("status-badge")).toHaveTextContent("spawning"); }); it("shows 'completed' badge for completed status", () => { render( ( ) as ReactElement ); expect(screen.getByTestId("status-badge")).toHaveTextContent("completed"); }); it("shows 'error' badge for error status", () => { render( ( ) as ReactElement ); expect(screen.getByTestId("status-badge")).toHaveTextContent("error"); }); }); // ========================================== // Output rendering // ========================================== describe("output area", () => { it("renders the output pre element", () => { render(() as ReactElement); expect(screen.getByTestId("agent-output")).toBeInTheDocument(); }); it("shows 'Waiting for output...' when outputLines is empty and status is running", () => { render( ( ) as ReactElement ); expect(screen.getByTestId("agent-output")).toHaveTextContent("Waiting for output..."); }); it("shows 'Spawning agent...' when status is spawning and no output", () => { render( ( ) as ReactElement ); expect(screen.getByTestId("agent-output")).toHaveTextContent("Spawning agent..."); }); it("renders output lines as text content", () => { const agent = makeAgent({ outputLines: ["Hello world\n", "Second line\n"], }); render(() as ReactElement); const output = screen.getByTestId("agent-output"); expect(output).toHaveTextContent("Hello world"); expect(output).toHaveTextContent("Second line"); }); it("strips ANSI escape codes from output", () => { const agent = makeAgent({ outputLines: ["\x1b[32mGreen text\x1b[0m\n"], }); render(() as ReactElement); const output = screen.getByTestId("agent-output"); expect(output).toHaveTextContent("Green text"); expect(output.textContent).not.toContain("\x1b"); }); it("has aria-live=polite for screen reader announcements", () => { render(() as ReactElement); expect(screen.getByTestId("agent-output")).toHaveAttribute("aria-live", "polite"); }); }); // ========================================== // Error message // ========================================== describe("error message", () => { it("shows error message when status is error and errorMessage is set", () => { const agent = makeAgent({ status: "error", endedAt: Date.now(), errorMessage: "Process crashed", }); render(() as ReactElement); expect(screen.getByTestId("agent-error-message")).toHaveTextContent("Process crashed"); }); it("renders alert role for error message", () => { const agent = makeAgent({ status: "error", endedAt: Date.now(), errorMessage: "OOM killed", }); render(() as ReactElement); expect(screen.getByRole("alert")).toBeInTheDocument(); }); it("does not show error message when status is running", () => { render(() as ReactElement); expect(screen.queryByTestId("agent-error-message")).not.toBeInTheDocument(); }); it("does not show error message when status is error but errorMessage is absent", () => { const agent = makeAgent({ status: "error", endedAt: Date.now() }); render(() as ReactElement); expect(screen.queryByTestId("agent-error-message")).not.toBeInTheDocument(); }); }); // ========================================== // Copy to clipboard // ========================================== describe("copy to clipboard", () => { it("renders the copy button", () => { render(() as ReactElement); expect(screen.getByTestId("copy-button")).toBeInTheDocument(); }); it("copy button has aria-label='Copy agent output'", () => { render(() as ReactElement); expect(screen.getByRole("button", { name: "Copy agent output" })).toBeInTheDocument(); }); it("calls clipboard.writeText with stripped output on click", async () => { const agent = makeAgent({ outputLines: ["\x1b[32mLine 1\x1b[0m\n", "Line 2\n"], }); render(() as ReactElement); await act(async () => { fireEvent.click(screen.getByTestId("copy-button")); await Promise.resolve(); }); expect(mockWriteText).toHaveBeenCalledWith("Line 1\nLine 2\n"); }); it("shows 'copied' text briefly after clicking copy", async () => { render(() as ReactElement); await act(async () => { fireEvent.click(screen.getByTestId("copy-button")); await Promise.resolve(); }); expect(screen.getByTestId("copy-button")).toHaveTextContent("copied"); }); it("reverts copy button text after timeout", async () => { render(() as ReactElement); await act(async () => { fireEvent.click(screen.getByTestId("copy-button")); await Promise.resolve(); }); act(() => { vi.advanceTimersByTime(2500); }); expect(screen.getByTestId("copy-button")).toHaveTextContent("copy"); }); }); // ========================================== // Auto-scroll // ========================================== describe("auto-scroll", () => { it("does not throw when outputLines changes", () => { const agent = makeAgent({ outputLines: ["Line 1\n"] }); const { rerender } = render(() as ReactElement); expect(() => { rerender( ( ) as ReactElement ); }).not.toThrow(); }); }); });