feat(web): implement multi-session terminal tab management (#520)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
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>
This commit was merged in pull request #520.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file TerminalPanel.test.tsx
|
||||
* @description Unit tests for the TerminalPanel component
|
||||
* @description Unit tests for the TerminalPanel component — multi-tab scenarios
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -13,27 +13,93 @@ import type { ReactElement } from "react";
|
||||
|
||||
// Mock XTerminal to avoid xterm.js DOM dependencies in panel tests
|
||||
vi.mock("./XTerminal", () => ({
|
||||
XTerminal: vi.fn(({ token, isVisible }: { token: string; isVisible: boolean }) => (
|
||||
<div
|
||||
data-testid="mock-xterminal"
|
||||
data-token={token}
|
||||
data-visible={isVisible ? "true" : "false"}
|
||||
/>
|
||||
)),
|
||||
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 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,
|
||||
})),
|
||||
}));
|
||||
|
||||
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();
|
||||
const onTabChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSessions = new Map();
|
||||
mockActiveSessionId = null;
|
||||
mockIsConnected = false;
|
||||
mockConnectionError = null;
|
||||
mockRegisterOutputCallback.mockReturnValue(vi.fn());
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
@@ -60,112 +126,249 @@ describe("TerminalPanel", () => {
|
||||
expect(panel).toHaveStyle({ height: "0px" });
|
||||
});
|
||||
|
||||
it("passes isVisible=true to XTerminal when open", () => {
|
||||
it("renders empty state when no sessions exist", () => {
|
||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||
const xterm = screen.getByTestId("mock-xterminal");
|
||||
expect(xterm).toHaveAttribute("data-visible", "true");
|
||||
// No XTerminal instances should be mounted
|
||||
expect(screen.queryByTestId("mock-xterminal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes isVisible=false to XTerminal when closed", () => {
|
||||
const { container } = render(
|
||||
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
|
||||
);
|
||||
// Use container query since the element is inside an aria-hidden region
|
||||
const xterm = container.querySelector('[data-testid="mock-xterminal"]');
|
||||
expect(xterm).toHaveAttribute("data-visible", "false");
|
||||
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("passes token to XTerminal", () => {
|
||||
render(
|
||||
(<TerminalPanel open={true} onClose={onClose} token="my-auth-token" />) as ReactElement
|
||||
);
|
||||
const xterm = screen.getByTestId("mock-xterminal");
|
||||
expect(xterm).toHaveAttribute("data-token", "my-auth-token");
|
||||
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
|
||||
// Tab bar from sessions
|
||||
// ==========================================
|
||||
|
||||
describe("tab bar", () => {
|
||||
it("renders default tabs when none provided", () => {
|
||||
it("renders a tab for each session", () => {
|
||||
setTwoSessions();
|
||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||
expect(screen.getByRole("tab", { name: "main" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders custom tabs", () => {
|
||||
const tabs = [
|
||||
{ id: "tab1", label: "Terminal 1" },
|
||||
{ id: "tab2", label: "Terminal 2" },
|
||||
];
|
||||
render(
|
||||
(
|
||||
<TerminalPanel open={true} onClose={onClose} tabs={tabs} 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 tab as selected", () => {
|
||||
const tabs = [
|
||||
{ id: "tab1", label: "Tab 1" },
|
||||
{ id: "tab2", label: "Tab 2" },
|
||||
];
|
||||
render(
|
||||
(
|
||||
<TerminalPanel
|
||||
open={true}
|
||||
onClose={onClose}
|
||||
tabs={tabs}
|
||||
activeTab="tab2"
|
||||
token="test-token"
|
||||
/>
|
||||
) as ReactElement
|
||||
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"
|
||||
);
|
||||
const tab2 = screen.getByRole("tab", { name: "Tab 2" });
|
||||
expect(tab2).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("calls onTabChange when a tab is clicked", () => {
|
||||
const tabs = [
|
||||
{ id: "tab1", label: "Tab 1" },
|
||||
{ id: "tab2", label: "Tab 2" },
|
||||
];
|
||||
render(
|
||||
(
|
||||
<TerminalPanel
|
||||
open={true}
|
||||
onClose={onClose}
|
||||
tabs={tabs}
|
||||
onTabChange={onTabChange}
|
||||
token="test-token"
|
||||
/>
|
||||
) as ReactElement
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Tab 2" }));
|
||||
expect(onTabChange).toHaveBeenCalledWith("tab2");
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Close button
|
||||
// New tab button
|
||||
// ==========================================
|
||||
|
||||
describe("close button", () => {
|
||||
it("renders the close 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 button is clicked", () => {
|
||||
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
|
||||
// ==========================================
|
||||
@@ -175,8 +378,6 @@ describe("TerminalPanel", () => {
|
||||
const { container } = render(
|
||||
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
|
||||
);
|
||||
// When aria-hidden=true, testing-library role queries ignore the element's aria-label.
|
||||
// Use a direct DOM query to verify the attribute.
|
||||
const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]');
|
||||
expect(panel).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
@@ -186,10 +387,42 @@ describe("TerminalPanel", () => {
|
||||
const panel = screen.getByRole("region", { name: "Terminal panel" });
|
||||
expect(panel).toHaveAttribute("aria-hidden", "false");
|
||||
});
|
||||
});
|
||||
|
||||
it("has tablist role on the tab bar", () => {
|
||||
// ==========================================
|
||||
// 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(screen.getByRole("tablist")).toBeInTheDocument();
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user