import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { TerminalModal } from "../TerminalModal";
import * as useTerminalModule from "../../hooks/useTerminal";
import * as useTerminalSessionsModule from "../../hooks/useTerminalSessions";
import * as apiModule from "../../api";
// Mock hooks and API
vi.mock("../../hooks/useTerminal", () => ({
useTerminal: vi.fn(),
}));
vi.mock("../../hooks/useTerminalSessions", () => ({
useTerminalSessions: vi.fn(),
}));
vi.mock("../../api", () => ({
createTerminalSession: vi.fn(),
killPtyTerminalSession: vi.fn(),
listTerminalSessions: vi.fn().mockResolvedValue([]),
}));
// Mock xterm modules to prevent DOM errors in jsdom
const mockTerminalInstance = {
loadAddon: vi.fn(),
open: vi.fn(),
onData: vi.fn(() => ({ dispose: vi.fn() })),
dispose: vi.fn(),
write: vi.fn(),
clear: vi.fn(),
focus: vi.fn(),
options: { fontSize: 14 },
cols: 80,
rows: 24,
};
vi.mock("@xterm/xterm", () => ({
Terminal: vi.fn(() => mockTerminalInstance),
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn(() => ({
fit: vi.fn(),
dispose: vi.fn(),
})),
}));
vi.mock("@xterm/addon-web-links", () => ({
WebLinksAddon: vi.fn(() => ({
dispose: vi.fn(),
})),
}));
vi.mock("@xterm/addon-webgl", () => {
throw new Error("WebGL not available");
});
// Suppress xterm CSS import
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
const mockUseTerminal = vi.mocked(useTerminalModule.useTerminal);
const mockUseTerminalSessions = vi.mocked(useTerminalSessionsModule.useTerminalSessions);
const mockCreateTerminalSession = vi.mocked(apiModule.createTerminalSession);
const mockKillPtyTerminalSession = vi.mocked(apiModule.killPtyTerminalSession);
// Default tab state
const defaultTab = {
id: "tab-1",
sessionId: "test-session-123",
title: "bash",
isActive: true,
createdAt: Date.now(),
};
const defaultSessionState = {
tabs: [defaultTab],
activeTab: defaultTab,
isReady: true,
createTab: vi.fn(),
closeTab: vi.fn(),
setActiveTab: vi.fn(),
updateTabTitle: vi.fn(),
restartActiveTab: vi.fn(),
};
describe("TerminalModal", () => {
const mockOnClose = vi.fn();
const mockSendInput = vi.fn();
const mockResize = vi.fn();
const mockReconnect = vi.fn();
const createMockTerminalState = (overrides = {}) => ({
connectionStatus: "disconnected" as const,
sendInput: mockSendInput,
resize: mockResize,
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
onConnect: vi.fn(() => vi.fn()),
onScrollback: vi.fn(() => vi.fn()),
reconnect: mockReconnect,
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
mockCreateTerminalSession.mockResolvedValue({
sessionId: "test-session-123",
shell: "/bin/bash",
cwd: "/project",
});
mockKillPtyTerminalSession.mockResolvedValue({ killed: true });
mockUseTerminal.mockReturnValue(createMockTerminalState());
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders without crashing when open", async () => {
render();
await waitFor(() => {
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
});
});
it("does not render when closed", () => {
const { container } = render();
expect(container.firstChild).toBeNull();
});
it("shows loading state while sessions are not ready", async () => {
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
isReady: false,
});
render();
await waitFor(() => {
expect(screen.getByTestId("terminal-loading")).toBeTruthy();
});
});
it("shows tabs when multiple sessions exist", async () => {
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [
defaultTab,
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
],
});
render();
await waitFor(() => {
expect(screen.getByText("bash")).toBeTruthy();
expect(screen.getByText("zsh")).toBeTruthy();
});
});
it("shows active tab styling", async () => {
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [
{ ...defaultTab, isActive: true },
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
],
});
render();
await waitFor(() => {
const activeTab = screen.getByText("bash").closest(".terminal-tab");
expect(activeTab).toHaveClass("terminal-tab--active");
});
});
it("tab click switches active tab", async () => {
const mockSetActiveTab = vi.fn();
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [
{ ...defaultTab, isActive: true },
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
],
setActiveTab: mockSetActiveTab,
});
render();
await waitFor(() => {
const zshTab = screen.getByText("zsh");
fireEvent.click(zshTab);
});
expect(mockSetActiveTab).toHaveBeenCalledWith("tab-2");
});
it("tab close button closes tab", async () => {
const mockCloseTab = vi.fn();
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [
{ ...defaultTab, isActive: true },
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
],
closeTab: mockCloseTab,
});
render();
await waitFor(() => {
// Find the close button for the zsh tab (second tab)
const closeButtons = screen.getAllByTitle("Close tab");
const zshCloseBtn = closeButtons[1]; // Second close button (for zsh tab)
if (zshCloseBtn) {
fireEvent.click(zshCloseBtn);
}
});
expect(mockCloseTab).toHaveBeenCalledWith("tab-2");
});
it("new tab button creates new tab", async () => {
const mockCreateTab = vi.fn().mockResolvedValue({
id: "tab-new",
sessionId: "new-session",
title: "Terminal 2",
isActive: true,
createdAt: Date.now(),
});
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
createTab: mockCreateTab,
});
render();
await waitFor(() => {
const newTabBtn = screen.getByTitle("New terminal");
fireEvent.click(newTabBtn);
});
expect(mockCreateTab).toHaveBeenCalled();
});
it("sessions are NOT killed when modal closes (session persistence)", async () => {
const { rerender } = render();
await waitFor(() => {
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
});
await act(async () => {
rerender();
});
// With multi-tab support, sessions should persist when modal closes
expect(mockKillPtyTerminalSession).not.toHaveBeenCalled();
});
it("closes modal on close button click", async () => {
render();
await waitFor(() => {
const closeBtn = screen.getByTestId("terminal-close-btn");
fireEvent.click(closeBtn);
});
expect(mockOnClose).toHaveBeenCalled();
});
it("closes modal on escape key", async () => {
render();
await act(async () => {
fireEvent.keyDown(document, { key: "Escape" });
});
expect(mockOnClose).toHaveBeenCalled();
});
it("closes modal on overlay click", async () => {
render();
await waitFor(() => {
const overlay = screen.getByTestId("terminal-modal-overlay");
fireEvent.click(overlay);
});
expect(mockOnClose).toHaveBeenCalled();
});
it("shows reconnect button when disconnected", async () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
connectionStatus: "disconnected",
})
);
render();
await waitFor(() => {
expect(screen.getByTestId("terminal-reconnect-btn")).toBeTruthy();
});
});
it("reconnects when reconnect button clicked", async () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
connectionStatus: "disconnected",
})
);
render();
await waitFor(() => {
const reconnectBtn = screen.getByTestId("terminal-reconnect-btn");
fireEvent.click(reconnectBtn);
});
expect(mockReconnect).toHaveBeenCalled();
});
it("WebSocket connects on mount with sessionId from active tab", async () => {
render();
await waitFor(() => {
expect(mockUseTerminal).toHaveBeenCalledWith("test-session-123");
});
});
it("initializes xterm after session is ready", async () => {
render();
// Wait for session to be ready and xterm to initialize
await waitFor(() => {
expect(mockTerminalInstance.open).toHaveBeenCalled();
});
// Verify xterm was opened with the terminal container div
const terminalDiv = screen.getByTestId("terminal-xterm");
expect(mockTerminalInstance.open).toHaveBeenCalledWith(terminalDiv);
});
it("xterm container is hidden while loading", async () => {
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
isReady: false,
});
render();
await waitFor(() => {
const xtermDiv = screen.getByTestId("terminal-xterm");
expect(xtermDiv.style.display).toBe("none");
});
});
it("xterm container becomes visible when ready", async () => {
render();
await waitFor(() => {
const xtermDiv = screen.getByTestId("terminal-xterm");
expect(xtermDiv.style.display).not.toBe("none");
});
});
it("subscribes to terminal data after xterm is ready", async () => {
const mockOnData = vi.fn(() => vi.fn());
const mockOnConnect = vi.fn(() => vi.fn());
const mockOnExit = vi.fn(() => vi.fn());
const mockOnScrollback = vi.fn(() => vi.fn());
mockUseTerminal.mockReturnValue(
createMockTerminalState({
onData: mockOnData,
onConnect: mockOnConnect,
onExit: mockOnExit,
onScrollback: mockOnScrollback,
})
);
render();
// Wait for xterm initialization to complete
await waitFor(() => {
expect(mockTerminalInstance.open).toHaveBeenCalled();
});
// After xterm is ready, data subscriptions should be established
await waitFor(() => {
expect(mockOnData).toHaveBeenCalled();
expect(mockOnConnect).toHaveBeenCalled();
expect(mockOnExit).toHaveBeenCalled();
expect(mockOnScrollback).toHaveBeenCalled();
});
});
it("calls restartActiveTab when New Session button clicked", async () => {
const mockRestartActiveTab = vi.fn();
let exitCallback: ((code: number) => void) | null = null;
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
restartActiveTab: mockRestartActiveTab,
});
// Create a custom mock that captures the exit callback
const customOnExit = vi.fn((cb: (code: number) => void) => {
exitCallback = cb;
return vi.fn();
});
mockUseTerminal.mockReturnValue({
connectionStatus: "connected",
sendInput: mockSendInput,
resize: mockResize,
onData: vi.fn(() => vi.fn()),
onExit: customOnExit,
onConnect: vi.fn(() => vi.fn()),
onScrollback: vi.fn(() => vi.fn()),
reconnect: mockReconnect,
});
render();
await waitFor(() => {
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
});
// Wait for xterm to initialize
await waitFor(() => {
expect(mockTerminalInstance.open).toHaveBeenCalled();
});
// Trigger the exit callback to simulate terminal exit
act(() => {
if (exitCallback) {
exitCallback(0);
}
});
await waitFor(() => {
expect(screen.getByTestId("terminal-restart-btn")).toBeTruthy();
});
const restartBtn = screen.getByTestId("terminal-restart-btn");
fireEvent.click(restartBtn);
expect(mockRestartActiveTab).toHaveBeenCalled();
});
});