feat(web): implement multi-session terminal tab management (#520)
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:
2026-02-26 03:18:35 +00:00
committed by jason.woltje
parent 13aa52aa53
commit 859dcfc4b7
7 changed files with 1913 additions and 300 deletions

View File

@@ -4,21 +4,13 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent } from "@testing-library/react";
import type { ReactElement } from "react";
// ==========================================
// Mocks — set up before importing components
// ==========================================
// Mock socket.io-client
vi.mock("socket.io-client");
// Mock lib/config
vi.mock("@/lib/config", () => ({
API_BASE_URL: "http://localhost:3001",
}));
// Mock xterm packages — they require a DOM canvas not available in jsdom
const mockTerminalDispose = vi.fn();
const mockTerminalWrite = vi.fn();
@@ -76,26 +68,6 @@ vi.mock("@xterm/addon-web-links", () => ({
// Mock the CSS import
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
// Mock useTerminal hook
const mockCreateSession = vi.fn();
const mockSendInput = vi.fn();
const mockResize = vi.fn();
const mockCloseSession = vi.fn();
let mockIsConnected = false;
let mockSessionId: string | null = null;
vi.mock("@/hooks/useTerminal", () => ({
useTerminal: vi.fn(() => ({
isConnected: mockIsConnected,
sessionId: mockSessionId,
createSession: mockCreateSession,
sendInput: mockSendInput,
resize: mockResize,
closeSession: mockCloseSession,
connectionError: null,
})),
}));
// Mock ResizeObserver
const mockObserve = vi.fn();
const mockUnobserve = vi.fn();
@@ -128,6 +100,31 @@ vi.stubGlobal(
import { XTerminal } from "./XTerminal";
// ==========================================
// Default props factory
// ==========================================
const mockSendInput = vi.fn();
const mockResize = vi.fn();
const mockCloseSession = vi.fn();
const mockRegisterOutputCallback = vi.fn(() => vi.fn()); // returns unsubscribe fn
const mockOnRestart = vi.fn();
function makeDefaultProps(
overrides: Partial<Parameters<typeof XTerminal>[0]> = {}
): Parameters<typeof XTerminal>[0] {
return {
sessionId: "session-test",
sendInput: mockSendInput,
resize: mockResize,
closeSession: mockCloseSession,
registerOutputCallback: mockRegisterOutputCallback,
isConnected: false,
sessionStatus: "active" as const,
...overrides,
};
}
// ==========================================
// Tests
// ==========================================
@@ -135,10 +132,9 @@ import { XTerminal } from "./XTerminal";
describe("XTerminal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsConnected = false;
mockSessionId = null;
mockTerminalCols = 80;
mockTerminalRows = 24;
mockRegisterOutputCallback.mockReturnValue(vi.fn());
});
afterEach(() => {
@@ -151,66 +147,101 @@ describe("XTerminal", () => {
describe("rendering", () => {
it("renders the terminal container", () => {
render((<XTerminal token="test-token" />) as ReactElement);
render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
expect(screen.getByTestId("xterminal-container")).toBeInTheDocument();
});
it("renders the xterm viewport div", () => {
render((<XTerminal token="test-token" />) as ReactElement);
render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
expect(screen.getByTestId("xterm-viewport")).toBeInTheDocument();
});
it("applies the className prop to the container", () => {
render((<XTerminal token="test-token" className="custom-class" />) as ReactElement);
render((<XTerminal {...makeDefaultProps()} className="custom-class" />) as ReactElement);
expect(screen.getByTestId("xterminal-container")).toHaveClass("custom-class");
});
it("shows connecting message when not connected", () => {
mockIsConnected = false;
it("sets data-session-id on the container", () => {
render((<XTerminal {...makeDefaultProps({ sessionId: "my-session" })} />) as ReactElement);
expect(screen.getByTestId("xterminal-container")).toHaveAttribute(
"data-session-id",
"my-session"
);
});
render((<XTerminal token="test-token" />) as ReactElement);
it("shows connecting message when not connected and session is active", () => {
render((<XTerminal {...makeDefaultProps({ isConnected: false })} />) as ReactElement);
expect(screen.getByText("Connecting to terminal...")).toBeInTheDocument();
});
it("does not show connecting message when connected", async () => {
mockIsConnected = true;
it("does not show connecting message when connected", () => {
render((<XTerminal {...makeDefaultProps({ isConnected: true })} />) as ReactElement);
expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument();
});
const { useTerminal } = await import("@/hooks/useTerminal");
vi.mocked(useTerminal).mockReturnValue({
isConnected: true,
sessionId: "session-xyz",
createSession: mockCreateSession,
sendInput: mockSendInput,
resize: mockResize,
closeSession: mockCloseSession,
connectionError: null,
});
render((<XTerminal token="test-token" />) as ReactElement);
it("does not show connecting message when session has exited", () => {
render(
(
<XTerminal {...makeDefaultProps({ isConnected: false, sessionStatus: "exited" })} />
) as ReactElement
);
expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument();
});
});
// ==========================================
// useTerminal integration
// Exit overlay
// ==========================================
describe("useTerminal integration", () => {
it("passes the token to useTerminal", async () => {
const { useTerminal } = await import("@/hooks/useTerminal");
render((<XTerminal token="my-auth-token" />) as ReactElement);
expect(vi.mocked(useTerminal)).toHaveBeenCalledWith(
expect.objectContaining({ token: "my-auth-token" })
);
describe("exit overlay", () => {
it("shows restart button when session has exited", () => {
render((<XTerminal {...makeDefaultProps({ sessionStatus: "exited" })} />) as ReactElement);
expect(screen.getByRole("button", { name: /restart terminal/i })).toBeInTheDocument();
});
it("passes onOutput, onExit, onError callbacks to useTerminal", async () => {
const { useTerminal } = await import("@/hooks/useTerminal");
render((<XTerminal token="test-token" />) as ReactElement);
const callArgs = vi.mocked(useTerminal).mock.calls[0]?.[0];
expect(typeof callArgs?.onOutput).toBe("function");
expect(typeof callArgs?.onExit).toBe("function");
expect(typeof callArgs?.onError).toBe("function");
it("does not show restart button when session is active", () => {
render((<XTerminal {...makeDefaultProps({ sessionStatus: "active" })} />) as ReactElement);
expect(screen.queryByRole("button", { name: /restart terminal/i })).not.toBeInTheDocument();
});
it("shows exit code in restart button when provided", () => {
render(
(
<XTerminal {...makeDefaultProps({ sessionStatus: "exited", exitCode: 1 })} />
) as ReactElement
);
expect(screen.getByRole("button", { name: /exit 1/i })).toBeInTheDocument();
});
it("calls onRestart when restart button is clicked", () => {
render(
(
<XTerminal {...makeDefaultProps({ sessionStatus: "exited", onRestart: mockOnRestart })} />
) as ReactElement
);
fireEvent.click(screen.getByRole("button", { name: /restart terminal/i }));
expect(mockOnRestart).toHaveBeenCalledTimes(1);
});
});
// ==========================================
// Output callback registration
// ==========================================
describe("registerOutputCallback", () => {
it("registers a callback for its sessionId on mount", () => {
render((<XTerminal {...makeDefaultProps({ sessionId: "test-session" })} />) as ReactElement);
expect(mockRegisterOutputCallback).toHaveBeenCalledWith("test-session", expect.any(Function));
});
it("calls the returned unsubscribe function on unmount", () => {
const unsubscribe = vi.fn();
mockRegisterOutputCallback.mockReturnValue(unsubscribe);
const { unmount } = render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
unmount();
expect(unsubscribe).toHaveBeenCalled();
});
});
@@ -220,8 +251,20 @@ describe("XTerminal", () => {
describe("accessibility", () => {
it("has an accessible region role", () => {
render((<XTerminal token="test-token" />) as ReactElement);
render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
expect(screen.getByRole("region", { name: "Terminal" })).toBeInTheDocument();
});
});
// ==========================================
// Visibility
// ==========================================
describe("isVisible", () => {
it("renders with isVisible=true by default", () => {
render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
// Container is present; isVisible affects re-fit timing
expect(screen.getByTestId("xterminal-container")).toBeInTheDocument();
});
});
});