Files
openchamber/packages/web/server/lib/opencode/lifecycle.test.js
T

236 lines
7.8 KiB
JavaScript
Raw Normal View History

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;
afterEach(() => {
spawnMock.mockReset();
if (typeof originalOpencodeBinary === 'string') {
process.env.OPENCODE_BINARY = originalOpencodeBinary;
return;
}
delete process.env.OPENCODE_BINARY;
});
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'),
2026-04-28 12:35:00 +03:00
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');
2026-04-28 12:35:00 +03:00
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;
const originalPath = process.env.PATH;
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');
process.env.PATH = originalPath;
await server.close();
});
2026-04-28 12:35:00 +03:00
it('reports the binary when managed OpenCode exits before becoming ready', async () => {
2026-04-28 12:35:00 +03:00
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');
2026-04-28 12:35:00 +03:00
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();
});
2026-04-28 12:35:00 +03:00
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();
});
});