Files
stack/apps/web/src/components/terminal/TerminalPanel.test.tsx
Jason Woltje 61522f43aa
All checks were successful
ci/woodpecker/push/web Pipeline was successful
feat(web): add agent output terminal tabs for orchestrator sessions (CT-ORCH-002)
Implements read-only agent output viewing in the TerminalPanel as a
separate tab group alongside interactive PTY terminal sessions.

- useAgentStream hook: SSE connection to /api/orchestrator/events with
  exponential backoff reconnect, per-agent output accumulation, and
  full lifecycle tracking (spawning→running→completed/error)
- AgentTerminal component: read-only <pre>-based output view with ANSI
  stripping, status indicator (pulse/dot), status badge, elapsed duration,
  copy-to-clipboard, and error message overlay
- TerminalPanel integration: agent tabs appear automatically when agents
  are active, show colored status dots, dismissable when completed/error,
  and section divider separates terminal vs agent tabs
- 79 new unit tests across useAgentStream, AgentTerminal, and TerminalPanel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:03:42 -06:00

582 lines
22 KiB
TypeScript

/**
* @file TerminalPanel.test.tsx
* @description Unit tests for the TerminalPanel component — multi-tab scenarios
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import type { ReactElement } from "react";
// ==========================================
// Mocks
// ==========================================
// Mock XTerminal to avoid xterm.js DOM dependencies in panel tests
vi.mock("./XTerminal", () => ({
XTerminal: vi.fn(
({
sessionId,
isVisible,
sessionStatus,
}: {
sessionId: string;
isVisible: boolean;
sessionStatus: string;
}) => (
<div
data-testid="mock-xterminal"
data-session-id={sessionId}
data-visible={isVisible ? "true" : "false"}
data-status={sessionStatus}
/>
)
),
}));
// Mock AgentTerminal to avoid complexity in panel tests
vi.mock("./AgentTerminal", () => ({
AgentTerminal: vi.fn(
({ agent }: { agent: { agentId: string; agentType: string; status: string } }) => (
<div
data-testid="mock-agent-terminal"
data-agent-id={agent.agentId}
data-agent-type={agent.agentType}
data-status={agent.status}
/>
)
),
}));
// Mock useTerminalSessions
const mockCreateSession = vi.fn();
const mockCloseSession = vi.fn();
const mockRenameSession = vi.fn();
const mockSetActiveSession = vi.fn();
const mockSendInput = vi.fn();
const mockResize = vi.fn();
const mockRegisterOutputCallback = vi.fn(() => vi.fn());
// Mutable state for the mock — tests update these
let mockSessions = new Map<
string,
{
sessionId: string;
name: string;
status: "active" | "exited";
exitCode?: number;
}
>();
let mockActiveSessionId: string | null = null;
let mockIsConnected = false;
let mockConnectionError: string | null = null;
vi.mock("@/hooks/useTerminalSessions", () => ({
useTerminalSessions: vi.fn(() => ({
sessions: mockSessions,
activeSessionId: mockActiveSessionId,
isConnected: mockIsConnected,
connectionError: mockConnectionError,
createSession: mockCreateSession,
closeSession: mockCloseSession,
renameSession: mockRenameSession,
setActiveSession: mockSetActiveSession,
sendInput: mockSendInput,
resize: mockResize,
registerOutputCallback: mockRegisterOutputCallback,
})),
}));
// Mock useAgentStream
const mockDismissAgent = vi.fn();
let mockAgents = new Map<
string,
{
agentId: string;
agentType: string;
status: "spawning" | "running" | "completed" | "error";
outputLines: string[];
startedAt: number;
}
>();
let mockAgentStreamConnected = false;
vi.mock("@/hooks/useAgentStream", () => ({
useAgentStream: vi.fn(() => ({
agents: mockAgents,
isConnected: mockAgentStreamConnected,
connectionError: null,
dismissAgent: mockDismissAgent,
})),
}));
import { TerminalPanel } from "./TerminalPanel";
// ==========================================
// Helpers
// ==========================================
function setTwoSessions(): void {
mockSessions = new Map([
["session-1", { sessionId: "session-1", name: "Terminal 1", status: "active" }],
["session-2", { sessionId: "session-2", name: "Terminal 2", status: "active" }],
]);
mockActiveSessionId = "session-1";
}
// ==========================================
// Tests
// ==========================================
describe("TerminalPanel", () => {
const onClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockSessions = new Map();
mockActiveSessionId = null;
mockIsConnected = false;
mockConnectionError = null;
mockRegisterOutputCallback.mockReturnValue(vi.fn());
mockAgents = new Map();
mockAgentStreamConnected = false;
});
// ==========================================
// Rendering
// ==========================================
describe("rendering", () => {
it("renders the terminal panel", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("region", { name: "Terminal panel" })).toBeInTheDocument();
});
it("renders with height 280 when open", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const panel = screen.getByRole("region", { name: "Terminal panel" });
expect(panel).toHaveStyle({ height: "280px" });
});
it("renders with height 0 when closed", () => {
const { container } = render(
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
);
const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]');
expect(panel).toHaveStyle({ height: "0px" });
});
it("renders empty state when no sessions exist", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
// No XTerminal instances should be mounted
expect(screen.queryByTestId("mock-xterminal")).not.toBeInTheDocument();
});
it("shows connecting message in empty state when not connected", () => {
mockIsConnected = false;
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByText("Connecting...")).toBeInTheDocument();
});
it("shows creating message in empty state when connected", () => {
mockIsConnected = true;
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByText("Creating terminal...")).toBeInTheDocument();
});
});
// ==========================================
// Tab bar from sessions
// ==========================================
describe("tab bar", () => {
it("renders a tab for each session", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("tab", { name: "Terminal 1" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Terminal 2" })).toBeInTheDocument();
});
it("marks the active session tab as selected", () => {
setTwoSessions();
mockActiveSessionId = "session-2";
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute(
"aria-selected",
"true"
);
expect(screen.getByRole("tab", { name: "Terminal 1" })).toHaveAttribute(
"aria-selected",
"false"
);
});
it("calls setActiveSession when a tab is clicked", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.click(screen.getByRole("tab", { name: "Terminal 2" }));
expect(mockSetActiveSession).toHaveBeenCalledWith("session-2");
});
it("has tablist role on the tab bar", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("tablist")).toBeInTheDocument();
});
});
// ==========================================
// New tab button
// ==========================================
describe("new tab button", () => {
it("renders the new tab button", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("button", { name: "New terminal tab" })).toBeInTheDocument();
});
it("calls createSession when new tab button is clicked", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.click(screen.getByRole("button", { name: "New terminal tab" }));
expect(mockCreateSession).toHaveBeenCalledWith(
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect.objectContaining({ name: expect.any(String) })
);
});
});
// ==========================================
// Per-tab close button
// ==========================================
describe("per-tab close button", () => {
it("renders a close button for each tab", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("button", { name: "Close Terminal 1" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Close Terminal 2" })).toBeInTheDocument();
});
it("calls closeSession with the correct sessionId when tab close is clicked", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.click(screen.getByRole("button", { name: "Close Terminal 1" }));
expect(mockCloseSession).toHaveBeenCalledWith("session-1");
});
});
// ==========================================
// Panel close button
// ==========================================
describe("panel close button", () => {
it("renders the close panel button", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("button", { name: "Close terminal" })).toBeInTheDocument();
});
it("calls onClose when close panel button is clicked", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.click(screen.getByRole("button", { name: "Close terminal" }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
// ==========================================
// Multi-tab XTerminal rendering
// ==========================================
describe("multi-tab terminal rendering", () => {
it("renders an XTerminal for each session", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const terminals = screen.getAllByTestId("mock-xterminal");
expect(terminals).toHaveLength(2);
});
it("shows the active session terminal as visible", () => {
setTwoSessions();
mockActiveSessionId = "session-1";
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const terminal1 = screen
.getAllByTestId("mock-xterminal")
.find((el) => el.getAttribute("data-session-id") === "session-1");
expect(terminal1).toHaveAttribute("data-visible", "true");
});
it("hides inactive session terminals", () => {
setTwoSessions();
mockActiveSessionId = "session-1";
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const terminal2 = screen
.getAllByTestId("mock-xterminal")
.find((el) => el.getAttribute("data-session-id") === "session-2");
expect(terminal2).toHaveAttribute("data-visible", "false");
});
it("passes sessionStatus to XTerminal", () => {
mockSessions = new Map([
[
"session-1",
{ sessionId: "session-1", name: "Terminal 1", status: "exited", exitCode: 0 },
],
]);
mockActiveSessionId = "session-1";
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const terminal = screen.getByTestId("mock-xterminal");
expect(terminal).toHaveAttribute("data-status", "exited");
});
it("passes isVisible=false to all terminals when panel is closed", () => {
setTwoSessions();
const { container } = render(
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
);
const terminals = container.querySelectorAll('[data-testid="mock-xterminal"]');
terminals.forEach((terminal) => {
expect(terminal).toHaveAttribute("data-visible", "false");
});
});
});
// ==========================================
// Inline tab rename
// ==========================================
describe("tab rename", () => {
it("shows a rename input when a tab is double-clicked", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
expect(screen.getByTestId("tab-rename-input")).toBeInTheDocument();
});
it("calls renameSession when rename input loses focus", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
const input = screen.getByTestId("tab-rename-input");
fireEvent.change(input, { target: { value: "Custom Shell" } });
fireEvent.blur(input);
expect(mockRenameSession).toHaveBeenCalledWith("session-1", "Custom Shell");
});
it("calls renameSession when Enter is pressed in the rename input", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
const input = screen.getByTestId("tab-rename-input");
fireEvent.change(input, { target: { value: "New Name" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(mockRenameSession).toHaveBeenCalledWith("session-1", "New Name");
});
it("cancels rename when Escape is pressed", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
const input = screen.getByTestId("tab-rename-input");
fireEvent.change(input, { target: { value: "Abandoned Name" } });
fireEvent.keyDown(input, { key: "Escape" });
expect(mockRenameSession).not.toHaveBeenCalled();
expect(screen.queryByTestId("tab-rename-input")).not.toBeInTheDocument();
});
});
// ==========================================
// Connection error banner
// ==========================================
describe("connection error", () => {
it("shows a connection error banner when connectionError is set", () => {
mockConnectionError = "WebSocket connection failed";
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const alert = screen.getByRole("alert");
expect(alert).toBeInTheDocument();
expect(alert).toHaveTextContent(/WebSocket connection failed/);
});
it("does not show the error banner when connectionError is null", () => {
mockConnectionError = null;
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
// ==========================================
// Accessibility
// ==========================================
describe("accessibility", () => {
it("has aria-hidden=true when closed", () => {
const { container } = render(
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
);
const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]');
expect(panel).toHaveAttribute("aria-hidden", "true");
});
it("has aria-hidden=false when open", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const panel = screen.getByRole("region", { name: "Terminal panel" });
expect(panel).toHaveAttribute("aria-hidden", "false");
});
});
// ==========================================
// Auto-create session
// ==========================================
describe("auto-create first session", () => {
it("calls createSession when connected and no sessions exist", () => {
mockIsConnected = true;
mockSessions = new Map();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(mockCreateSession).toHaveBeenCalledWith(
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect.objectContaining({ name: expect.any(String) })
);
});
it("does not call createSession when sessions already exist", () => {
mockIsConnected = true;
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(mockCreateSession).not.toHaveBeenCalled();
});
it("does not call createSession when not connected", () => {
mockIsConnected = false;
mockSessions = new Map();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(mockCreateSession).not.toHaveBeenCalled();
});
it("does not call createSession when panel is closed", () => {
mockIsConnected = true;
mockSessions = new Map();
render((<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement);
expect(mockCreateSession).not.toHaveBeenCalled();
});
});
// ==========================================
// Agent tab integration
// ==========================================
describe("agent tab integration", () => {
function setOneAgent(status: "spawning" | "running" | "completed" | "error" = "running"): void {
mockAgents = new Map([
[
"agent-1",
{
agentId: "agent-1",
agentType: "worker",
status,
outputLines: ["Hello from agent\n"],
startedAt: Date.now() - 3000,
},
],
]);
}
it("renders an agent tab when an agent is active", () => {
setOneAgent("running");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getAllByTestId("agent-tab")).toHaveLength(1);
});
it("renders no agent tabs when agents map is empty", () => {
mockAgents = new Map();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.queryByTestId("agent-tab")).not.toBeInTheDocument();
});
it("agent tab button has the agent type as label", () => {
setOneAgent("running");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("tab", { name: "Agent: worker" })).toBeInTheDocument();
});
it("agent tab has role=tab", () => {
setOneAgent("running");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("tab", { name: "Agent: worker" })).toBeInTheDocument();
});
it("shows dismiss button for completed agents", () => {
setOneAgent("completed");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("button", { name: "Dismiss worker agent" })).toBeInTheDocument();
});
it("shows dismiss button for error agents", () => {
setOneAgent("error");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("button", { name: "Dismiss worker agent" })).toBeInTheDocument();
});
it("does not show dismiss button for running agents", () => {
setOneAgent("running");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(
screen.queryByRole("button", { name: "Dismiss worker agent" })
).not.toBeInTheDocument();
});
it("does not show dismiss button for spawning agents", () => {
setOneAgent("spawning");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(
screen.queryByRole("button", { name: "Dismiss worker agent" })
).not.toBeInTheDocument();
});
it("calls dismissAgent when dismiss button is clicked", () => {
setOneAgent("completed");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.click(screen.getByRole("button", { name: "Dismiss worker agent" }));
expect(mockDismissAgent).toHaveBeenCalledWith("agent-1");
});
it("renders AgentTerminal when agent tab is active", () => {
setOneAgent("running");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
// Click the agent tab to make it active
fireEvent.click(screen.getByRole("tab", { name: "Agent: worker" }));
// AgentTerminal should be rendered (mock shows mock-agent-terminal)
expect(screen.getByTestId("mock-agent-terminal")).toBeInTheDocument();
});
it("shows a divider between terminal and agent tabs", () => {
setTwoSessions();
setOneAgent("running");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
// The divider div is aria-hidden; check it's present in the DOM
const tablist = screen.getByRole("tablist");
const divider = tablist.querySelector('[aria-hidden="true"][style*="width: 1"]');
expect(divider).toBeInTheDocument();
});
it("agent tabs show correct data-agent-status", () => {
setOneAgent("running");
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const tab = screen.getByTestId("agent-tab");
expect(tab).toHaveAttribute("data-agent-status", "running");
});
it("empty state not shown when agents exist but no terminal sessions", () => {
mockSessions = new Map();
setOneAgent("running");
mockIsConnected = false;
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.queryByText("Connecting...")).not.toBeInTheDocument();
});
});
});