feat(web): integrate xterm.js with WebSocket terminal backend (#518)
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:
227
apps/web/src/components/terminal/XTerminal.test.tsx
Normal file
227
apps/web/src/components/terminal/XTerminal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user