All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
369 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|