feat(KB-058): complete Step 7 — add tests for terminal service and hook

This commit is contained in:
gsxdsm
2026-03-29 23:34:23 -07:00
parent bd53f08059
commit d990edd947
3 changed files with 508 additions and 223 deletions

View File

@@ -2,234 +2,106 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { TerminalModal } from "../TerminalModal";
import * as useTerminalModule from "../../hooks/useTerminal";
import * as apiModule from "../../api";
// Mock the useTerminal hook
// Mock hooks and API
vi.mock("../../hooks/useTerminal", () => ({
useTerminal: vi.fn(),
}));
vi.mock("../../api", () => ({
createTerminalSession: vi.fn(),
killTerminalSession: vi.fn(),
}));
const mockUseTerminal = vi.mocked(useTerminalModule.useTerminal);
const mockCreateTerminalSession = vi.mocked(apiModule.createTerminalSession);
const mockKillTerminalSession = vi.mocked(apiModule.killTerminalSession);
describe("TerminalModal", () => {
const mockOnClose = vi.fn();
const mockExecuteCommand = vi.fn();
const mockClearHistory = vi.fn();
const mockKillCurrentCommand = vi.fn();
const mockSetInputValue = vi.fn();
const mockNavigateHistoryUp = vi.fn();
const mockNavigateHistoryDown = vi.fn();
const mockResetHistoryNavigation = vi.fn();
const mockSendInput = vi.fn();
const mockResize = vi.fn();
const mockReconnect = vi.fn();
const createMockTerminalState = (overrides = {}) => ({
history: [],
currentSessionId: null,
isRunning: false,
inputValue: "",
historyIndex: -1,
error: null,
executeCommand: mockExecuteCommand,
clearHistory: mockClearHistory,
killCurrentCommand: mockKillCurrentCommand,
setInputValue: mockSetInputValue,
navigateHistoryUp: mockNavigateHistoryUp,
navigateHistoryDown: mockNavigateHistoryDown,
resetHistoryNavigation: mockResetHistoryNavigation,
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(() => {
mockOnClose.mockClear();
mockExecuteCommand.mockClear();
mockClearHistory.mockClear();
mockKillCurrentCommand.mockClear();
mockSetInputValue.mockClear();
mockNavigateHistoryUp.mockClear();
mockNavigateHistoryDown.mockClear();
mockResetHistoryNavigation.mockClear();
vi.clearAllMocks();
mockCreateTerminalSession.mockResolvedValue({
sessionId: "test-session-123",
shell: "/bin/bash",
cwd: "/project",
});
mockKillTerminalSession.mockResolvedValue({ killed: true });
mockUseTerminal.mockReturnValue(createMockTerminalState());
});
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
it("renders without crashing when open", () => {
it("renders without crashing when open", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
expect(screen.getByTestId("terminal-welcome")).toBeTruthy();
});
it("does not render when closed", () => {
const { container } = render(
<TerminalModal isOpen={false} onClose={mockOnClose} />
);
expect(container.firstChild).toBeNull();
});
it("shows welcome message when empty", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByTestId("terminal-welcome")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Interactive Terminal" })).toBeTruthy();
});
it("executes command on form submit", async () => {
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
// Update mock to provide a value
mockUseTerminal.mockReturnValue(createMockTerminalState({ inputValue: "ls -la" }));
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const form = screen.getByTestId("terminal-input").closest("form");
fireEvent.submit(form!);
expect(mockExecuteCommand).toHaveBeenCalledWith("ls -la");
});
it("executes initial command when provided", async () => {
mockUseTerminal.mockReturnValue(createMockTerminalState({ inputValue: "" }));
render(
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="git status" />
);
await waitFor(() => {
expect(mockExecuteCommand).toHaveBeenCalledWith("git status");
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
});
});
it("clears history when clear button clicked", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.click(screen.getByTestId("terminal-clear-btn"));
expect(mockClearHistory).toHaveBeenCalled();
it("does not render when closed", () => {
const { container } = render(<TerminalModal isOpen={false} onClose={mockOnClose} />);
expect(container.firstChild).toBeNull();
});
it("kills process when kill button clicked while running", () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({ isRunning: true, currentSessionId: "session-123" })
);
it("creates terminal session on open", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.click(screen.getByTestId("terminal-kill-btn"));
expect(mockKillCurrentCommand).toHaveBeenCalled();
await waitFor(() => {
expect(mockCreateTerminalSession).toHaveBeenCalled();
});
});
it("shows running indicator when command is executing", () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
isRunning: true,
currentSessionId: "session-123",
history: [
{
id: "entry-1",
command: "sleep 10",
output: "",
exitCode: null,
timestamp: new Date(),
isRunning: true,
},
],
})
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByTestId("terminal-entry-entry-1")).toBeTruthy();
expect(screen.getByText("Running...")).toBeTruthy();
});
it("displays command history with output", () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
history: [
{
id: "entry-1",
command: "echo hello",
output: "hello\n",
exitCode: 0,
timestamp: new Date(),
isRunning: false,
},
{
id: "entry-2",
command: "ls",
output: "file1.txt\nfile2.txt\n",
exitCode: 0,
timestamp: new Date(),
isRunning: false,
},
],
})
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByTestId("terminal-entry-entry-1")).toBeTruthy();
expect(screen.getByTestId("terminal-entry-entry-2")).toBeTruthy();
expect(screen.getByText("echo hello")).toBeTruthy();
expect(screen.getByText("ls")).toBeTruthy();
});
it("disables input while command is running", () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({ isRunning: true })
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const input = screen.getByTestId("terminal-input");
expect(input).toBeDisabled();
expect(input).toHaveAttribute("placeholder", "Command running...");
});
it("handles Ctrl+C to kill running process", () => {
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
it("shows loading state while creating session", async () => {
mockCreateTerminalSession.mockImplementation(() => new Promise(() => {}));
mockUseTerminal.mockReturnValue(
createMockTerminalState({ isRunning: true })
);
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const input = screen.getByTestId("terminal-input");
fireEvent.keyDown(input, { key: "c", ctrlKey: true });
expect(mockKillCurrentCommand).toHaveBeenCalled();
});
it("handles Ctrl+L to clear history", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const input = screen.getByTestId("terminal-input");
fireEvent.keyDown(input, { key: "l", ctrlKey: true });
expect(mockClearHistory).toHaveBeenCalled();
expect(screen.getByTestId("terminal-loading")).toBeTruthy();
});
it("handles Up arrow to navigate history", () => {
it("shows error when session creation fails", async () => {
mockCreateTerminalSession.mockRejectedValue(new Error("Failed to create session"));
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const input = screen.getByTestId("terminal-input");
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(mockNavigateHistoryUp).toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId("terminal-error")).toBeTruthy();
});
});
it("handles Down arrow to navigate history", () => {
it("closes modal on close button click", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const input = screen.getByTestId("terminal-input");
fireEvent.keyDown(input, { key: "ArrowDown" });
await waitFor(() => {
const closeBtn = screen.getByTestId("terminal-close-btn");
fireEvent.click(closeBtn);
});
expect(mockNavigateHistoryDown).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
it("modal closes on Escape key press", () => {
it("closes modal on escape key", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.keyDown(document, { key: "Escape" });
@@ -237,69 +109,67 @@ describe("TerminalModal", () => {
expect(mockOnClose).toHaveBeenCalled();
});
it("modal closes on overlay click", () => {
it("closes modal on overlay click", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const overlay = screen.getByTestId("terminal-modal-overlay");
fireEvent.click(overlay);
await waitFor(() => {
const overlay = screen.getByTestId("terminal-modal-overlay");
fireEvent.click(overlay);
});
expect(mockOnClose).toHaveBeenCalled();
});
it("modal does not close when clicking inside modal content", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
it("kills session on modal close", async () => {
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const modal = screen.getByTestId("terminal-modal");
fireEvent.click(modal);
await waitFor(() => {
expect(mockCreateTerminalSession).toHaveBeenCalled();
});
expect(mockOnClose).not.toHaveBeenCalled();
rerender(<TerminalModal isOpen={false} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockKillTerminalSession).toHaveBeenCalledWith("test-session-123");
});
});
it("modal closes on close button click", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.click(screen.getByTestId("terminal-close-btn"));
expect(mockOnClose).toHaveBeenCalled();
});
it("updates input value on change", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const input = screen.getByTestId("terminal-input");
fireEvent.change(input, { target: { value: "git status" } });
expect(mockSetInputValue).toHaveBeenCalledWith("git status");
});
it("marks error output with error class", () => {
it("shows reconnect button when disconnected", async () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
history: [
{
id: "entry-1",
command: "exit 1",
output: "Error occurred",
exitCode: 1,
timestamp: new Date(),
isRunning: false,
},
],
createMockTerminalState({
connectionStatus: "disconnected",
})
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const output = screen.getByText("Error occurred");
expect(output.className).toContain("terminal-output-error");
await waitFor(() => {
expect(screen.getByTestId("terminal-reconnect-btn")).toBeTruthy();
});
});
it("focuses input when modal opens", async () => {
it("reconnects when reconnect button clicked", async () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
connectionStatus: "disconnected",
})
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const input = screen.getByTestId("terminal-input");
expect(document.activeElement).toBe(input);
const reconnectBtn = screen.getByTestId("terminal-reconnect-btn");
fireEvent.click(reconnectBtn);
});
expect(mockReconnect).toHaveBeenCalled();
});
it("WebSocket connects on mount with sessionId", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockUseTerminal).toHaveBeenCalledWith("test-session-123");
});
});
});

