All checks were successful
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|