feat(web): integrate xterm.js with WebSocket terminal backend (#518)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was 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 #518.
This commit is contained in:
2026-02-26 02:55:53 +00:00
committed by jason.woltje
parent 8128eb7fbe
commit 417c6ab49c
9 changed files with 1694 additions and 100 deletions

View File

@@ -0,0 +1,227 @@
/**
* @file XTerminal.test.tsx
* @description Unit tests for the XTerminal component
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } 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();
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 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();
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";
// ==========================================
// Tests
// ==========================================
describe("XTerminal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsConnected = false;
mockSessionId = null;
mockTerminalCols = 80;
mockTerminalRows = 24;
});
afterEach(() => {
vi.clearAllMocks();
});
// ==========================================
// Rendering
// ==========================================
describe("rendering", () => {
it("renders the terminal container", () => {
render((<XTerminal token="test-token" />) as ReactElement);
expect(screen.getByTestId("xterminal-container")).toBeInTheDocument();
});
it("renders the xterm viewport div", () => {
render((<XTerminal token="test-token" />) 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);
expect(screen.getByTestId("xterminal-container")).toHaveClass("custom-class");
});
it("shows connecting message when not connected", () => {
mockIsConnected = false;
render((<XTerminal token="test-token" />) as ReactElement);
expect(screen.getByText("Connecting to terminal...")).toBeInTheDocument();
});
it("does not show connecting message when connected", async () => {
mockIsConnected = true;
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);
expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument();
});
});
// ==========================================
// useTerminal integration
// ==========================================
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" })
);
});
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");
});
});
// ==========================================
// Accessibility
// ==========================================
describe("accessibility", () => {
it("has an accessible region role", () => {
render((<XTerminal token="test-token" />) as ReactElement);
expect(screen.getByRole("region", { name: "Terminal" })).toBeInTheDocument();
});
});
});