View File

@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useTerminal } from "./useTerminal";
// Mock WebSocket
global.WebSocket = vi.fn() as unknown as typeof WebSocket;
describe("useTerminal", () => {
let mockWebSocket: {
send: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
readyState: number;
onopen: (() => void) | null;
onmessage: ((event: { data: string }) => void) | null;
onclose: ((event?: { code: number }) => void) | null;
onerror: (() => void) | null;
};
beforeEach(() => {
mockWebSocket = {
send: vi.fn(),
close: vi.fn(),
readyState: WebSocket.CONNECTING,
onopen: null,
onmessage: null,
onclose: null,
onerror: null,
};
(global.WebSocket as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => mockWebSocket);
});
afterEach(() => {
vi.clearAllMocks();
});
it("returns disconnected status when sessionId is null", () => {
const { result } = renderHook(() => useTerminal(null));
expect(result.current.connectionStatus).toBe("disconnected");
});
it("establishes WebSocket connection on valid sessionId", () => {
renderHook(() => useTerminal("test-session-123"));
expect(global.WebSocket).toHaveBeenCalledWith(
expect.stringContaining("/api/terminal/ws?sessionId=test-session-123")
);
});
it("shows connecting status while establishing connection", () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
expect(result.current.connectionStatus).toBe("connecting");
});
it("shows connected status when WebSocket opens", async () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
mockWebSocket.readyState = WebSocket.OPEN;
mockWebSocket.onopen?.();
await waitFor(() => {
expect(result.current.connectionStatus).toBe("connected");
});
});
it("sends input data when connected", async () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
mockWebSocket.readyState = WebSocket.OPEN;
mockWebSocket.onopen?.();
await waitFor(() => {
result.current.sendInput("ls -la");
});
expect(mockWebSocket.send).toHaveBeenCalledWith(
JSON.stringify({ type: "input", data: "ls -la" })
);
});
it("calls onData callback when data received", async () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
const onDataMock = vi.fn();
const unsub = result.current.onData(onDataMock);
mockWebSocket.onmessage?.({
data: JSON.stringify({ type: "data", data: "hello world" }),
});
expect(onDataMock).toHaveBeenCalledWith("hello world");
unsub();
});
it("calls onConnect callback when connected", async () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
const onConnectMock = vi.fn();
const unsub = result.current.onConnect(onConnectMock);
mockWebSocket.onmessage?.({
data: JSON.stringify({ type: "connected", shell: "/bin/bash", cwd: "/project" }),
});
expect(onConnectMock).toHaveBeenCalledWith({ shell: "/bin/bash", cwd: "/project" });
unsub();
});
it("calls onExit callback when session exits", async () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
const onExitMock = vi.fn();
const unsub = result.current.onExit(onExitMock);
mockWebSocket.onmessage?.({
data: JSON.stringify({ type: "exit", exitCode: 0 }),
});
expect(onExitMock).toHaveBeenCalledWith(0);
unsub();
});
it("calls onScrollback callback when scrollback received", async () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
const onScrollbackMock = vi.fn();
const unsub = result.current.onScrollback(onScrollbackMock);
mockWebSocket.onmessage?.({
data: JSON.stringify({ type: "scrollback", data: "previous output" }),
});
expect(onScrollbackMock).toHaveBeenCalledWith("previous output");
unsub();
});
it("does not reconnect on 4004 session not found", async () => {
const { result } = renderHook(() => useTerminal("test-session-123"));
mockWebSocket.onclose?.({ code: 4004 });
await waitFor(() => {
expect(result.current.connectionStatus).toBe("disconnected");
});
});
});

