Files
stack/apps/web/src/components/terminal/AgentTerminal.test.tsx
Jason Woltje 9b2520ce1f
All checks were successful
ci/woodpecker/push/web Pipeline was successful
feat(web): add agent output terminal tabs for orchestrator sessions (#522)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 04:04:26 +00:00

369 lines
13 KiB
TypeScript

/**
* @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> = {}): 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((<AgentTerminal agent={makeAgent()} />) as ReactElement);
expect(screen.getByTestId("agent-terminal")).toBeInTheDocument();
});
it("has region role with agent type label", () => {
render((<AgentTerminal agent={makeAgent({ agentType: "planner" })} />) as ReactElement);
expect(screen.getByRole("region", { name: "Agent output: planner" })).toBeInTheDocument();
});
it("sets data-agent-id attribute", () => {
render((<AgentTerminal agent={makeAgent({ agentId: "my-agent-123" })} />) 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((<AgentTerminal agent={makeAgent()} className="custom-cls" />) as ReactElement);
expect(screen.getByTestId("agent-terminal")).toHaveClass("custom-cls");
});
});
// ==========================================
// Header
// ==========================================
describe("header", () => {
it("renders the agent type label", () => {
render((<AgentTerminal agent={makeAgent({ agentType: "coordinator" })} />) as ReactElement);
expect(screen.getByTestId("agent-type-label")).toHaveTextContent("coordinator");
});
it("includes jobId in the label when provided", () => {
render(
(
<AgentTerminal agent={makeAgent({ agentType: "worker", jobId: "job-42" })} />
) as ReactElement
);
expect(screen.getByTestId("agent-type-label")).toHaveTextContent("worker · job-42");
});
it("does not show jobId separator when jobId is absent", () => {
render((<AgentTerminal agent={makeAgent({ agentType: "worker" })} />) as ReactElement);
expect(screen.getByTestId("agent-type-label")).not.toHaveTextContent("·");
});
it("renders the duration element", () => {
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
expect(screen.getByTestId("agent-duration")).toBeInTheDocument();
});
it("shows seconds for short-running agents", () => {
const agent = makeAgent({ startedAt: Date.now() - 8000 });
render((<AgentTerminal agent={agent} />) 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((<AgentTerminal agent={agent} />) as ReactElement);
expect(screen.getByTestId("agent-duration")).toHaveTextContent("m");
});
});
// ==========================================
// Status indicator
// ==========================================
describe("status indicator", () => {
it("shows a running indicator for running status", () => {
render((<AgentTerminal agent={makeAgent({ status: "running" })} />) as ReactElement);
const indicator = screen.getByTestId("status-indicator");
expect(indicator).toHaveAttribute("data-status", "running");
});
it("shows a spawning indicator for spawning status", () => {
render((<AgentTerminal agent={makeAgent({ status: "spawning" })} />) as ReactElement);
const indicator = screen.getByTestId("status-indicator");
expect(indicator).toHaveAttribute("data-status", "spawning");
});
it("shows completed indicator for completed status", () => {
render(
(
<AgentTerminal agent={makeAgent({ status: "completed", endedAt: Date.now() })} />
) as ReactElement
);
const indicator = screen.getByTestId("status-indicator");
expect(indicator).toHaveAttribute("data-status", "completed");
});
it("shows error indicator for error status", () => {
render(
(
<AgentTerminal agent={makeAgent({ status: "error", endedAt: Date.now() })} />
) 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((<AgentTerminal agent={makeAgent({ status: "running" })} />) as ReactElement);
expect(screen.getByTestId("status-badge")).toHaveTextContent("running");
});
it("shows 'spawning' badge for spawning status", () => {
render((<AgentTerminal agent={makeAgent({ status: "spawning" })} />) as ReactElement);
expect(screen.getByTestId("status-badge")).toHaveTextContent("spawning");
});
it("shows 'completed' badge for completed status", () => {
render(
(
<AgentTerminal agent={makeAgent({ status: "completed", endedAt: Date.now() })} />
) as ReactElement
);
expect(screen.getByTestId("status-badge")).toHaveTextContent("completed");
});
it("shows 'error' badge for error status", () => {
render(
(
<AgentTerminal agent={makeAgent({ status: "error", endedAt: Date.now() })} />
) as ReactElement
);
expect(screen.getByTestId("status-badge")).toHaveTextContent("error");
});
});
// ==========================================
// Output rendering
// ==========================================
describe("output area", () => {
it("renders the output pre element", () => {
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
expect(screen.getByTestId("agent-output")).toBeInTheDocument();
});
it("shows 'Waiting for output...' when outputLines is empty and status is running", () => {
render(
(
<AgentTerminal agent={makeAgent({ status: "running", outputLines: [] })} />
) as ReactElement
);
expect(screen.getByTestId("agent-output")).toHaveTextContent("Waiting for output...");
});
it("shows 'Spawning agent...' when status is spawning and no output", () => {
render(
(
<AgentTerminal agent={makeAgent({ status: "spawning", outputLines: [] })} />
) 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((<AgentTerminal agent={agent} />) 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((<AgentTerminal agent={agent} />) 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((<AgentTerminal agent={makeAgent()} />) 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((<AgentTerminal agent={agent} />) 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((<AgentTerminal agent={agent} />) as ReactElement);
expect(screen.getByRole("alert")).toBeInTheDocument();
});
it("does not show error message when status is running", () => {
render((<AgentTerminal agent={makeAgent({ status: "running" })} />) 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((<AgentTerminal agent={agent} />) as ReactElement);
expect(screen.queryByTestId("agent-error-message")).not.toBeInTheDocument();
});
});
// ==========================================
// Copy to clipboard
// ==========================================
describe("copy to clipboard", () => {
it("renders the copy button", () => {
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
expect(screen.getByTestId("copy-button")).toBeInTheDocument();
});
it("copy button has aria-label='Copy agent output'", () => {
render((<AgentTerminal agent={makeAgent()} />) 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((<AgentTerminal agent={agent} />) 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((<AgentTerminal agent={makeAgent()} />) 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((<AgentTerminal agent={makeAgent()} />) 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((<AgentTerminal agent={agent} />) as ReactElement);
expect(() => {
rerender(
(
<AgentTerminal agent={{ ...agent, outputLines: ["Line 1\n", "Line 2\n"] }} />
) as ReactElement
);
}).not.toThrow();
});
});
});