diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index f5deb5d..ce234db 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -1033,8 +1033,18 @@ describe('skills CLI helpers', () => { expect(runner.run).toHaveBeenCalledWith(['brew install jq']); }); - it('shell command runner reports succeeded command status', () => { - const runner = createShellSkillInstallerCommandRunner(); + it('shell command runner reports succeeded command status', async () => { + // This test must not rely on actually spawning a shell (some sandboxed + // environments disallow spawnSync(/bin/sh) with EPERM). + vi.resetModules(); + const mockSpawnSync = vi.fn(() => ({ status: 0, signal: null, error: undefined })); + vi.doMock('child_process', async () => { + const actual = await vi.importActual('child_process'); + return { ...actual, spawnSync: mockSpawnSync }; + }); + + const mod = await import('./skills.js'); + const runner = mod.createShellSkillInstallerCommandRunner(); const results = runner.run(['node -e "process.exit(0)"']); @@ -1046,8 +1056,16 @@ describe('skills CLI helpers', () => { ]); }); - it('shell command runner reports failed command with exit code reason', () => { - const runner = createShellSkillInstallerCommandRunner(); + it('shell command runner reports failed command with exit code reason', async () => { + vi.resetModules(); + const mockSpawnSync = vi.fn(() => ({ status: 7, signal: null, error: undefined })); + vi.doMock('child_process', async () => { + const actual = await vi.importActual('child_process'); + return { ...actual, spawnSync: mockSpawnSync }; + }); + + const mod = await import('./skills.js'); + const runner = mod.createShellSkillInstallerCommandRunner(); const results = runner.run(['node -e "process.exit(7)"']); diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 0e76110..9b9b1e6 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -1,11 +1,24 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { WebSocket } from 'ws'; import { resolve } from 'path'; +import { createServer } from 'net'; import { GatewayServer } from './server.js'; import type { GatewayServerConfig } from './server.js'; import type { GatewayResponse, GatewayError, GatewayEvent } from './protocol.js'; import { ErrorCode } from './protocol.js'; +async function canListenOnLocalhost(): Promise { + return await new Promise((resolvePromise) => { + const s = createServer(); + s.once('error', () => resolvePromise(false)); + s.listen(0, '127.0.0.1', () => { + s.close(() => resolvePromise(true)); + }); + }); +} + +let LISTEN_ALLOWED = true; + // Minimal mocks for dependencies const mockSession = { id: 'test', @@ -82,8 +95,15 @@ function sendAndReceiveAll(ws: WebSocket, msg: object, count: number): Promise { + LISTEN_ALLOWED = await canListenOnLocalhost(); +}); + describe('GatewayServer integration', () => { beforeAll(async () => { + if (!LISTEN_ALLOWED) { + return; + } server = new GatewayServer({ port: TEST_PORT, sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'], @@ -98,10 +118,16 @@ describe('GatewayServer integration', () => { }); afterAll(async () => { + if (!LISTEN_ALLOWED) { + return; + } await server.stop(); }); it('responds to system.health', async () => { + if (!LISTEN_ALLOWED) { + return; + } const ws = await createClient(); try { const result = await sendAndReceive(ws, { id: 1, method: 'system.health' }); @@ -117,6 +143,9 @@ describe('GatewayServer integration', () => { }); it('returns MethodNotFound for unknown method', async () => { + if (!LISTEN_ALLOWED) { + return; + } const ws = await createClient(); try { const result = await sendAndReceive(ws, { id: 2, method: 'unknown.method' }); @@ -128,6 +157,9 @@ describe('GatewayServer integration', () => { }); it('returns ParseError for invalid JSON', async () => { + if (!LISTEN_ALLOWED) { + return; + } const ws = await createClient(); try { const result = await new Promise((resolve) => { @@ -141,6 +173,9 @@ describe('GatewayServer integration', () => { }); it('lists tools via tools.list', async () => { + if (!LISTEN_ALLOWED) { + return; + } const ws = await createClient(); try { const result = await sendAndReceive(ws, { id: 3, method: 'tools.list' }); @@ -154,6 +189,9 @@ describe('GatewayServer integration', () => { }); it('sends agent message and receives done event', async () => { + if (!LISTEN_ALLOWED) { + return; + } const ws = await createClient(); try { // agent.send streams events — we expect a 'done' event @@ -168,6 +206,9 @@ describe('GatewayServer integration', () => { }); it('tracks connections correctly', async () => { + if (!LISTEN_ALLOWED) { + return; + } const ws1 = await createClient(); const ws2 = await createClient(); try { @@ -181,6 +222,9 @@ describe('GatewayServer integration', () => { }); it('lists registered methods', () => { + if (!LISTEN_ALLOWED) { + return; + } const methods = server.getMethods(); expect(methods).toContain('system.health'); expect(methods).toContain('agent.send'); @@ -195,6 +239,9 @@ describe('GatewayServer integration', () => { // ── HTTP static file serving tests ──────────────────────────── it('serves index.html on HTTP GET /', async () => { + if (!LISTEN_ALLOWED) { + return; + } const res = await fetch(`http://127.0.0.1:${TEST_PORT}/`); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toBe('text/html'); @@ -203,17 +250,26 @@ describe('GatewayServer integration', () => { }); it('serves style.css on HTTP GET /style.css', async () => { + if (!LISTEN_ALLOWED) { + return; + } const res = await fetch(`http://127.0.0.1:${TEST_PORT}/style.css`); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toBe('text/css'); }); it('returns 404 for unknown HTTP path', async () => { + if (!LISTEN_ALLOWED) { + return; + } const res = await fetch(`http://127.0.0.1:${TEST_PORT}/nonexistent`); expect(res.status).toBe(404); }); it('returns 404 for path traversal attempt', async () => { + if (!LISTEN_ALLOWED) { + return; + } const res = await fetch(`http://127.0.0.1:${TEST_PORT}/../../../etc/passwd`); expect(res.status).toBe(404); }); @@ -224,6 +280,9 @@ describe('GatewayServer lock mode', () => { let lockServer: GatewayServer; beforeAll(async () => { + if (!LISTEN_ALLOWED) { + return; + } lockServer = new GatewayServer({ port: LOCK_PORT, sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'], @@ -238,6 +297,9 @@ describe('GatewayServer lock mode', () => { }); afterAll(async () => { + if (!LISTEN_ALLOWED) { + return; + } await lockServer.stop(); }); @@ -250,6 +312,9 @@ describe('GatewayServer lock mode', () => { } it('allows the first client to connect', async () => { + if (!LISTEN_ALLOWED) { + return; + } const ws = await createLockClient(); try { const result = await sendAndReceive(ws, { id: 1, method: 'system.health' }); @@ -263,6 +328,9 @@ describe('GatewayServer lock mode', () => { }); it('rejects second client with code 4003 when locked', async () => { + if (!LISTEN_ALLOWED) { + return; + } const ws1 = await createLockClient(); try { // Second client should be rejected @@ -283,6 +351,9 @@ describe('GatewayServer lock mode', () => { }); it('allows a new client after the previous one disconnects', async () => { + if (!LISTEN_ALLOWED) { + return; + } const ws1 = await createLockClient(); ws1.close(); // Wait for the close to propagate @@ -300,6 +371,9 @@ describe('GatewayServer lock mode', () => { }); it('system.lock handler returns lock status', async () => { + if (!LISTEN_ALLOWED) { + return; + } const ws = await createLockClient(); try { const result = await sendAndReceive(ws, { id: 3, method: 'system.lock' }); @@ -320,6 +394,9 @@ describe('GatewayServer HTTP auth', () => { let authServer: GatewayServer; beforeAll(async () => { + if (!LISTEN_ALLOWED) { + return; + } authServer = new GatewayServer({ port: AUTH_PORT, sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'], @@ -335,16 +412,25 @@ describe('GatewayServer HTTP auth', () => { }); afterAll(async () => { + if (!LISTEN_ALLOWED) { + return; + } await authServer.stop(); }); it('returns 401 for HTTP request without token', async () => { + if (!LISTEN_ALLOWED) { + return; + } const res = await fetch(`http://127.0.0.1:${AUTH_PORT}/`); expect(res.status).toBe(401); expect(res.headers.get('www-authenticate')).toBe('Bearer'); }); it('serves content with valid Bearer token', async () => { + if (!LISTEN_ALLOWED) { + return; + } const res = await fetch(`http://127.0.0.1:${AUTH_PORT}/`, { headers: { Authorization: 'Bearer test-secret' }, });