Merge main
This commit is contained in:
@@ -2,24 +2,33 @@ 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: vi.fn(),
|
||||
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 {
|
||||
@@ -48,7 +57,7 @@ const createMockChild = () => {
|
||||
return child;
|
||||
};
|
||||
|
||||
const createRuntime = (overrides = {}) => {
|
||||
const createRuntime = (overrides = {}, stateOverrides = {}, envOverrides = {}) => {
|
||||
const state = {
|
||||
openCodeWorkingDirectory: '/tmp/project',
|
||||
openCodeProcess: null,
|
||||
@@ -60,6 +69,9 @@ const createRuntime = (overrides = {}) => {
|
||||
openCodeApiPrefixDetected: false,
|
||||
openCodeApiDetectionTimer: null,
|
||||
lastOpenCodeError: null,
|
||||
lastOpenCodeHealthFailure: null,
|
||||
lastManagedOpenCodeProcess: null,
|
||||
lastOpenCodeRestartDiagnostics: null,
|
||||
isOpenCodeReady: false,
|
||||
openCodeNotReadySince: 0,
|
||||
isExternalOpenCode: false,
|
||||
@@ -70,9 +82,10 @@ const createRuntime = (overrides = {}) => {
|
||||
resolvedWslBinary: null,
|
||||
resolvedWslOpencodePath: null,
|
||||
resolvedWslDistro: null,
|
||||
...stateOverrides,
|
||||
};
|
||||
|
||||
return createOpenCodeLifecycleRuntime({
|
||||
const runtime = createOpenCodeLifecycleRuntime({
|
||||
state,
|
||||
env: {
|
||||
ENV_CONFIGURED_OPENCODE_PORT: 45678,
|
||||
@@ -80,6 +93,7 @@ const createRuntime = (overrides = {}) => {
|
||||
ENV_EFFECTIVE_PORT: 3001,
|
||||
ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1',
|
||||
ENV_SKIP_OPENCODE_START: false,
|
||||
...envOverrides,
|
||||
},
|
||||
syncToHmrState: vi.fn(),
|
||||
syncFromHmrState: vi.fn(),
|
||||
@@ -107,9 +121,471 @@ const createRuntime = (overrides = {}) => {
|
||||
})),
|
||||
...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();
|
||||
@@ -129,8 +605,139 @@ describe('OpenCode lifecycle', () => {
|
||||
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 () => {
|
||||
@@ -241,3 +848,70 @@ describe('OpenCode lifecycle', () => {
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user