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>
271 lines
8.6 KiB
TypeScript
271 lines
8.6 KiB
TypeScript
/**
|
|
* @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<string, unknown>,
|
|
_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<string, unknown>) {
|
|
this.fit = mockFitAddonFit;
|
|
});
|
|
|
|
const MockWebLinksAddon = vi.fn(function MockWebLinksAddonConstructor(
|
|
this: Record<string, unknown>
|
|
) {
|
|
// 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<string, unknown>, _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<string, unknown>, _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<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
|
|
// ==========================================
|
|
|
|
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((<XTerminal {...makeDefaultProps()} />) as ReactElement);
|
|
expect(screen.getByTestId("xterminal-container")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the xterm viewport div", () => {
|
|
render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
|
|
expect(screen.getByTestId("xterm-viewport")).toBeInTheDocument();
|
|
});
|
|
|
|
it("applies the className prop to the container", () => {
|
|
render((<XTerminal {...makeDefaultProps()} className="custom-class" />) as ReactElement);
|
|
expect(screen.getByTestId("xterminal-container")).toHaveClass("custom-class");
|
|
});
|
|
|
|
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"
|
|
);
|
|
});
|
|
|
|
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", () => {
|
|
render((<XTerminal {...makeDefaultProps({ isConnected: true })} />) as ReactElement);
|
|
expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// Exit overlay
|
|
// ==========================================
|
|
|
|
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("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();
|
|
});
|
|
});
|
|
|
|
// ==========================================
|
|
// Accessibility
|
|
// ==========================================
|
|
|
|
describe("accessibility", () => {
|
|
it("has an accessible region role", () => {
|
|
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();
|
|
});
|
|
});
|
|
});
|