View File

@@ -0,0 +1,269 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { TerminalService } from "./terminal-service";
// Mock node-pty
const mockPtyProcess = {
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
onData: vi.fn((cb: (data: string) => void) => {
mockPtyProcess._onDataCallback = cb;
return { dispose: vi.fn() };
}),
onExit: vi.fn((cb: (e: { exitCode: number }) => void) => {
mockPtyProcess._onExitCallback = cb;
return { dispose: vi.fn() };
}),
_onDataCallback: null as ((data: string) => void) | null,
_onExitCallback: null as ((e: { exitCode: number }) => void) | null,
};
vi.mock("node-pty", () => ({
spawn: vi.fn(() => mockPtyProcess),
}));
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
existsSync: vi.fn(() => true),
};
});
describe("TerminalService", () => {
let service: TerminalService;
const projectRoot = "/test/project";
beforeEach(() => {
vi.clearAllMocks();
service = new TerminalService(projectRoot, 10);
mockPtyProcess._onDataCallback = null;
mockPtyProcess._onExitCallback = null;
});
afterEach(() => {
service.cleanup();
});
describe("createSession", () => {
it("creates session with detected shell", async () => {
const session = await service.createSession();
expect(session).toBeTruthy();
expect(session?.id).toMatch(/^term-\d+-/);
expect(session?.cwd).toBe(projectRoot);
});
it("returns null when session limit reached", async () => {
const limitedService = new TerminalService(projectRoot, 1);
const session1 = await limitedService.createSession();
expect(session1).toBeTruthy();
const session2 = await limitedService.createSession();
expect(session2).toBeNull();
limitedService.cleanup();
});
it("rejects shells not in allowlist", async () => {
const session = await service.createSession({ shell: "/tmp/evil-shell" });
expect(session).toBeNull();
});
});
describe("write", () => {
it("sends data to PTY", async () => {
const session = await service.createSession();
expect(session).toBeTruthy();
const result = service.write(session!.id, "ls -la\n");
expect(result).toBe(true);
expect(mockPtyProcess.write).toHaveBeenCalledWith("ls -la\n");
});
it("returns false for invalid session", () => {
const result = service.write("invalid-session", "test");
expect(result).toBe(false);
});
it("rejects data with null bytes", async () => {
const session = await service.createSession();
expect(session).toBeTruthy();
const result = service.write(session!.id, "test\0malicious");
expect(result).toBe(false);
});
});
describe("resize", () => {
it("updates PTY dimensions", async () => {
const session = await service.createSession();
expect(session).toBeTruthy();
const result = service.resize(session!.id, 120, 40);
expect(result).toBe(true);
expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40);
});
it("returns false for invalid session", () => {
const result = service.resize("invalid-session", 80, 24);
expect(result).toBe(false);
});
});
describe("killSession", () => {
it("terminates session", async () => {
const session = await service.createSession();
expect(session).toBeTruthy();
const result = service.killSession(session!.id);
expect(result).toBe(true);
expect(mockPtyProcess.kill).toHaveBeenCalledWith("SIGTERM");
});
it("returns false for non-existent session", () => {
const result = service.killSession("non-existent");
expect(result).toBe(false);
});
});
describe("session management", () => {
it("enforces session limit", async () => {
const limitedService = new TerminalService(projectRoot, 2);
const session1 = await limitedService.createSession();
const session2 = await limitedService.createSession();
const session3 = await limitedService.createSession();
expect(session1).toBeTruthy();
expect(session2).toBeTruthy();
expect(session3).toBeNull();
limitedService.cleanup();
});
it("lists active sessions", async () => {
const session1 = await service.createSession();
const session2 = await service.createSession();
const sessions = service.getAllSessions();
expect(sessions).toHaveLength(2);
expect(sessions.some((s) => s.id === session1?.id)).toBe(true);
expect(sessions.some((s) => s.id === session2?.id)).toBe(true);
});
it("cleans up all sessions", async () => {
await service.createSession();
await service.createSession();
expect(service.getSessionCount()).toBe(2);
service.cleanup();
expect(service.getSessionCount()).toBe(0);
});
});
describe("scrollback buffer", () => {
it("maintains scrollback buffer", async () => {
const session = await service.createSession();
expect(session).toBeTruthy();
mockPtyProcess._onDataCallback?.("output line 1\n");
mockPtyProcess._onDataCallback?.("output line 2\n");
const scrollback = service.getScrollback(session!.id);
expect(scrollback).toContain("output line 1");
expect(scrollback).toContain("output line 2");
});
it("returns null for invalid session", () => {
const scrollback = service.getScrollback("invalid-session");
expect(scrollback).toBeNull();
});
});
describe("event handling", () => {
it("emits data events", async () => {
const dataMock = vi.fn();
service.onData(dataMock);
const session = await service.createSession();
expect(session).toBeTruthy();
mockPtyProcess._onDataCallback?.("test data");
expect(dataMock).toHaveBeenCalledWith(session!.id, "test data");
});
it("emits exit events", async () => {
const exitMock = vi.fn();
service.onExit(exitMock);
const session = await service.createSession();
expect(session).toBeTruthy();
mockPtyProcess._onExitCallback?.({ exitCode: 0 });
expect(exitMock).toHaveBeenCalledWith(session!.id, 0);
});
it("allows unsubscribing from events", async () => {
const dataMock = vi.fn();
const unsub = service.onData(dataMock);
unsub();
const session = await service.createSession();
expect(session).toBeTruthy();
mockPtyProcess._onDataCallback?.("test");
expect(dataMock).not.toHaveBeenCalled();
});
});
describe("maxSessions configuration", () => {
it("returns default max sessions", () => {
expect(service.getMaxSessions()).toBe(10);
});
it("allows updating max sessions", () => {
service.setMaxSessions(5);
expect(service.getMaxSessions()).toBe(5);
});
it("enforces minimum session limit", () => {
service.setMaxSessions(0);
expect(service.getMaxSessions()).toBe(1);
});
it("enforces maximum session limit", () => {
service.setMaxSessions(200);
expect(service.getMaxSessions()).toBe(100);
});
});
describe("session validation", () => {
it("returns undefined for invalid session IDs", () => {
const session = service.getSession("invalid<id>");
expect(session).toBeUndefined();
});
it("returns null scrollback for invalid session IDs", () => {
const scrollback = service.getScrollback("invalid<id>");
expect(scrollback).toBeNull();
});
it("returns false for write with invalid session ID", () => {
const result = service.write("invalid<id>", "data");
expect(result).toBe(false);
});
});
});