2026-04-25 20:44:07 +03:00
import { EventEmitter } from 'node:events' ;
import { afterEach , describe , expect , it , vi } from 'vitest' ;
const spawnMock = vi . fn ();
2026-07-31 12:51:15 +03:00
const recordStartupPerformanceMock = vi . fn ();
2026-04-25 20:44:07 +03:00
vi . mock ( 'node:child_process' , () => ({
spawn : spawnMock ,
spawnSync : vi . fn (),
}));
2026-07-31 12:51:15 +03:00
vi . mock ( './startup-performance.js' , () => ({
recordStartupPerformance : recordStartupPerformanceMock ,
}));
2026-04-25 20:44:07 +03:00
const { createOpenCodeLifecycleRuntime } = await import ( './lifecycle.js' );
const originalOpencodeBinary = process . env . OPENCODE_BINARY ;
2026-05-12 03:57:14 -04:00
const originalPath = process . env . PATH ;
2026-07-29 17:26:11 +03:00
const originalFetch = globalThis . fetch ;
2026-04-25 20:44:07 +03:00
afterEach (() => {
spawnMock . mockReset ();
2026-07-31 12:51:15 +03:00
recordStartupPerformanceMock . mockReset ();
2026-07-29 17:26:11 +03:00
globalThis . fetch = originalFetch ;
2026-04-25 20:44:07 +03:00
if ( typeof originalOpencodeBinary === 'string' ) {
process . env . OPENCODE_BINARY = originalOpencodeBinary ;
2026-05-12 03:57:14 -04:00
} else {
delete process . env . OPENCODE_BINARY ;
}
if ( typeof originalPath === 'string' ) {
process . env . PATH = originalPath ;
} else {
delete process . env . PATH ;
2026-04-25 20:44:07 +03:00
}
});
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 ;
};
2026-08-05 11:24:14 +03:00
const createRuntime = ( overrides = {}, stateOverrides = {}, envOverrides = {}) => {
2026-04-25 20:44:07 +03:00
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 ,
2026-07-29 17:26:11 +03:00
... stateOverrides ,
2026-04-25 20:44:07 +03:00
};
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 ,
2026-08-05 11:24:14 +03:00
... envOverrides ,
2026-04-25 20:44:07 +03:00
},
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' ),
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' ,
})),
2026-04-25 20:44:07 +03:00
... overrides ,
});
};
describe ( 'OpenCode lifecycle' , () => {
2026-07-31 12:51:15 +03:00
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 ( '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 );
});
2026-07-29 17:26:11 +03:00
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 ();
});
2026-08-03 13:23:42 +03:00
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 ();
});
2026-07-29 17:26:11 +03:00
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 );
});
2026-08-05 13:59:17 +03:00
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 ( '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 ();
});
2026-04-25 20:44:07 +03:00
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' );
2026-04-25 20:44:07 +03:00
expect ( options . env . OPENCODE_SERVER_PASSWORD ). toBe ( 'password' );
2026-08-03 13:23:42 +03:00
expect ( server . exitCode ). toBeNull ();
expect ( server . signalCode ). toBeNull ();
2026-04-25 20:44:07 +03:00
await server . close ();
2026-08-03 13:23:42 +03:00
expect ( server . signalCode ). toBe ( 'SIGTERM' );
2026-04-25 20:44:07 +03:00
});
2026-04-26 16:24:07 +03:00
2026-08-05 11:24:14 +03:00
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' );
});
2026-08-03 08:54:39 +00:00
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 ;
}
});
2026-07-24 21:54:28 +03:00
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 ();
});
2026-04-26 16:24:07 +03:00
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 ();
});
2026-04-28 12:35:00 +03:00
2026-05-06 02:06:16 +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 ();
2026-05-06 02:06:16 +03:00
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 );
});
2026-05-06 02:06:16 +03:00
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 ();
});
2026-04-25 20:44:07 +03:00
});