/** * @file XTerminal.test.tsx * @description Unit tests for the XTerminal component */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import type { ReactElement } from "react"; // ========================================== // Mocks — set up before importing components // ========================================== // Mock xterm packages — they require a DOM canvas not available in jsdom const mockTerminalDispose = vi.fn(); const mockTerminalWrite = vi.fn(); const mockTerminalClear = vi.fn(); const mockTerminalOpen = vi.fn(); const mockOnData = vi.fn((_handler: (data: string) => void) => ({ dispose: vi.fn() })); const mockLoadAddon = vi.fn(); let mockTerminalCols = 80; let mockTerminalRows = 24; const MockTerminal = vi.fn(function MockTerminalConstructor( this: Record, _options: unknown ) { this.open = mockTerminalOpen; this.loadAddon = mockLoadAddon; this.onData = mockOnData; this.write = mockTerminalWrite; this.clear = mockTerminalClear; this.dispose = mockTerminalDispose; this.options = {}; Object.defineProperty(this, "cols", { get: () => mockTerminalCols, configurable: true, }); Object.defineProperty(this, "rows", { get: () => mockTerminalRows, configurable: true, }); }); const mockFitAddonFit = vi.fn(); const MockFitAddon = vi.fn(function MockFitAddonConstructor(this: Record) { this.fit = mockFitAddonFit; }); const MockWebLinksAddon = vi.fn(function MockWebLinksAddonConstructor( this: Record ) { // no-op }); vi.mock("@xterm/xterm", () => ({ Terminal: MockTerminal, })); vi.mock("@xterm/addon-fit", () => ({ FitAddon: MockFitAddon, })); vi.mock("@xterm/addon-web-links", () => ({ WebLinksAddon: MockWebLinksAddon, })); // Mock the CSS import vi.mock("@xterm/xterm/css/xterm.css", () => ({})); // Mock ResizeObserver const mockObserve = vi.fn(); const mockUnobserve = vi.fn(); const mockDisconnect = vi.fn(); vi.stubGlobal( "ResizeObserver", vi.fn(function MockResizeObserver(this: Record, _callback: unknown) { this.observe = mockObserve; this.unobserve = mockUnobserve; this.disconnect = mockDisconnect; }) ); // Mock MutationObserver const mockMutationObserve = vi.fn(); const mockMutationDisconnect = vi.fn(); vi.stubGlobal( "MutationObserver", vi.fn(function MockMutationObserver(this: Record, _callback: unknown) { this.observe = mockMutationObserve; this.disconnect = mockMutationDisconnect; }) ); // ========================================== // Import component after mocks are set up // ========================================== 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[0]> = {} ): Parameters[0] { return { sessionId: "session-test", sendInput: mockSendInput, resize: mockResize, closeSession: mockCloseSession, registerOutputCallback: mockRegisterOutputCallback, isConnected: false, sessionStatus: "active" as const, ...overrides, }; } // ========================================== // Tests // ========================================== describe("XTerminal", () => { beforeEach(() => { vi.clearAllMocks(); mockTerminalCols = 80; mockTerminalRows = 24; mockRegisterOutputCallback.mockReturnValue(vi.fn()); }); afterEach(() => { vi.clearAllMocks(); }); // ========================================== // Rendering // ========================================== describe("rendering", () => { it("renders the terminal container", () => { render(() as ReactElement); expect(screen.getByTestId("xterminal-container")).toBeInTheDocument(); }); it("renders the xterm viewport div", () => { render(() as ReactElement); expect(screen.getByTestId("xterm-viewport")).toBeInTheDocument(); }); it("applies the className prop to the container", () => { render(() as ReactElement); expect(screen.getByTestId("xterminal-container")).toHaveClass("custom-class"); }); it("sets data-session-id on the container", () => { render(() as ReactElement); expect(screen.getByTestId("xterminal-container")).toHaveAttribute( "data-session-id", "my-session" ); }); it("shows connecting message when not connected and session is active", () => { render(() as ReactElement); expect(screen.getByText("Connecting to terminal...")).toBeInTheDocument(); }); it("does not show connecting message when connected", () => { render(() as ReactElement); expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument(); }); it("does not show connecting message when session has exited", () => { render( ( ) as ReactElement ); expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument(); }); }); // ========================================== // Exit overlay // ========================================== describe("exit overlay", () => { it("shows restart button when session has exited", () => { render(() as ReactElement); expect(screen.getByRole("button", { name: /restart terminal/i })).toBeInTheDocument(); }); it("does not show restart button when session is active", () => { render(() as ReactElement); expect(screen.queryByRole("button", { name: /restart terminal/i })).not.toBeInTheDocument(); }); it("shows exit code in restart button when provided", () => { render( ( ) as ReactElement ); expect(screen.getByRole("button", { name: /exit 1/i })).toBeInTheDocument(); }); it("calls onRestart when restart button is clicked", () => { render( ( ) 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(() 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(() as ReactElement); unmount(); expect(unsubscribe).toHaveBeenCalled(); }); }); // ========================================== // Accessibility // ========================================== describe("accessibility", () => { it("has an accessible region role", () => { render(() as ReactElement); expect(screen.getByRole("region", { name: "Terminal" })).toBeInTheDocument(); }); }); // ========================================== // Visibility // ========================================== describe("isVisible", () => { it("renders with isVisible=true by default", () => { render(() as ReactElement); // Container is present; isVisible affects re-fit timing expect(screen.getByTestId("xterminal-container")).toBeInTheDocument(); }); }); });