feat(KB-058): complete Step 7 — add tests for terminal service and hook
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
146
packages/dashboard/app/hooks/useTerminal.test.ts
Normal file
146
packages/dashboard/app/hooks/useTerminal.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
269
packages/dashboard/src/terminal-service.test.ts
Normal file
269
packages/dashboard/src/terminal-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user