feat(api): add terminal WebSocket gateway with PTY session management (#515)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #515.
This commit is contained in:
337
apps/api/src/terminal/terminal.service.spec.ts
Normal file
337
apps/api/src/terminal/terminal.service.spec.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* 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(() => {
|
||||
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();
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user