/** * TerminalService Tests * * Unit tests for PTY session management: create, write, resize, close, * workspace cleanup, and access control. */ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import type { Socket } from "socket.io"; import { TerminalService, MAX_SESSIONS_PER_WORKSPACE } from "./terminal.service"; // ========================================== // Mocks // ========================================== // Mock node-pty before importing service const mockPtyProcess = { onData: vi.fn(), onExit: vi.fn(), write: vi.fn(), resize: vi.fn(), kill: vi.fn(), pid: 12345, }; vi.mock("node-pty", () => ({ spawn: vi.fn(() => mockPtyProcess), })); function createMockSocket(id = "socket-1"): Socket { return { id, emit: vi.fn(), join: vi.fn(), leave: vi.fn(), disconnect: vi.fn(), data: {}, } as unknown as Socket; } // ========================================== // Tests // ========================================== describe("TerminalService", () => { let service: TerminalService; let mockSocket: Socket; beforeEach(async () => { vi.clearAllMocks(); // Reset mock implementations mockPtyProcess.onData.mockImplementation((_cb: (data: string) => void) => {}); mockPtyProcess.onExit.mockImplementation( (_cb: (e: { exitCode: number; signal?: number }) => void) => {} ); service = new TerminalService(); // Trigger lazy import of node-pty (uses dynamic import(), intercepted by vi.mock) await service.onModuleInit(); mockSocket = createMockSocket(); }); afterEach(() => { vi.clearAllMocks(); }); // ========================================== // createSession // ========================================== describe("createSession", () => { it("should create a PTY session and return sessionId", () => { const result = service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "socket-1", }); expect(result.sessionId).toBeDefined(); expect(typeof result.sessionId).toBe("string"); expect(result.cols).toBe(80); expect(result.rows).toBe(24); }); it("should use provided cols and rows", () => { const result = service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "socket-1", cols: 120, rows: 40, }); expect(result.cols).toBe(120); expect(result.rows).toBe(40); }); it("should return the provided session name", () => { const result = service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "socket-1", name: "my-terminal", }); expect(result.name).toBe("my-terminal"); }); it("should wire PTY onData to emit terminal:output", () => { let dataCallback: ((data: string) => void) | undefined; mockPtyProcess.onData.mockImplementation((cb: (data: string) => void) => { dataCallback = cb; }); const result = service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "socket-1", }); expect(dataCallback).toBeDefined(); dataCallback!("hello world"); expect(mockSocket.emit).toHaveBeenCalledWith("terminal:output", { sessionId: result.sessionId, data: "hello world", }); }); it("should wire PTY onExit to emit terminal:exit and cleanup", () => { let exitCallback: ((e: { exitCode: number; signal?: number }) => void) | undefined; mockPtyProcess.onExit.mockImplementation( (cb: (e: { exitCode: number; signal?: number }) => void) => { exitCallback = cb; } ); const result = service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "socket-1", }); expect(exitCallback).toBeDefined(); exitCallback!({ exitCode: 0 }); expect(mockSocket.emit).toHaveBeenCalledWith("terminal:exit", { sessionId: result.sessionId, exitCode: 0, signal: undefined, }); // Session should be cleaned up expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(false); expect(service.getWorkspaceSessionCount("ws-1")).toBe(0); }); it("should throw when workspace session limit is reached", () => { const limit = MAX_SESSIONS_PER_WORKSPACE; for (let i = 0; i < limit; i++) { service.createSession(createMockSocket(`socket-${String(i)}`), { workspaceId: "ws-limit", socketId: `socket-${String(i)}`, }); } expect(() => service.createSession(createMockSocket("socket-overflow"), { workspaceId: "ws-limit", socketId: "socket-overflow", }) ).toThrow(/maximum/i); }); it("should allow sessions in different workspaces independently", () => { service.createSession(mockSocket, { workspaceId: "ws-a", socketId: "s1" }); service.createSession(createMockSocket("s2"), { workspaceId: "ws-b", socketId: "s2" }); expect(service.getWorkspaceSessionCount("ws-a")).toBe(1); expect(service.getWorkspaceSessionCount("ws-b")).toBe(1); }); }); // ========================================== // writeToSession // ========================================== describe("writeToSession", () => { it("should write data to PTY", () => { const result = service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "socket-1", }); service.writeToSession(result.sessionId, "ls -la\n"); expect(mockPtyProcess.write).toHaveBeenCalledWith("ls -la\n"); }); it("should throw for unknown sessionId", () => { expect(() => service.writeToSession("nonexistent-id", "data")).toThrow(/not found/i); }); }); // ========================================== // resizeSession // ========================================== describe("resizeSession", () => { it("should resize PTY dimensions", () => { const result = service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "socket-1", }); service.resizeSession(result.sessionId, 132, 50); expect(mockPtyProcess.resize).toHaveBeenCalledWith(132, 50); }); it("should throw for unknown sessionId", () => { expect(() => service.resizeSession("nonexistent-id", 80, 24)).toThrow(/not found/i); }); }); // ========================================== // closeSession // ========================================== describe("closeSession", () => { it("should kill PTY and return true for existing session", () => { const result = service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "socket-1", }); const closed = service.closeSession(result.sessionId); expect(closed).toBe(true); expect(mockPtyProcess.kill).toHaveBeenCalled(); expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(false); }); it("should return false for nonexistent sessionId", () => { const closed = service.closeSession("does-not-exist"); expect(closed).toBe(false); }); it("should clean up workspace tracking after close", () => { const result = service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "socket-1", }); expect(service.getWorkspaceSessionCount("ws-1")).toBe(1); service.closeSession(result.sessionId); expect(service.getWorkspaceSessionCount("ws-1")).toBe(0); }); it("should not throw if PTY kill throws", () => { mockPtyProcess.kill.mockImplementationOnce(() => { throw new Error("PTY already dead"); }); const result = service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "socket-1", }); expect(() => service.closeSession(result.sessionId)).not.toThrow(); }); }); // ========================================== // closeWorkspaceSessions // ========================================== describe("closeWorkspaceSessions", () => { it("should kill all sessions for a workspace", () => { service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "s1" }); service.createSession(createMockSocket("s2"), { workspaceId: "ws-1", socketId: "s2" }); expect(service.getWorkspaceSessionCount("ws-1")).toBe(2); service.closeWorkspaceSessions("ws-1"); expect(service.getWorkspaceSessionCount("ws-1")).toBe(0); expect(mockPtyProcess.kill).toHaveBeenCalledTimes(2); }); it("should not affect sessions in other workspaces", () => { service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "s1" }); service.createSession(createMockSocket("s2"), { workspaceId: "ws-2", socketId: "s2" }); service.closeWorkspaceSessions("ws-1"); expect(service.getWorkspaceSessionCount("ws-1")).toBe(0); expect(service.getWorkspaceSessionCount("ws-2")).toBe(1); }); it("should not throw for workspaces with no sessions", () => { expect(() => service.closeWorkspaceSessions("ws-nonexistent")).not.toThrow(); }); }); // ========================================== // sessionBelongsToWorkspace // ========================================== describe("sessionBelongsToWorkspace", () => { it("should return true for a session belonging to the workspace", () => { const result = service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "socket-1", }); expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(true); }); it("should return false for a session in a different workspace", () => { const result = service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "socket-1", }); expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-2")).toBe(false); }); it("should return false for a nonexistent sessionId", () => { expect(service.sessionBelongsToWorkspace("no-such-id", "ws-1")).toBe(false); }); }); // ========================================== // getWorkspaceSessionCount // ========================================== describe("getWorkspaceSessionCount", () => { it("should return 0 for workspace with no sessions", () => { expect(service.getWorkspaceSessionCount("empty-ws")).toBe(0); }); it("should track session count accurately", () => { service.createSession(mockSocket, { workspaceId: "ws-count", socketId: "s1" }); expect(service.getWorkspaceSessionCount("ws-count")).toBe(1); service.createSession(createMockSocket("s2"), { workspaceId: "ws-count", socketId: "s2" }); expect(service.getWorkspaceSessionCount("ws-count")).toBe(2); }); }); });