import { EventEmitter } from 'node:events'; import { afterEach, describe, expect, it, vi } from 'vitest'; const spawnMock = vi.fn(); vi.mock('node:child_process', () => ({ spawn: spawnMock, spawnSync: vi.fn(), })); const { createOpenCodeLifecycleRuntime } = await import('./lifecycle.js'); const originalOpencodeBinary = process.env.OPENCODE_BINARY; const originalPath = process.env.PATH; afterEach(() => { spawnMock.mockReset(); if (typeof originalOpencodeBinary === 'string') { process.env.OPENCODE_BINARY = originalOpencodeBinary; } else { delete process.env.OPENCODE_BINARY; } if (typeof originalPath === 'string') { process.env.PATH = originalPath; } else { delete process.env.PATH; } }); const createMockChild = () => { const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.exitCode = null; child.signalCode = null; child.pid = 12345; child.kill = vi.fn(() => { child.signalCode = 'SIGTERM'; queueMicrotask(() => child.emit('close', null, 'SIGTERM')); return true; }); return child; }; const createRuntime = (overrides = {}) => { const state = { openCodeWorkingDirectory: '/tmp/project', openCodeProcess: null, openCodePort: null, openCodeBaseUrl: null, currentRestartPromise: null, isRestartingOpenCode: false, openCodeApiPrefix: '', openCodeApiPrefixDetected: false, openCodeApiDetectionTimer: null, lastOpenCodeError: null, isOpenCodeReady: false, openCodeNotReadySince: 0, isExternalOpenCode: false, isShuttingDown: false, healthCheckInterval: null, expressApp: null, useWslForOpencode: false, resolvedWslBinary: null, resolvedWslOpencodePath: null, resolvedWslDistro: null, }; return createOpenCodeLifecycleRuntime({ state, env: { ENV_CONFIGURED_OPENCODE_PORT: 45678, ENV_CONFIGURED_OPENCODE_HOST: null, ENV_EFFECTIVE_PORT: 3001, ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1', ENV_SKIP_OPENCODE_START: false, }, syncToHmrState: vi.fn(), syncFromHmrState: vi.fn(), getOpenCodeAuthHeaders: () => ({}), buildOpenCodeUrl: (route) => `http://127.0.0.1:45678${route}`, waitForReady: vi.fn(async () => true), normalizeApiPrefix: vi.fn(() => ''), applyOpencodeBinaryFromSettings: vi.fn(async () => null), ensureOpencodeCliEnv: vi.fn(), ensureLocalOpenCodeServerPassword: vi.fn(async () => 'password'), buildWslExecArgs: vi.fn((args) => args), resolveWslExecutablePath: vi.fn(), resolveManagedOpenCodeLaunchSpec: vi.fn((binary) => ({ binary, args: [], wrapperType: null })), setOpenCodePort: vi.fn((port) => { state.openCodePort = port; }), setDetectedOpenCodeApiPrefix: vi.fn(), setupProxy: vi.fn(), ensureOpenCodeApiPrefix: vi.fn(), clearResolvedOpenCodeBinary: vi.fn(), buildAugmentedPath: vi.fn(() => '/home/user/.bun/bin:/usr/local/bin:/usr/bin'), buildManagedOpenCodePath: vi.fn(() => '/home/user/.bun/bin:/usr/local/bin:/usr/bin'), getManagedOpenCodeShellEnvSnapshot: vi.fn(() => ({ PATH: '/home/user/.bun/bin:/usr/local/bin:/usr/bin', SHELL_ONLY: 'yes', OPENCODE_SERVER_PASSWORD: 'shell-password', })), ...overrides, }); }; describe('OpenCode lifecycle', () => { it('launches managed OpenCode with the managed PATH', async () => { delete process.env.OPENCODE_BINARY; const child = createMockChild(); spawnMock.mockImplementationOnce(() => { queueMicrotask(() => { child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n'); }); return child; }); const runtime = createRuntime(); const server = await runtime.startOpenCode(); const [binary, args, options] = spawnMock.mock.calls[0]; expect(binary).toBe('opencode'); expect(args).toEqual(['serve', '--hostname', '127.0.0.1', '--port', '45678']); expect(options.env.PATH).toBe('/home/user/.bun/bin:/usr/local/bin:/usr/bin'); expect(options.env.SHELL_ONLY).toBe('yes'); expect(options.env.OPENCODE_SERVER_PASSWORD).toBe('password'); await server.close(); }); it('falls back to buildAugmentedPath when buildManagedOpenCodePath is not provided', async () => { delete process.env.OPENCODE_BINARY; const child = createMockChild(); spawnMock.mockImplementationOnce(() => { queueMicrotask(() => { child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n'); }); return child; }); const runtime = createRuntime({ buildManagedOpenCodePath: undefined, buildAugmentedPath: vi.fn(() => '/home/user/.cargo/bin:/usr/local/bin'), }); const server = await runtime.startOpenCode(); const [, , options] = spawnMock.mock.calls[0]; expect(options.env.PATH).toBe('/home/user/.cargo/bin:/usr/local/bin'); await server.close(); }); it('falls back to process.env.PATH when neither build function is provided', async () => { delete process.env.OPENCODE_BINARY; process.env.PATH = '/usr/bin:/bin'; const child = createMockChild(); spawnMock.mockImplementationOnce(() => { queueMicrotask(() => { child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n'); }); return child; }); const runtime = createRuntime({ buildManagedOpenCodePath: undefined, buildAugmentedPath: undefined, }); const server = await runtime.startOpenCode(); const [, , options] = spawnMock.mock.calls[0]; expect(options.env.PATH).toBe('/usr/bin:/bin'); await server.close(); }); it('reports the binary when managed OpenCode exits before becoming ready', async () => { delete process.env.OPENCODE_BINARY; const firstChild = createMockChild(); const secondChild = createMockChild(); spawnMock.mockImplementationOnce(() => { queueMicrotask(() => { firstChild.emit('exit', null, 'SIGTERM'); }); return firstChild; }); spawnMock.mockImplementationOnce(() => { queueMicrotask(() => { secondChild.emit('exit', null, 'SIGTERM'); }); return secondChild; }); const runtime = createRuntime(); await expect(runtime.startOpenCode()).rejects.toThrow('OpenCode process exited before serving with signal SIGTERM. Binary used: opencode. No stdout/stderr captured'); expect(spawnMock).toHaveBeenCalledTimes(2); }); it('does not retry managed startup when the configured OpenCode binary is invalid', async () => { delete process.env.OPENCODE_BINARY; const error = new Error('Configured OpenCode binary not found: /missing/opencode'); error.code = 'OPENCODE_BINARY_INVALID'; const applyOpencodeBinaryFromSettings = vi.fn(async () => { throw error; }); const runtime = createRuntime({ applyOpencodeBinaryFromSettings }); await expect(runtime.startOpenCode()).rejects.toThrow('Configured OpenCode binary not found: /missing/opencode'); expect(applyOpencodeBinaryFromSettings).toHaveBeenCalledTimes(1); expect(applyOpencodeBinaryFromSettings).toHaveBeenCalledWith({ strict: true }); expect(spawnMock).not.toHaveBeenCalled(); }); it('retries managed OpenCode startup once after a pre-ready exit', async () => { delete process.env.OPENCODE_BINARY; const firstChild = createMockChild(); const secondChild = createMockChild(); spawnMock.mockImplementationOnce(() => { queueMicrotask(() => { firstChild.emit('exit', null, 'SIGTERM'); }); return firstChild; }); spawnMock.mockImplementationOnce(() => { queueMicrotask(() => { secondChild.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n'); }); return secondChild; }); const runtime = createRuntime(); const server = await runtime.startOpenCode(); expect(spawnMock).toHaveBeenCalledTimes(2); await server.close(); }); });