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 ();
vi . mock ( 'node:child_process' , () => ({
spawn : spawnMock ,
spawnSync : vi . fn (),
}));
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-04-25 20:44:07 +03:00
afterEach (() => {
spawnMock . mockReset ();
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 ;
};
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' ),
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' , () => {
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' );
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
});