918 lines
30 KiB
JavaScript
918 lines
30 KiB
JavaScript
import { EventEmitter } from 'node:events';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const spawnMock = vi.fn();
|
|
const spawnSyncMock = vi.fn();
|
|
const recordStartupPerformanceMock = vi.fn();
|
|
|
|
vi.mock('node:child_process', () => ({
|
|
spawn: spawnMock,
|
|
spawnSync: spawnSyncMock,
|
|
// `managed-process-registry.js` (imported transitively via lifecycle.js)
|
|
// calls `promisify(execFile)` at module load, so the mock must expose a
|
|
// function here. Lifecycle tests don't exercise the reaper path, so a plain
|
|
// stub is enough; the registry's best-effort writes are no-ops on errors.
|
|
execFile: vi.fn(),
|
|
}));
|
|
vi.mock('./startup-performance.js', () => ({
|
|
recordStartupPerformance: recordStartupPerformanceMock,
|
|
}));
|
|
|
|
const { createOpenCodeLifecycleRuntime } = await import('./lifecycle.js');
|
|
|
|
const originalOpencodeBinary = process.env.OPENCODE_BINARY;
|
|
const originalPath = process.env.PATH;
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
afterEach(() => {
|
|
spawnMock.mockReset();
|
|
spawnSyncMock.mockReset();
|
|
recordStartupPerformanceMock.mockReset();
|
|
globalThis.fetch = originalFetch;
|
|
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 = {}, stateOverrides = {}, envOverrides = {}) => {
|
|
const state = {
|
|
openCodeWorkingDirectory: '/tmp/project',
|
|
openCodeProcess: null,
|
|
openCodePort: null,
|
|
openCodeBaseUrl: null,
|
|
currentRestartPromise: null,
|
|
isRestartingOpenCode: false,
|
|
openCodeApiPrefix: '',
|
|
openCodeApiPrefixDetected: false,
|
|
openCodeApiDetectionTimer: null,
|
|
lastOpenCodeError: null,
|
|
lastOpenCodeHealthFailure: null,
|
|
lastManagedOpenCodeProcess: null,
|
|
lastOpenCodeRestartDiagnostics: null,
|
|
isOpenCodeReady: false,
|
|
openCodeNotReadySince: 0,
|
|
isExternalOpenCode: false,
|
|
isShuttingDown: false,
|
|
healthCheckInterval: null,
|
|
expressApp: null,
|
|
useWslForOpencode: false,
|
|
resolvedWslBinary: null,
|
|
resolvedWslOpencodePath: null,
|
|
resolvedWslDistro: null,
|
|
...stateOverrides,
|
|
};
|
|
|
|
const runtime = 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,
|
|
...envOverrides,
|
|
},
|
|
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'),
|
|
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,
|
|
});
|
|
runtime.testState = state;
|
|
return runtime;
|
|
};
|
|
|
|
describe('OpenCode lifecycle', () => {
|
|
it('records an authoritative ready terminal event for external startup', async () => {
|
|
globalThis.fetch = vi.fn(async () => ({
|
|
ok: true,
|
|
json: async () => ({ healthy: true }),
|
|
}));
|
|
const runtime = createRuntime({
|
|
env: {
|
|
ENV_CONFIGURED_OPENCODE_PORT: 45678,
|
|
ENV_CONFIGURED_OPENCODE_HOST: null,
|
|
ENV_EFFECTIVE_PORT: 45678,
|
|
ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1',
|
|
ENV_SKIP_OPENCODE_START: true,
|
|
},
|
|
reapManagedOrphanedProcesses: vi.fn(async () => ({ reaped: 0 })),
|
|
});
|
|
|
|
await runtime.bootstrapOpenCodeAtStartup();
|
|
|
|
expect(recordStartupPerformanceMock).toHaveBeenCalledWith('opencode.bootstrap.ready', {
|
|
totalDurationMs: expect.any(Number),
|
|
outcome: 'ready',
|
|
});
|
|
expect(recordStartupPerformanceMock).not.toHaveBeenCalledWith(
|
|
'opencode.bootstrap.error',
|
|
expect.anything(),
|
|
);
|
|
const terminalEvents = recordStartupPerformanceMock.mock.calls.filter(([phase]) => (
|
|
phase === 'opencode.bootstrap.ready' || phase === 'opencode.bootstrap.error'
|
|
));
|
|
expect(terminalEvents).toHaveLength(1);
|
|
});
|
|
|
|
it('recovers an external OPENCODE_HOST connection using its configured endpoint', async () => {
|
|
const fetchMock = vi.fn(async () => ({
|
|
ok: true,
|
|
json: async () => ({ healthy: true }),
|
|
}));
|
|
globalThis.fetch = fetchMock;
|
|
const runtime = createRuntime({}, {
|
|
openCodePort: null,
|
|
openCodeBaseUrl: null,
|
|
isExternalOpenCode: true,
|
|
}, {
|
|
ENV_CONFIGURED_OPENCODE_PORT: null,
|
|
ENV_CONFIGURED_OPENCODE_HOST: { origin: 'http://seamus:4095', port: 4095 },
|
|
ENV_EFFECTIVE_PORT: 4095,
|
|
});
|
|
|
|
await runtime.restartOpenCode();
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'http://seamus:4095/global/health',
|
|
expect.objectContaining({ method: 'GET' }),
|
|
);
|
|
expect(runtime.testState.openCodePort).toBe(4095);
|
|
expect(runtime.testState.openCodeBaseUrl).toBe('http://seamus:4095');
|
|
expect(runtime.testState.lastOpenCodeError).toBeNull();
|
|
});
|
|
|
|
it('retains the OPENCODE_HOST port after an external re-probe fails', async () => {
|
|
globalThis.fetch = vi.fn(async () => ({
|
|
ok: false,
|
|
json: async () => null,
|
|
}));
|
|
const runtime = createRuntime({}, {
|
|
openCodePort: 4095,
|
|
openCodeBaseUrl: 'http://seamus:4095',
|
|
isExternalOpenCode: true,
|
|
}, {
|
|
ENV_CONFIGURED_OPENCODE_PORT: null,
|
|
ENV_CONFIGURED_OPENCODE_HOST: { origin: 'http://seamus:4095', port: 4095 },
|
|
ENV_EFFECTIVE_PORT: 4095,
|
|
});
|
|
|
|
await expect(runtime.restartOpenCode()).rejects.toThrow(
|
|
'External OpenCode server on port 4095 is not responding',
|
|
);
|
|
|
|
expect(runtime.testState.openCodePort).toBe(4095);
|
|
expect(runtime.testState.openCodeBaseUrl).toBe('http://seamus:4095');
|
|
});
|
|
|
|
it('warms recently used directories after a successful bootstrap', async () => {
|
|
const fetchMock = vi.fn(async () => ({
|
|
ok: true,
|
|
json: async () => ({ healthy: true }),
|
|
}));
|
|
globalThis.fetch = fetchMock;
|
|
const runtime = createRuntime({
|
|
env: {
|
|
ENV_CONFIGURED_OPENCODE_PORT: 45678,
|
|
ENV_CONFIGURED_OPENCODE_HOST: null,
|
|
ENV_EFFECTIVE_PORT: 45678,
|
|
ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1',
|
|
ENV_SKIP_OPENCODE_START: true,
|
|
},
|
|
reapManagedOrphanedProcesses: vi.fn(async () => ({ reaped: 0 })),
|
|
getWarmupDirectories: vi.fn(async () => ['/tmp/worktree-a', '/tmp/project-b']),
|
|
});
|
|
|
|
await runtime.bootstrapOpenCodeAtStartup();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
const warmupUrls = fetchMock.mock.calls
|
|
.map(([url]) => String(url))
|
|
.filter((url) => url.includes('/session/status'));
|
|
expect(warmupUrls).toEqual([
|
|
'http://127.0.0.1:45678/session/status?directory=%2Ftmp%2Fworktree-a',
|
|
'http://127.0.0.1:45678/session/status?directory=%2Ftmp%2Fproject-b',
|
|
]);
|
|
});
|
|
|
|
it('records an authoritative error terminal event when bootstrap fails', async () => {
|
|
const runtime = createRuntime({
|
|
syncFromHmrState: vi.fn(() => {
|
|
throw new Error('bootstrap failed');
|
|
}),
|
|
reapManagedOrphanedProcesses: vi.fn(async () => ({ reaped: 0 })),
|
|
});
|
|
|
|
await runtime.bootstrapOpenCodeAtStartup();
|
|
|
|
expect(recordStartupPerformanceMock).toHaveBeenCalledWith('opencode.bootstrap.error', {
|
|
totalDurationMs: expect.any(Number),
|
|
outcome: 'error',
|
|
});
|
|
expect(recordStartupPerformanceMock).not.toHaveBeenCalledWith(
|
|
'opencode.bootstrap.ready',
|
|
expect.anything(),
|
|
);
|
|
const terminalEvents = recordStartupPerformanceMock.mock.calls.filter(([phase]) => (
|
|
phase === 'opencode.bootstrap.ready' || phase === 'opencode.bootstrap.error'
|
|
));
|
|
expect(terminalEvents).toHaveLength(1);
|
|
});
|
|
|
|
it('does not count rapid transport-triggered checks as independent health failures', async () => {
|
|
const close = vi.fn(async () => {});
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
let now = 1;
|
|
globalThis.fetch = vi.fn(async () => ({
|
|
ok: false,
|
|
json: async () => null,
|
|
}));
|
|
const runtime = createRuntime({ now: () => now }, {
|
|
openCodePort: 45678,
|
|
openCodeProcess: {
|
|
pid: null,
|
|
exitCode: null,
|
|
signalCode: null,
|
|
close,
|
|
},
|
|
isOpenCodeReady: true,
|
|
});
|
|
|
|
for (let attempt = 0; attempt < 25; attempt += 1) {
|
|
await runtime.triggerHealthCheck();
|
|
}
|
|
|
|
expect(close).not.toHaveBeenCalled();
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
|
|
now += 15_000;
|
|
await runtime.triggerHealthCheck();
|
|
|
|
expect(warn).toHaveBeenCalledTimes(2);
|
|
expect(warn).toHaveBeenLastCalledWith(expect.stringContaining('(2/20)'));
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: 'timeout',
|
|
expectedClass: 'timeout',
|
|
fetchResult: () => {
|
|
const error = new Error('The operation was aborted');
|
|
error.name = 'AbortError';
|
|
throw error;
|
|
},
|
|
},
|
|
{
|
|
name: 'connection refusal',
|
|
expectedClass: 'connection_refused',
|
|
fetchResult: () => {
|
|
const error = new Error('connect ECONNREFUSED 127.0.0.1:45678');
|
|
error.code = 'ECONNREFUSED';
|
|
throw error;
|
|
},
|
|
},
|
|
{
|
|
name: 'invalid JSON',
|
|
expectedClass: 'invalid_response',
|
|
fetchResult: () => ({
|
|
ok: true,
|
|
json: async () => {
|
|
throw new SyntaxError('Unexpected token');
|
|
},
|
|
}),
|
|
},
|
|
])('classifies and stores a counted $name health failure', async ({ expectedClass, fetchResult }) => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
globalThis.fetch = vi.fn(fetchResult);
|
|
const runtime = createRuntime({}, {
|
|
openCodePort: 45678,
|
|
openCodeProcess: {
|
|
pid: process.pid,
|
|
exitCode: null,
|
|
signalCode: null,
|
|
close: vi.fn(async () => {}),
|
|
},
|
|
isOpenCodeReady: true,
|
|
});
|
|
|
|
await runtime.triggerHealthCheck();
|
|
|
|
expect(runtime.testState.lastOpenCodeHealthFailure).toEqual({
|
|
class: expectedClass,
|
|
detail: expect.any(String),
|
|
at: expect.any(String),
|
|
source: 'immediate',
|
|
});
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining(`class=${expectedClass}`));
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('does not mistake a live managed process wrapper for an exited child', async () => {
|
|
const close = vi.fn(async () => {});
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
globalThis.fetch = vi.fn(async () => ({
|
|
ok: false,
|
|
json: async () => null,
|
|
}));
|
|
const runtime = createRuntime({}, {
|
|
openCodePort: 45678,
|
|
openCodeProcess: {
|
|
pid: process.pid,
|
|
close,
|
|
},
|
|
isOpenCodeReady: true,
|
|
});
|
|
|
|
await runtime.triggerHealthCheck();
|
|
|
|
expect(close).not.toHaveBeenCalled();
|
|
expect(spawnMock).not.toHaveBeenCalled();
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('(1/20)'));
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('restarts an exited managed process without waiting for the failure interval', async () => {
|
|
const close = vi.fn(async () => {});
|
|
const replacement = createMockChild();
|
|
globalThis.fetch = vi.fn(async () => ({
|
|
ok: false,
|
|
json: async () => null,
|
|
}));
|
|
spawnMock.mockImplementationOnce(() => {
|
|
queueMicrotask(() => {
|
|
replacement.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
|
|
});
|
|
return replacement;
|
|
});
|
|
const runtime = createRuntime({}, {
|
|
openCodePort: 45678,
|
|
openCodeProcess: {
|
|
pid: null,
|
|
exitCode: 1,
|
|
signalCode: null,
|
|
close,
|
|
},
|
|
});
|
|
|
|
await runtime.triggerHealthCheck();
|
|
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('calls onOpenCodeRestarted after a successful managed restart', async () => {
|
|
const close = vi.fn(async () => {});
|
|
const replacement = createMockChild();
|
|
const onOpenCodeRestarted = vi.fn();
|
|
globalThis.fetch = vi.fn(async () => ({
|
|
ok: false,
|
|
json: async () => null,
|
|
}));
|
|
spawnMock.mockImplementationOnce(() => {
|
|
queueMicrotask(() => {
|
|
replacement.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
|
|
});
|
|
return replacement;
|
|
});
|
|
const runtime = createRuntime({ onOpenCodeRestarted }, {
|
|
openCodePort: 45678,
|
|
openCodeProcess: {
|
|
pid: null,
|
|
exitCode: 1,
|
|
signalCode: null,
|
|
close,
|
|
},
|
|
});
|
|
|
|
await runtime.triggerHealthCheck();
|
|
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
// The restart completed on a (possibly new) port — the event-stream
|
|
// upstreams must rebind so the UI keeps receiving events (#2638).
|
|
expect(onOpenCodeRestarted).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('retains post-listen stderr and exited process diagnostics across restart', async () => {
|
|
const firstChild = createMockChild();
|
|
const replacement = createMockChild();
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
globalThis.fetch = vi.fn(async () => ({
|
|
ok: false,
|
|
status: 503,
|
|
json: async () => null,
|
|
}));
|
|
spawnMock.mockImplementationOnce(() => {
|
|
queueMicrotask(() => {
|
|
firstChild.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
|
|
});
|
|
return firstChild;
|
|
});
|
|
spawnMock.mockImplementationOnce(() => {
|
|
queueMicrotask(() => {
|
|
replacement.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
|
|
});
|
|
return replacement;
|
|
});
|
|
const runtime = createRuntime();
|
|
const server = await runtime.startOpenCode();
|
|
runtime.testState.openCodeProcess = server;
|
|
|
|
firstChild.stderr.emit(
|
|
'data',
|
|
`${'x'.repeat(40 * 1024)}\ntoken=runtime-secret\nruntime worker failed after startup\n`,
|
|
);
|
|
firstChild.exitCode = 7;
|
|
firstChild.emit('exit', 7, null);
|
|
|
|
expect(server.exitCode).toBe(7);
|
|
expect(Buffer.byteLength(server.stderrTail)).toBeLessThanOrEqual(32 * 1024);
|
|
expect(server.stderrTail).not.toContain('runtime-secret');
|
|
expect(server.stderrTail).toContain('runtime worker failed after startup');
|
|
|
|
await runtime.triggerHealthCheck();
|
|
|
|
expect(runtime.testState.lastOpenCodeRestartDiagnostics).toEqual({
|
|
reason: 'immediate-process-exited',
|
|
healthFailure: null,
|
|
process: {
|
|
pid: 12345,
|
|
exitCode: 7,
|
|
signalCode: null,
|
|
stderrTail: expect.stringContaining('runtime worker failed after startup'),
|
|
alive: false,
|
|
},
|
|
busySessionCount: 0,
|
|
at: expect.any(String),
|
|
});
|
|
expect(runtime.testState.lastManagedOpenCodeProcess).toEqual({
|
|
pid: 12345,
|
|
exitCode: 7,
|
|
signalCode: null,
|
|
stderrTail: expect.stringContaining('runtime worker failed after startup'),
|
|
});
|
|
|
|
await runtime.testState.openCodeProcess.close();
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('redacts Authorization scheme credentials from stderr diagnostics', async () => {
|
|
const firstChild = createMockChild();
|
|
const replacement = createMockChild();
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
globalThis.fetch = vi.fn(async () => ({
|
|
ok: false,
|
|
status: 503,
|
|
json: async () => null,
|
|
}));
|
|
spawnMock.mockImplementationOnce(() => {
|
|
queueMicrotask(() => {
|
|
firstChild.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
|
|
});
|
|
return firstChild;
|
|
});
|
|
spawnMock.mockImplementationOnce(() => {
|
|
queueMicrotask(() => {
|
|
replacement.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
|
|
});
|
|
return replacement;
|
|
});
|
|
const runtime = createRuntime();
|
|
const server = await runtime.startOpenCode();
|
|
runtime.testState.openCodeProcess = server;
|
|
|
|
firstChild.stderr.emit(
|
|
'data',
|
|
'request rejected: Authorization: Basic dXNlcjpwYXNz\n'
|
|
+ 'authorization: basic bG93ZXI6Y2FzZQ==\n'
|
|
+ 'Authorization: Bearer fake-bearer-token-value\n'
|
|
+ 'falling back to basic health monitor\n'
|
|
+ 'runtime worker failed after startup\n',
|
|
);
|
|
firstChild.exitCode = 7;
|
|
firstChild.emit('exit', 7, null);
|
|
|
|
expect(server.stderrTail).not.toContain('dXNlcjpwYXNz');
|
|
expect(server.stderrTail).not.toContain('bG93ZXI6Y2FzZQ');
|
|
expect(server.stderrTail).not.toContain('fake-bearer-token-value');
|
|
expect(server.stderrTail).toContain('falling back to basic health monitor');
|
|
expect(server.stderrTail).toContain('runtime worker failed after startup');
|
|
|
|
await runtime.triggerHealthCheck();
|
|
|
|
const diagnosticsTail = runtime.testState.lastOpenCodeRestartDiagnostics.process.stderrTail;
|
|
expect(diagnosticsTail).not.toContain('dXNlcjpwYXNz');
|
|
expect(diagnosticsTail).not.toContain('bG93ZXI6Y2FzZQ');
|
|
expect(diagnosticsTail).not.toContain('fake-bearer-token-value');
|
|
expect(diagnosticsTail).toContain('falling back to basic health monitor');
|
|
expect(diagnosticsTail).toContain('runtime worker failed after startup');
|
|
|
|
await runtime.testState.openCodeProcess.close();
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('does not call onOpenCodeRestarted when a managed restart fails', async () => {
|
|
const close = vi.fn(async () => {});
|
|
const onOpenCodeRestarted = vi.fn();
|
|
globalThis.fetch = vi.fn(async () => ({
|
|
ok: false,
|
|
json: async () => null,
|
|
}));
|
|
spawnMock.mockImplementation(() => {
|
|
const child = createMockChild();
|
|
queueMicrotask(() => {
|
|
child.emit('error', new Error('spawn failed'));
|
|
});
|
|
return child;
|
|
});
|
|
const runtime = createRuntime({ onOpenCodeRestarted }, {
|
|
openCodePort: 45678,
|
|
openCodeProcess: {
|
|
pid: null,
|
|
exitCode: 1,
|
|
signalCode: null,
|
|
close,
|
|
},
|
|
});
|
|
|
|
// triggerHealthCheck logs instead of rethrowing; call restartOpenCode
|
|
// directly to observe the failure result.
|
|
await expect(runtime.restartOpenCode()).rejects.toThrow();
|
|
|
|
expect(onOpenCodeRestarted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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');
|
|
expect(server.exitCode).toBeNull();
|
|
expect(server.signalCode).toBeNull();
|
|
|
|
await server.close();
|
|
expect(server.signalCode).toBe('SIGTERM');
|
|
});
|
|
|
|
it('launches managed OpenCode on the configured bind hostname', async () => {
|
|
delete process.env.OPENCODE_BINARY;
|
|
const child = createMockChild();
|
|
spawnMock.mockImplementationOnce(() => {
|
|
queueMicrotask(() => {
|
|
child.stdout.emit('data', 'opencode server listening on http://0.0.0.0:45678\n');
|
|
});
|
|
return child;
|
|
});
|
|
|
|
const runtime = createRuntime({}, {}, { ENV_CONFIGURED_OPENCODE_HOSTNAME: '0.0.0.0' });
|
|
const server = await runtime.startOpenCode();
|
|
const [binary, args] = spawnMock.mock.calls[0];
|
|
|
|
expect(binary).toBe('opencode');
|
|
expect(args).toEqual(['serve', '--hostname', '0.0.0.0', '--port', '45678']);
|
|
|
|
await server.close();
|
|
expect(server.signalCode).toBe('SIGTERM');
|
|
});
|
|
|
|
it('strips AppImage ARGV0 from managed OpenCode launch env', async () => {
|
|
delete process.env.OPENCODE_BINARY;
|
|
const previousArgv0 = process.env.ARGV0;
|
|
process.env.ARGV0 = '/path/to/OpenChamber/OpenChamber-1.17.2-linux-x86_64.AppImage';
|
|
const child = createMockChild();
|
|
spawnMock.mockImplementationOnce(() => {
|
|
queueMicrotask(() => {
|
|
child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
|
|
});
|
|
return child;
|
|
});
|
|
|
|
try {
|
|
const runtime = createRuntime({
|
|
getManagedOpenCodeShellEnvSnapshot: vi.fn(() => ({
|
|
PATH: '/home/user/.bun/bin:/usr/local/bin:/usr/bin',
|
|
ARGV0: '/leaked/from/shell/snapshot.AppImage',
|
|
SHELL_ONLY: 'yes',
|
|
})),
|
|
});
|
|
const server = await runtime.startOpenCode();
|
|
const [, , options] = spawnMock.mock.calls[0];
|
|
|
|
expect(options.env).not.toHaveProperty('ARGV0');
|
|
expect(options.env.SHELL_ONLY).toBe('yes');
|
|
expect(options.env.PATH).toBe('/home/user/.bun/bin:/usr/local/bin:/usr/bin');
|
|
|
|
await server.close();
|
|
} finally {
|
|
if (previousArgv0 === undefined) delete process.env.ARGV0;
|
|
else process.env.ARGV0 = previousArgv0;
|
|
}
|
|
});
|
|
|
|
it('adds managed OpenChamber tool environment without allowing it to replace launch invariants', async () => {
|
|
const child = createMockChild();
|
|
spawnMock.mockImplementationOnce(() => {
|
|
queueMicrotask(() => {
|
|
child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
|
|
});
|
|
return child;
|
|
});
|
|
const getManagedOpenCodeEnv = vi.fn(async () => ({
|
|
OPENCODE_CONFIG_CONTENT: '{"plugin":["file:///tool.js"]}',
|
|
OPENCHAMBER_AGENT_TOOL_TOKEN: 'ephemeral',
|
|
PATH: '/untrusted/path',
|
|
OPENCODE_SERVER_PASSWORD: 'untrusted-password',
|
|
}));
|
|
|
|
const runtime = createRuntime({ getManagedOpenCodeEnv });
|
|
const server = await runtime.startOpenCode();
|
|
const [, , options] = spawnMock.mock.calls[0];
|
|
|
|
expect(getManagedOpenCodeEnv).toHaveBeenCalledOnce();
|
|
expect(options.env.OPENCODE_CONFIG_CONTENT).toBe('{"plugin":["file:///tool.js"]}');
|
|
expect(options.env.OPENCHAMBER_AGENT_TOOL_TOKEN).toBe('ephemeral');
|
|
expect(options.env.PATH).toBe('/home/user/.bun/bin:/usr/local/bin:/usr/bin');
|
|
expect(options.env.OPENCODE_SERVER_PASSWORD).toBe('password');
|
|
|
|
await server.close();
|
|
});
|
|
|
|
it('mirrors Google credential env aliases into the managed OpenCode environment', async () => {
|
|
const previousGemini = process.env.GEMINI_API_KEY;
|
|
const previousGoogleGen = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
|
const previousGoogle = process.env.GOOGLE_API_KEY;
|
|
process.env.GEMINI_API_KEY = 'AIza-from-gemini';
|
|
delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
|
delete process.env.GOOGLE_API_KEY;
|
|
|
|
try {
|
|
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 [, , options] = spawnMock.mock.calls[0];
|
|
|
|
expect(options.env.GEMINI_API_KEY).toBe('AIza-from-gemini');
|
|
expect(options.env.GOOGLE_API_KEY).toBe('AIza-from-gemini');
|
|
expect(options.env.GOOGLE_GENERATIVE_AI_API_KEY).toBe('AIza-from-gemini');
|
|
|
|
await server.close();
|
|
} finally {
|
|
if (typeof previousGemini === 'string') {
|
|
process.env.GEMINI_API_KEY = previousGemini;
|
|
} else {
|
|
delete process.env.GEMINI_API_KEY;
|
|
}
|
|
if (typeof previousGoogleGen === 'string') {
|
|
process.env.GOOGLE_GENERATIVE_AI_API_KEY = previousGoogleGen;
|
|
} else {
|
|
delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
|
}
|
|
if (typeof previousGoogle === 'string') {
|
|
process.env.GOOGLE_API_KEY = previousGoogle;
|
|
} else {
|
|
delete process.env.GOOGLE_API_KEY;
|
|
}
|
|
}
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe('killProcessOnPort on Windows', () => {
|
|
const originalPlatform = process.platform;
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
});
|
|
|
|
const setPlatform = (platform) => {
|
|
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
|
|
};
|
|
|
|
it('force-kills the process listening on the target port via taskkill', () => {
|
|
setPlatform('win32');
|
|
const orphanPid = 54321;
|
|
spawnSyncMock.mockImplementation((cmd) => {
|
|
if (cmd === 'powershell') {
|
|
return { stdout: `${orphanPid}\r\n` };
|
|
}
|
|
return { stdout: '' };
|
|
});
|
|
|
|
const runtime = createRuntime();
|
|
runtime.killProcessOnPort(45678);
|
|
|
|
expect(spawnSyncMock).toHaveBeenCalledWith(
|
|
'powershell',
|
|
expect.arrayContaining([expect.stringContaining('-LocalPort 45678')]),
|
|
expect.objectContaining({ windowsHide: true })
|
|
);
|
|
expect(spawnSyncMock).toHaveBeenCalledWith(
|
|
'taskkill',
|
|
['/PID', String(orphanPid), '/F'],
|
|
expect.objectContaining({ windowsHide: true })
|
|
);
|
|
});
|
|
|
|
it('never force-kills its own process id', () => {
|
|
setPlatform('win32');
|
|
spawnSyncMock.mockImplementation((cmd) => {
|
|
if (cmd === 'powershell') {
|
|
return { stdout: `${process.pid}\r\n` };
|
|
}
|
|
return { stdout: '' };
|
|
});
|
|
|
|
const runtime = createRuntime();
|
|
runtime.killProcessOnPort(45678);
|
|
|
|
expect(spawnSyncMock).not.toHaveBeenCalledWith('taskkill', expect.anything(), expect.anything());
|
|
});
|
|
|
|
it('does nothing when no process is listening on the target port', () => {
|
|
setPlatform('win32');
|
|
spawnSyncMock.mockImplementation((cmd) => {
|
|
if (cmd === 'powershell') {
|
|
return { stdout: '' };
|
|
}
|
|
return { stdout: '' };
|
|
});
|
|
|
|
const runtime = createRuntime();
|
|
runtime.killProcessOnPort(45678);
|
|
|
|
expect(spawnSyncMock).not.toHaveBeenCalledWith('taskkill', expect.anything(), expect.anything());
|
|
});
|
|
});
|