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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user