Files
makelore/tests/unit/opencode-manager.test.ts

1763 lines
60 KiB
TypeScript

import { EventEmitter } from 'node:events';
import { createServer as createHttpServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, dirname, join } from 'node:path';
import type { ChildProcess, SpawnOptionsWithoutStdio } from 'node:child_process';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { OpencodeManager } from '@electron/opencode/manager';
import {
ensureBundledSuperpowersPlugin,
resolveBundledAgentBrowserPluginPath,
resolveBundledSuperpowersDir,
} from '@electron/opencode/superpowers';
import { logger } from '@electron/utils/logger';
type SpawnCall = {
command: string;
args: string[];
options: SpawnOptionsWithoutStdio;
};
class FakeChildProcess extends EventEmitter {
readonly stdout = new EventEmitter();
readonly stderr = new EventEmitter();
readonly pid = 4242;
readonly kill = vi.fn(() => true);
}
function createSpawnHarness() {
const children: FakeChildProcess[] = [];
const calls: SpawnCall[] = [];
const spawn = vi.fn(
(command: string, args: string[], options: SpawnOptionsWithoutStdio): ChildProcess => {
const child = new FakeChildProcess();
children.push(child);
calls.push({ command, args, options });
return child as unknown as ChildProcess;
},
);
return { children, calls, spawn };
}
function createBundledSuperpowersFixture(): string {
const sourceDir = mkdtempSync(join(tmpdir(), 'niancode-superpowers-source-'));
writeFileSync(
join(sourceDir, 'package.json'),
JSON.stringify({ name: 'superpowers', version: '5.0.7' }),
);
writeFileSync(join(sourceDir, 'LICENSE'), 'source-license');
const pluginDir = join(sourceDir, '.opencode', 'plugins');
const skillDir = join(sourceDir, 'skills', 'using-superpowers');
mkdirSync(pluginDir, { recursive: true });
mkdirSync(skillDir, { recursive: true });
writeFileSync(
join(pluginDir, 'superpowers.js'),
'export const SuperpowersPlugin = async () => ({});\n',
);
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: using-superpowers\n---\n');
return sourceDir;
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('OpencodeManager', () => {
it('resolves the packaged Superpowers source without depending on the managed destination name', () => {
const resourcesPath = 'C:\\Program Files\\NianCode\\resources';
expect(resolveBundledSuperpowersDir({
isPackaged: true,
resourcesPath,
appPath: 'C:\\Program Files\\NianCode\\resources\\app.asar',
})).toBe(join(resourcesPath, 'resources', 'skills', 'superpowers'));
});
it('resolves the bundled Agent Browser plugin from the course skills bundle', () => {
const resourcesPath = 'C:\\Program Files\\Makelore\\resources';
expect(resolveBundledAgentBrowserPluginPath({
isPackaged: true,
resourcesPath,
appPath: 'C:\\Program Files\\Makelore\\resources\\app.asar',
})).toBe(join(
resourcesPath,
'course-skills',
'agent-browser',
'.opencode',
'plugins',
'niancode-agent-browser.js',
));
});
it('spawns the bundled opencode server and becomes running after listening output', async () => {
const { children, calls, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4321,
binPath: 'C:\\NianCode\\opencode.exe',
spawn,
});
const startPromise = manager.start();
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4321\n'),
);
const status = await startPromise;
expect(spawn).toHaveBeenCalledOnce();
expect(calls[0]).toMatchObject({
command: 'C:\\NianCode\\opencode.exe',
args: ['serve', '--hostname=127.0.0.1', '--port=4321'],
options: {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
},
});
expect(status).toMatchObject({
state: 'running',
port: 4321,
url: 'http://127.0.0.1:4321',
pid: 4242,
});
expect(manager.getStatus()).toEqual(status);
expect(manager.getStatus()).not.toBe(status);
});
it('falls back to an ephemeral port when the preferred port has an unhealthy listener', async () => {
const { children, calls, spawn } = createSpawnHarness();
const preferredPort = 4340;
const actualPort = 54721;
const findPortOwner = vi.fn(async () => ({ pid: 33188 }));
const fetch = vi.fn(async () => new Response(null, { status: 503 }));
vi.stubGlobal('fetch', fetch);
const manager = new OpencodeManager({
port: preferredPort,
binPath: 'C:\\NianCode\\opencode.exe',
spawn,
findPortOwner,
});
const startPromise = manager.start();
await vi.waitFor(() => expect(children).toHaveLength(1));
children[0].stdout.emit(
'data',
Buffer.from(`opencode server listening on http://127.0.0.1:${actualPort}\n`),
);
const status = await startPromise;
expect(calls[0].args).toEqual([
'serve',
'--hostname=127.0.0.1',
'--port=0',
]);
expect(status).toMatchObject({
state: 'running',
port: actualPort,
url: `http://127.0.0.1:${actualPort}`,
pid: 4242,
});
expect(manager.getStatus()).toEqual(status);
});
it('uses the production bind probe when an unhealthy listener occupies the preferred port', async () => {
const listener = createHttpServer((_request, response) => {
response.statusCode = 503;
response.end();
});
await new Promise<void>((resolve, reject) => {
listener.once('error', reject);
listener.listen({ host: '127.0.0.1', port: 0 }, () => resolve());
});
const preferredPort = (listener.address() as AddressInfo).port;
const actualPort = 54723;
try {
const { children, calls, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: preferredPort,
binPath: 'C:\\NianCode\\opencode.exe',
preflightPreferredPort: true,
spawn,
});
const startPromise = manager.start();
await vi.waitFor(() => expect(children).toHaveLength(1));
children[0].stdout.emit(
'data',
Buffer.from(`opencode server listening on http://127.0.0.1:${actualPort}\n`),
);
await expect(startPromise).resolves.toMatchObject({
state: 'running',
port: actualPort,
});
expect(calls[0].args).toContain('--port=0');
} finally {
listener.closeAllConnections();
await new Promise<void>((resolve, reject) => {
listener.close((error) => error ? reject(error) : resolve());
});
}
});
it('passes configProvider JSON through OPENCODE_CONFIG_CONTENT', async () => {
const { children, calls, spawn } = createSpawnHarness();
const config = { provider: { anthropic: { models: ['claude'] } } };
const manager = new OpencodeManager({
port: 4322,
binPath: '/opt/opencode',
configProvider: () => config,
spawn,
});
const startPromise = manager.start();
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4322\n'),
);
await startPromise;
expect(calls[0].options.env).toMatchObject({
OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
});
});
it('runs extensionless opencode package scripts through Node on Windows', async () => {
const { children, calls, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4325,
binPath: 'C:\\NianCode\\resources\\opencode-ai\\bin\\opencode',
spawn,
});
const startPromise = manager.start();
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4325\n'),
);
await startPromise;
if (process.platform === 'win32') {
expect(calls[0].command).toBe(process.execPath);
expect(calls[0].args).toEqual([
'C:\\NianCode\\resources\\opencode-ai\\bin\\opencode',
'serve',
'--hostname=127.0.0.1',
'--port=4325',
]);
expect(calls[0].options.env).toMatchObject({
ELECTRON_RUN_AS_NODE: '1',
});
} else {
expect(calls[0].command).toBe('C:\\NianCode\\resources\\opencode-ai\\bin\\opencode');
}
});
it('passes runtime provider environment variables to the child process', async () => {
const { children, calls, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4324,
binPath: '/opt/opencode',
runtimeConfigProvider: () => ({
config: {
provider: {
openai: {
options: {
apiKey: '{env:NIANCODE_OPENCODE_OPENAI_API_KEY}',
},
},
},
},
env: {
NIANCODE_OPENCODE_OPENAI_API_KEY: 'sk-openai',
},
}),
spawn,
});
const startPromise = manager.start();
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4324\n'),
);
await startPromise;
expect(calls[0].options.env).toMatchObject({
OPENCODE_CONFIG_CONTENT: JSON.stringify({
provider: {
openai: {
options: {
apiKey: '{env:NIANCODE_OPENCODE_OPENAI_API_KEY}',
},
},
},
}),
NIANCODE_OPENCODE_OPENAI_API_KEY: 'sk-openai',
});
});
it('passes the managed Python runtime only to the OpenCode child environment', async () => {
const { children, calls, spawn } = createSpawnHarness();
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-python-'));
const pythonExecutable = join(userDataDir, 'bundled-python', 'python.exe');
const uvExecutable = join(userDataDir, 'bundled-bin', 'uv.exe');
const originalPath = process.env.PATH ?? '';
try {
const manager = new OpencodeManager({
port: 4325,
binPath: '/opt/opencode',
userDataDir,
pythonRuntime: {
executable: pythonExecutable,
binDir: dirname(pythonExecutable),
source: 'bundled',
},
uvRuntime: {
executable: uvExecutable,
binDir: dirname(uvExecutable),
source: 'bundled',
},
spawn,
});
const startPromise = manager.start();
children[0].stdout.emit('data', Buffer.from('opencode server listening on http://127.0.0.1:4325\n'));
await startPromise;
expect(calls[0].options.env).toMatchObject({
NIANCODE_PYTHON_PATH: pythonExecutable,
NIANCODE_UV_PATH: uvExecutable,
});
const childPath = String(calls[0].options.env?.PATH);
expect(childPath.split(delimiter).slice(0, 2)).toEqual([
dirname(uvExecutable),
dirname(pythonExecutable),
]);
expect(childPath.split(delimiter).slice(2).join(delimiter)).toBe(originalPath);
expect(process.env.PATH ?? '').toBe(originalPath);
} finally {
rmSync(userDataDir, { recursive: true, force: true });
}
});
it('logs the sanitized runtime provider target before spawning', async () => {
const { children, spawn } = createSpawnHarness();
const logInfo = vi.spyOn(logger, 'info').mockImplementation(() => undefined);
const manager = new OpencodeManager({
port: 4330,
binPath: '/opt/opencode',
runtimeConfigProvider: () => ({
config: {
model: 'niancode-user-models/gpt-4.1-mini',
enabled_providers: ['niancode-user-models'],
provider: {
'niancode-user-models': {
npm: '@ai-sdk/openai-compatible',
options: {
apiKey: '{env:NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY}',
baseURL: 'https://one-api.example.com/v1',
},
models: {
'gpt-4.1-mini': { name: 'gpt-4.1-mini' },
},
},
},
mcp: {
playwright: {
type: 'local',
command: ['node', 'node_modules/@playwright/mcp/cli.js'],
enabled: true,
env: {
ELECTRON_RUN_AS_NODE: '1',
},
},
},
},
env: {
NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY: 'sk-user-token',
},
}),
spawn,
});
const startPromise = manager.start();
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4330\n'),
);
await startPromise;
expect(logInfo).toHaveBeenCalledWith(
'[opencode-runtime] Starting runtime with provider config',
expect.objectContaining({
port: 4330,
model: 'niancode-user-models/gpt-4.1-mini',
enabledProviderIds: ['niancode-user-models'],
envKeys: ['NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY'],
providers: [
expect.objectContaining({
id: 'niancode-user-models',
baseURL: 'https://one-api.example.com/v1',
hasApiKey: true,
apiKeyEnv: 'NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY',
}),
],
mcpServerIds: ['playwright'],
mcpServers: [
expect.objectContaining({
id: 'playwright',
type: 'local',
enabled: true,
command: ['node', 'node_modules/@playwright/mcp/cli.js'],
envKeys: ['ELECTRON_RUN_AS_NODE'],
}),
],
}),
);
expect(JSON.stringify(logInfo.mock.calls)).not.toContain('sk-user-token');
});
it('attaches to a healthy opencode server when startup exits because the port is already in use', async () => {
const { children, spawn } = createSpawnHarness();
const logWarn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
const fetch = vi.fn(async () => new Response(JSON.stringify({ healthy: true, version: '1.15.3' }), {
headers: { 'content-type': 'application/json' },
status: 200,
}));
vi.stubGlobal('fetch', fetch);
const manager = new OpencodeManager({
port: 4329,
binPath: '/opt/opencode',
spawn,
startupTimeoutMs: 1,
});
const startPromise = manager.start();
children[0].stderr.emit('data', Buffer.from('Failed to start server. Is port 4329 in use?\n'));
children[0].emit('exit', 1);
const status = await startPromise;
expect(fetch).toHaveBeenCalledWith(
'http://127.0.0.1:4329/global/health',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(spawn).toHaveBeenCalledOnce();
expect(status).toMatchObject({
state: 'running',
port: 4329,
url: 'http://127.0.0.1:4329',
});
expect(logWarn).toHaveBeenCalledWith(
'[opencode-runtime] Attached to an existing server; newly generated provider config may not be active',
expect.objectContaining({
port: 4329,
url: 'http://127.0.0.1:4329',
}),
);
expect(manager.getStatus()).toEqual(status);
});
it('stops a managed Unix attached runtime before spawning the new provider config', async () => {
const { children, calls, spawn } = createSpawnHarness();
const findPortOwner = vi.fn()
.mockResolvedValueOnce({
pid: 5150,
commandLine: '/opt/opencode serve --hostname=127.0.0.1 --port=4334',
})
.mockResolvedValueOnce(null);
const killProcess = vi.fn(() => true);
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ healthy: true }), {
headers: { 'content-type': 'application/json' },
status: 200,
})));
const manager = new OpencodeManager({
port: 4334,
binPath: '/opt/opencode',
runtimeConfigProvider: () => ({
config: {},
env: {
NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY: 'fresh-token',
},
}),
spawn,
findPortOwner,
killProcess,
startupTimeoutMs: 1_000,
});
const startPromise = manager.start();
await vi.waitFor(() => expect(children).toHaveLength(1));
children[0].stderr.emit('data', Buffer.from('Failed to start server. Is port 4334 in use?\n'));
children[0].emit('exit', 1);
await expect(startPromise).resolves.toMatchObject({
state: 'running',
port: 4334,
url: 'http://127.0.0.1:4334',
});
const restartPromise = manager.restart();
await vi.waitFor(() => expect(children).toHaveLength(2));
children[1].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4334\n'),
);
const status = await restartPromise;
expect(killProcess).toHaveBeenCalledWith(5150);
expect(calls[0].options.env).toMatchObject({
NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY: 'fresh-token',
});
expect(status).toMatchObject({
state: 'running',
port: 4334,
pid: 4242,
});
});
it('stops an attached bundled opencode server before restarting', async () => {
const { children, spawn } = createSpawnHarness();
const attachedOwner = {
pid: 5150,
executablePath: 'C:\\NianCode\\opencode.exe',
commandLine: 'C:\\NianCode\\opencode.exe serve --hostname=127.0.0.1 --port=4331',
};
const findPortOwner = vi.fn()
.mockResolvedValueOnce(attachedOwner)
.mockResolvedValueOnce(null);
const killProcess = vi.fn(() => true);
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ healthy: true }), {
headers: { 'content-type': 'application/json' },
status: 200,
})));
const manager = new OpencodeManager({
port: 4331,
binPath: 'C:\\NianCode\\opencode.exe',
spawn,
findPortOwner,
killProcess,
startupTimeoutMs: 1_000,
});
const startPromise = manager.start();
await vi.waitFor(() => expect(children).toHaveLength(1));
children[0].stderr.emit('data', Buffer.from('Failed to start server. Is port 4331 in use?\n'));
children[0].emit('exit', 1);
await expect(startPromise).resolves.toMatchObject({
state: 'running',
port: 4331,
url: 'http://127.0.0.1:4331',
});
const restartPromise = manager.restart();
await vi.waitFor(() => expect(children).toHaveLength(2));
children[1].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4331\n'),
);
const restartedStatus = await restartPromise;
expect(findPortOwner).toHaveBeenCalledWith(4331);
expect(killProcess).toHaveBeenCalledWith(5150);
expect(spawn).toHaveBeenCalledTimes(2);
expect(restartedStatus).toMatchObject({
state: 'running',
port: 4331,
url: 'http://127.0.0.1:4331',
pid: 4242,
});
});
it('does not kill an attached server when the port owner is not the bundled opencode binary', async () => {
const { children, spawn } = createSpawnHarness();
const findPortOwner = vi.fn(async () => ({
pid: 6161,
executablePath: 'C:\\OtherApp\\server.exe',
commandLine: 'C:\\OtherApp\\server.exe --port=4332',
}));
const killProcess = vi.fn(() => true);
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ healthy: true }), {
headers: { 'content-type': 'application/json' },
status: 200,
})));
const manager = new OpencodeManager({
port: 4332,
binPath: 'C:\\NianCode\\opencode.exe',
spawn,
findPortOwner,
killProcess,
});
const startPromise = manager.start();
await vi.waitFor(() => expect(children).toHaveLength(1));
children[0].stderr.emit('data', Buffer.from('Failed to start server. Is port 4332 in use?\n'));
children[0].emit('exit', 1);
await startPromise;
await manager.stop();
expect(findPortOwner).toHaveBeenCalledWith(4332);
expect(killProcess).not.toHaveBeenCalled();
expect(manager.getStatus()).toEqual({ state: 'stopped', port: 4332 });
});
it('retains an attached managed port when terminating its process fails', async () => {
const { children, spawn } = createSpawnHarness();
const port = 4336;
const attachedOwner = {
pid: 5150,
executablePath: 'C:\\NianCode\\opencode.exe',
commandLine: `C:\\NianCode\\opencode.exe serve --port=${port}`,
};
const findPortOwner = vi.fn()
.mockResolvedValueOnce(attachedOwner)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ pid: attachedOwner.pid });
const killProcess = vi.fn(() => false);
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ healthy: true }), {
headers: { 'content-type': 'application/json' },
status: 200,
})));
const manager = new OpencodeManager({
port,
binPath: 'C:\\NianCode\\opencode.exe',
spawn,
findPortOwner,
killProcess,
});
const startPromise = manager.start();
await vi.waitFor(() => expect(children).toHaveLength(1));
children[0].emit('exit', 1);
await startPromise;
const emittedStates: string[] = [];
manager.on('status', (status: { state: string }) => emittedStates.push(status.state));
await expect(manager.stop()).rejects.toThrow(
`Failed to stop attached opencode process ${attachedOwner.pid}`,
);
await expect(manager.stop()).rejects.toThrow(
`Timed out waiting for opencode port ${port} to be released`,
);
expect(killProcess).toHaveBeenCalledOnce();
expect(emittedStates).not.toContain('stopped');
expect(manager.getStatus()).toMatchObject({ state: 'error', port });
});
it('isolates opencode config, data, and cache under the app user data directory', async () => {
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
try {
const { children, calls, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4326,
binPath: '/opt/opencode',
userDataDir,
runtimeConfigProvider: () => ({
config: {},
env: {
NIANCODE_OPENCODE_OPENAI_API_KEY: 'sk-openai',
},
}),
spawn,
});
const startPromise = manager.start();
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4326\n'),
);
await startPromise;
const opencodeDir = join(userDataDir, 'opencode');
expect(calls[0].options.env).toMatchObject({
XDG_CONFIG_HOME: join(opencodeDir, 'config'),
XDG_DATA_HOME: join(opencodeDir, 'data'),
XDG_CACHE_HOME: join(opencodeDir, 'cache'),
NIANCODE_OPENCODE_OPENAI_API_KEY: 'sk-openai',
});
expect(existsSync(join(opencodeDir, 'config'))).toBe(true);
expect(existsSync(join(opencodeDir, 'data'))).toBe(true);
expect(existsSync(join(opencodeDir, 'cache'))).toBe(true);
} finally {
rmSync(userDataDir, { recursive: true, force: true });
}
});
it('disables external Skill discovery for the managed NianCode runtime', async () => {
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
try {
const { children, calls, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4334,
binPath: '/opt/opencode',
userDataDir,
runtimeConfigProvider: () => ({
config: {},
env: {
OPENCODE_DISABLE_EXTERNAL_SKILLS: '0',
},
}),
spawn,
});
const startPromise = manager.start();
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4334\n'),
);
await startPromise;
expect(calls[0].options.env).toMatchObject({
OPENCODE_DISABLE_EXTERNAL_SKILLS: '1',
});
} finally {
rmSync(userDataDir, { recursive: true, force: true });
}
});
it('does not pass Meowa credentials into the model runtime process', async () => {
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
const previousApiKey = process.env.MEOWART_API_KEY;
const previousDevKey = process.env.MEOWART_DEV_KEY;
process.env.MEOWART_API_KEY = 'meowa-secret';
process.env.MEOWART_DEV_KEY = 'meowa-dev-secret';
try {
const { children, calls, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4335,
binPath: '/opt/opencode',
userDataDir,
runtimeConfigProvider: () => ({ config: {}, env: {} }),
spawn,
});
const startPromise = manager.start();
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4335\n'),
);
await startPromise;
expect(calls[0].options.env).not.toHaveProperty('MEOWART_API_KEY');
expect(calls[0].options.env).not.toHaveProperty('MEOWART_DEV_KEY');
} finally {
if (previousApiKey === undefined) delete process.env.MEOWART_API_KEY;
else process.env.MEOWART_API_KEY = previousApiKey;
if (previousDevKey === undefined) delete process.env.MEOWART_DEV_KEY;
else process.env.MEOWART_DEV_KEY = previousDevKey;
rmSync(userDataDir, { recursive: true, force: true });
}
});
it('installs bundled Superpowers into the managed opencode config directory before spawning', async () => {
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
const bundledSuperpowersDir = createBundledSuperpowersFixture();
try {
const { children, calls, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4328,
binPath: '/opt/opencode',
userDataDir,
bundledSuperpowersDir,
runtimeConfigProvider: () => ({ config: {}, env: {} }),
spawn,
});
const startPromise = manager.start();
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4328\n'),
);
await startPromise;
const opencodeDir = join(userDataDir, 'opencode');
const managedConfigDir = join(opencodeDir, 'niancode-config');
const installedRepoDir = join(managedConfigDir, 'superpowers-bundles', '5.0.7');
const wrapperPath = join(managedConfigDir, 'plugins', 'superpowers-niancode.js');
expect(calls[0].options.env).toMatchObject({
OPENCODE_CONFIG_DIR: managedConfigDir,
});
expect(existsSync(join(installedRepoDir, 'skills', 'using-superpowers', 'SKILL.md'))).toBe(true);
expect(existsSync(join(installedRepoDir, '.opencode', 'plugins', 'superpowers.js'))).toBe(true);
expect(readFileSync(wrapperPath, 'utf8')).toContain(
"export { SuperpowersPlugin } from '../superpowers-bundles/5.0.7/.opencode/plugins/superpowers.js';",
);
expect(JSON.parse(readFileSync(
join(managedConfigDir, 'superpowers-active.json'),
'utf8',
))).toEqual({ version: '5.0.7', directory: '5.0.7' });
} finally {
rmSync(userDataDir, { recursive: true, force: true });
rmSync(bundledSuperpowersDir, { recursive: true, force: true });
}
});
it('migrates the wrapper without touching an unusable legacy destination under a non-ASCII user path', () => {
const managedRoot = mkdtempSync(join(tmpdir(), 'niancode-managed-config-'));
const managedConfigDir = join(
managedRoot,
'卢欢',
'AppData',
'Roaming',
'niancode',
'opencode',
'niancode-config',
);
mkdirSync(managedConfigDir, { recursive: true });
const bundledSuperpowersDir = createBundledSuperpowersFixture();
try {
const legacyPath = join(managedConfigDir, 'superpowers');
writeFileSync(legacyPath, 'legacy-path-must-remain-untouched');
const wrapperPath = join(managedConfigDir, 'plugins', 'superpowers-niancode.js');
mkdirSync(join(managedConfigDir, 'plugins'), { recursive: true });
writeFileSync(
wrapperPath,
"export { SuperpowersPlugin } from '../superpowers/.opencode/plugins/superpowers.js';\n",
);
expect(ensureBundledSuperpowersPlugin({
managedConfigDir,
sourceDir: bundledSuperpowersDir,
})).toBe(true);
expect(readFileSync(legacyPath, 'utf8')).toBe('legacy-path-must-remain-untouched');
expect(readFileSync(wrapperPath, 'utf8')).not.toContain('../superpowers/.opencode');
expect(readFileSync(wrapperPath, 'utf8')).toContain('../superpowers-bundles/5.0.7/.opencode');
expect(existsSync(join(
managedConfigDir,
'superpowers-bundles',
'5.0.7',
'skills',
'using-superpowers',
'SKILL.md',
))).toBe(true);
} finally {
rmSync(managedRoot, { recursive: true, force: true });
rmSync(bundledSuperpowersDir, { recursive: true, force: true });
}
});
it('reuses an installed Superpowers version without overwriting its files', () => {
const managedConfigDir = mkdtempSync(join(tmpdir(), 'niancode-managed-config-'));
const bundledSuperpowersDir = createBundledSuperpowersFixture();
try {
expect(ensureBundledSuperpowersPlugin({
managedConfigDir,
sourceDir: bundledSuperpowersDir,
})).toBe(true);
const installedLicense = join(
managedConfigDir,
'superpowers-bundles',
'5.0.7',
'LICENSE',
);
writeFileSync(installedLicense, 'installed-copy-must-not-be-overwritten');
expect(ensureBundledSuperpowersPlugin({
managedConfigDir,
sourceDir: bundledSuperpowersDir,
})).toBe(true);
expect(readFileSync(installedLicense, 'utf8')).toBe('installed-copy-must-not-be-overwritten');
} finally {
rmSync(managedConfigDir, { recursive: true, force: true });
rmSync(bundledSuperpowersDir, { recursive: true, force: true });
}
});
it('cleans an incomplete Superpowers staging directory', () => {
const managedConfigDir = mkdtempSync(join(tmpdir(), 'niancode-managed-config-'));
const bundledSuperpowersDir = createBundledSuperpowersFixture();
try {
rmSync(join(
bundledSuperpowersDir,
'skills',
'using-superpowers',
'SKILL.md',
));
expect(() => ensureBundledSuperpowersPlugin({
managedConfigDir,
sourceDir: bundledSuperpowersDir,
})).toThrow('Bundled Superpowers staging copy is incomplete');
const bundlesDir = join(managedConfigDir, 'superpowers-bundles');
expect(readdirSync(bundlesDir).some((entry) => entry.startsWith('.staging-'))).toBe(false);
} finally {
rmSync(managedConfigDir, { recursive: true, force: true });
rmSync(bundledSuperpowersDir, { recursive: true, force: true });
}
});
it('keeps the active bundle when a newer Superpowers bundle is incomplete', () => {
const managedConfigDir = mkdtempSync(join(tmpdir(), 'niancode-managed-config-'));
const installedSourceDir = createBundledSuperpowersFixture();
const brokenSourceDir = createBundledSuperpowersFixture();
try {
expect(ensureBundledSuperpowersPlugin({
managedConfigDir,
sourceDir: installedSourceDir,
})).toBe(true);
const manifestPath = join(managedConfigDir, 'superpowers-active.json');
const originalManifest = readFileSync(manifestPath, 'utf8');
writeFileSync(
join(brokenSourceDir, 'package.json'),
JSON.stringify({ name: 'superpowers', version: '5.0.8' }),
);
rmSync(join(brokenSourceDir, 'skills', 'using-superpowers', 'SKILL.md'));
expect(() => ensureBundledSuperpowersPlugin({
managedConfigDir,
sourceDir: brokenSourceDir,
})).toThrow('Bundled Superpowers staging copy is incomplete');
expect(readFileSync(manifestPath, 'utf8')).toBe(originalManifest);
expect(existsSync(join(
managedConfigDir,
'superpowers-bundles',
'5.0.7',
'.opencode',
'plugins',
'superpowers.js',
))).toBe(true);
} finally {
rmSync(managedConfigDir, { recursive: true, force: true });
rmSync(installedSourceDir, { recursive: true, force: true });
rmSync(brokenSourceDir, { recursive: true, force: true });
}
});
it('uses a fresh bundle directory when the preferred version path is unusable', () => {
const managedConfigDir = mkdtempSync(join(tmpdir(), 'niancode-managed-config-'));
const bundledSuperpowersDir = createBundledSuperpowersFixture();
try {
const bundlesDir = join(managedConfigDir, 'superpowers-bundles');
mkdirSync(bundlesDir, { recursive: true });
writeFileSync(join(bundlesDir, '5.0.7'), 'unusable-preferred-path');
expect(ensureBundledSuperpowersPlugin({
managedConfigDir,
sourceDir: bundledSuperpowersDir,
})).toBe(true);
const manifest = JSON.parse(readFileSync(
join(managedConfigDir, 'superpowers-active.json'),
'utf8',
)) as { version: string; directory: string };
expect(manifest.version).toBe('5.0.7');
expect(manifest.directory).toMatch(/^5\.0\.7-/);
expect(existsSync(join(
bundlesDir,
manifest.directory,
'.opencode',
'plugins',
'superpowers.js',
))).toBe(true);
} finally {
rmSync(managedConfigDir, { recursive: true, force: true });
rmSync(bundledSuperpowersDir, { recursive: true, force: true });
}
});
it('installs bundled course skills into the managed opencode config directory before spawning', async () => {
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
const bundledCourseSkillsDir = mkdtempSync(join(tmpdir(), 'niancode-course-skills-source-'));
try {
const skillDir = join(bundledCourseSkillsDir, 'pm-project-plan');
await import('node:fs/promises').then(async ({ mkdir }) => {
await mkdir(skillDir, { recursive: true });
});
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: pm-project-plan\ndescription: Plan projects.\n---\n');
const { children, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4329,
binPath: '/opt/opencode',
userDataDir,
bundledCourseSkillsDir,
runtimeConfigProvider: () => ({ config: {}, env: {} }),
spawn,
});
const startPromise = manager.start();
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4329\n'),
);
await startPromise;
const installedSkillPath = join(
userDataDir,
'opencode',
'niancode-config',
'skills',
'pm-project-plan',
'SKILL.md',
);
expect(existsSync(installedSkillPath)).toBe(true);
expect(readFileSync(installedSkillPath, 'utf8')).toContain('name: pm-project-plan');
} finally {
rmSync(userDataDir, { recursive: true, force: true });
rmSync(bundledCourseSkillsDir, { recursive: true, force: true });
}
});
it('installs the bundled Agent Browser tool plugin before spawning', async () => {
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
const sourceDir = mkdtempSync(join(tmpdir(), 'niancode-agent-browser-plugin-source-'));
const sourcePluginPath = join(sourceDir, 'niancode-agent-browser.js');
try {
writeFileSync(
sourcePluginPath,
'export const NianCodeAgentBrowserPlugin = async () => ({});\n',
);
const { children, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4337,
binPath: '/opt/opencode',
userDataDir,
bundledAgentBrowserPluginPath: sourcePluginPath,
runtimeConfigProvider: () => ({ config: {}, env: {} }),
spawn,
});
const startPromise = manager.start();
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4337\n'),
);
await startPromise;
const installedPluginPath = join(
userDataDir,
'opencode',
'niancode-config',
'plugins',
'niancode-agent-browser.js',
);
expect(existsSync(installedPluginPath)).toBe(true);
expect(readFileSync(installedPluginPath, 'utf8')).toContain(
'NianCodeAgentBrowserPlugin',
);
} finally {
rmSync(userDataDir, { recursive: true, force: true });
rmSync(sourceDir, { recursive: true, force: true });
}
});
it('removes retired bundled skills from the managed config before spawning', async () => {
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
const bundledCourseSkillsDir = mkdtempSync(join(tmpdir(), 'niancode-course-skills-source-'));
const retiredSkillDir = join(userDataDir, 'opencode', 'niancode-config', 'skills', 'student-growth-logger');
const retiredDeploySkillDir = join(userDataDir, 'opencode', 'niancode-config', 'skills', 'deploy-publish-check');
try {
mkdirSync(retiredSkillDir, { recursive: true });
mkdirSync(retiredDeploySkillDir, { recursive: true });
writeFileSync(join(retiredSkillDir, 'SKILL.md'), '---\nname: student-growth-logger\n---\n');
writeFileSync(join(retiredDeploySkillDir, 'SKILL.md'), '---\nname: deploy-publish-check\n---\n');
const { children, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4331,
binPath: '/opt/opencode',
userDataDir,
bundledCourseSkillsDir,
runtimeConfigProvider: () => ({ config: {}, env: {} }),
spawn,
});
const startPromise = manager.start();
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4331\n'),
);
await startPromise;
expect(existsSync(retiredSkillDir)).toBe(false);
expect(existsSync(retiredDeploySkillDir)).toBe(false);
} finally {
rmSync(userDataDir, { recursive: true, force: true });
rmSync(bundledCourseSkillsDir, { recursive: true, force: true });
}
});
it('includes startup stderr in the failed runtime status', async () => {
const { children, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4327,
binPath: '/opt/opencode',
spawn,
});
const startPromise = manager.start();
children[0].stderr.emit('data', Buffer.from('EEXIST: file already exists, mkdir .config/opencode\n'));
children[0].emit('exit', 1);
await expect(startPromise).rejects.toThrow('EEXIST');
expect(manager.getStatus()).toMatchObject({
state: 'error',
port: 4327,
error: expect.stringContaining('EEXIST'),
});
});
it('reports runtime configuration failures instead of remaining starting', async () => {
const manager = new OpencodeManager({
port: 4351,
binPath: '/opt/opencode',
runtimeConfigProvider: async () => {
throw new Error('provider config unavailable');
},
});
await expect(manager.start()).rejects.toThrow('provider config unavailable');
expect(manager.getStatus()).toMatchObject({
state: 'error',
port: 4351,
error: 'provider config unavailable',
});
});
it('reports synchronous spawn failures instead of remaining starting', async () => {
const { spawn } = createSpawnHarness();
spawn.mockImplementationOnce(() => {
throw new Error('spawn failed synchronously');
});
const manager = new OpencodeManager({
port: 4352,
binPath: '/opt/opencode',
spawn,
});
await expect(manager.start()).rejects.toThrow('spawn failed synchronously');
expect(manager.getStatus()).toMatchObject({
state: 'error',
port: 4352,
error: 'spawn failed synchronously',
});
});
it('kills the process on stop and marks the runtime stopped', async () => {
const { children, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4323,
binPath: '/opt/opencode',
spawn,
});
const startPromise = manager.start();
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4323\n'),
);
await startPromise;
const stopPromise = manager.stop();
children[0].emit('exit', 0);
await stopPromise;
expect(children[0].kill).toHaveBeenCalledOnce();
expect(manager.getStatus()).toEqual({ state: 'stopped', port: 4323 });
});
it('rejects stop without reporting stopped when the child exits before its port is released', async () => {
const { children, spawn } = createSpawnHarness();
const port = 4341;
let portHeld = false;
const findPortOwner = vi.fn(async () => portHeld
? { pid: 4242, executablePath: '/opt/opencode' }
: null);
const manager = new OpencodeManager({
port,
binPath: '/opt/opencode',
spawn,
findPortOwner,
});
const startPromise = manager.start();
await vi.waitFor(() => expect(children).toHaveLength(1));
children[0].stdout.emit(
'data',
Buffer.from(`opencode server listening on http://127.0.0.1:${port}\n`),
);
await startPromise;
const emittedStates: string[] = [];
manager.on('status', (status: { state: string }) => emittedStates.push(status.state));
portHeld = true;
vi.useFakeTimers();
try {
const stopPromise = manager.stop();
const stopRejection = expect(stopPromise).rejects.toThrow(
`Timed out waiting for opencode port ${port} to be released`,
);
children[0].emit('exit', 0);
const reportedStoppedBeforeTimeout = emittedStates.includes('stopped')
|| manager.getStatus().state === 'stopped';
await vi.runAllTimersAsync();
await stopRejection;
expect(reportedStoppedBeforeTimeout).toBe(false);
expect(emittedStates).not.toContain('stopped');
expect(manager.getStatus()).toMatchObject({
state: 'error',
port,
error: `Timed out waiting for opencode port ${port} to be released`,
});
} finally {
vi.useRealTimers();
}
});
it('preserves an unexpected-exit port release failure for a later stop', async () => {
const { children, spawn } = createSpawnHarness();
const port = 4354;
let portHeld = false;
const findPortOwner = vi.fn(async () => portHeld ? { pid: 33188 } : null);
const manager = new OpencodeManager({
port,
binPath: '/opt/opencode',
findPortOwner,
spawn,
});
const startPromise = manager.start();
await vi.waitFor(() => expect(children).toHaveLength(1));
children[0].stdout.emit(
'data',
Buffer.from(`opencode server listening on http://127.0.0.1:${port}\n`),
);
await startPromise;
const emittedStates: string[] = [];
manager.on('status', (status: { state: string }) => emittedStates.push(status.state));
portHeld = true;
vi.useFakeTimers();
try {
children[0].emit('exit', 1);
await vi.advanceTimersByTimeAsync(2_000);
expect(manager.getStatus()).toMatchObject({
state: 'error',
port,
error: `Timed out waiting for opencode port ${port} to be released`,
});
await expect(manager.stop()).rejects.toThrow(
`Timed out waiting for opencode port ${port} to be released`,
);
expect(emittedStates).not.toContain('stopped');
expect(manager.getStatus().state).toBe('error');
} finally {
vi.useRealTimers();
}
});
it('preserves a startup-timeout port release failure for a later stop', async () => {
const listener = createHttpServer();
await new Promise<void>((resolve, reject) => {
listener.once('error', reject);
listener.listen({ host: '127.0.0.1', port: 0 }, () => resolve());
});
const port = (listener.address() as AddressInfo).port;
try {
const { children, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port,
binPath: '/opt/opencode',
startupTimeoutMs: 10,
spawn,
});
const startPromise = manager.start();
const startRejection = expect(startPromise).rejects.toThrow(
'Timed out waiting for opencode server after 10ms',
);
children[0].kill.mockImplementationOnce(() => {
setImmediate(() => children[0].emit('exit', 1));
return true;
});
await startRejection;
await expect(manager.stop()).rejects.toThrow(
`Timed out waiting for opencode port ${port} to be released`,
);
expect(manager.getStatus()).toMatchObject({
state: 'error',
port,
error: `Timed out waiting for opencode port ${port} to be released`,
});
} finally {
listener.closeAllConnections();
await new Promise<void>((resolve, reject) => {
listener.close((error) => error ? reject(error) : resolve());
});
}
});
it('does not overwrite a timed-out start process that failed to exit', async () => {
const { children, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4355,
binPath: '/opt/opencode',
startupTimeoutMs: 10,
spawn,
});
await expect(manager.start()).rejects.toThrow(
'Timed out waiting for opencode server after 10ms',
);
expect(children).toHaveLength(1);
const retry = manager.start();
expect(children).toHaveLength(1);
await expect(retry).rejects.toThrow(
`Timed out waiting for opencode process ${children[0].pid} to exit`,
);
expect(spawn).toHaveBeenCalledOnce();
expect(manager.getStatus().state).toBe('error');
}, 10_000);
it('retains a timed-out port across a late old-process exit and dynamic retry', async () => {
const { children, calls, spawn } = createSpawnHarness();
const preferredPort = 4356;
const actualPort = 54724;
let oldPortHeld = false;
const findPortOwner = vi.fn(async (port: number) => (
oldPortHeld && port === preferredPort ? { pid: 33188 } : null
));
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 503 })));
const manager = new OpencodeManager({
port: preferredPort,
binPath: '/opt/opencode',
findPortOwner,
startupTimeoutMs: 200,
spawn,
});
const firstStart = manager.start();
const firstRejection = expect(firstStart).rejects.toThrow(
'Timed out waiting for opencode server after 200ms',
);
await vi.waitFor(() => expect(children).toHaveLength(1));
oldPortHeld = true;
await firstRejection;
children[0].emit('exit', 1);
const retry = manager.start();
await vi.waitFor(() => expect(children).toHaveLength(2));
expect(calls[1].args).toContain('--port=0');
children[1].stdout.emit(
'data',
Buffer.from(`opencode server listening on http://127.0.0.1:${actualPort}\n`),
);
await retry;
const stop = manager.stop();
const stopRejection = expect(stop).rejects.toThrow(
`Timed out waiting for opencode port ${preferredPort} to be released`,
);
children[1].emit('exit', 0);
await stopRejection;
expect(spawn).toHaveBeenCalledTimes(2);
expect(manager.getStatus()).toMatchObject({
state: 'error',
port: preferredPort,
});
}, 10_000);
it('restarts on an ephemeral port after the old process exits but keeps its port occupied', async () => {
const { children, calls, spawn } = createSpawnHarness();
const preferredPort = 4342;
const actualPort = 54722;
let portHeld = false;
const findPortOwner = vi.fn(async (port: number) => (
portHeld && port === preferredPort ? { pid: 4242 } : null
));
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 503 })));
const manager = new OpencodeManager({
port: preferredPort,
binPath: 'C:\\NianCode\\opencode.exe',
spawn,
findPortOwner,
});
const startPromise = manager.start();
await vi.waitFor(() => expect(children).toHaveLength(1));
children[0].stdout.emit(
'data',
Buffer.from(`opencode server listening on http://127.0.0.1:${preferredPort}\n`),
);
await startPromise;
const emittedStates: string[] = [];
manager.on('status', (status: { state: string }) => emittedStates.push(status.state));
portHeld = true;
vi.useFakeTimers();
try {
const restartPromise = manager.restart();
children[0].emit('exit', 0);
await vi.advanceTimersByTimeAsync(2_000);
expect(children).toHaveLength(2);
expect(calls[1].args).toEqual([
'serve',
'--hostname=127.0.0.1',
'--port=0',
]);
children[1].stdout.emit(
'data',
Buffer.from(`opencode server listening on http://127.0.0.1:${actualPort}\n`),
);
await expect(restartPromise).resolves.toMatchObject({
state: 'running',
port: actualPort,
url: `http://127.0.0.1:${actualPort}`,
});
const stopPromise = manager.stop();
const stopRejection = expect(stopPromise).rejects.toThrow(
`Timed out waiting for opencode port ${preferredPort} to be released`,
);
children[1].emit('exit', 0);
await stopRejection;
expect(emittedStates).not.toContain('stopped');
expect(manager.getStatus()).toMatchObject({
state: 'error',
port: preferredPort,
});
} finally {
vi.useRealTimers();
}
});
it('cancels an in-flight start before the child is spawned', async () => {
const { children, spawn } = createSpawnHarness();
let resolveRuntimeConfig!: (value: { config: Record<string, unknown>; env: Record<string, string> }) => void;
const runtimeConfig = new Promise<{ config: Record<string, unknown>; env: Record<string, string> }>((resolve) => {
resolveRuntimeConfig = resolve;
});
const manager = new OpencodeManager({
port: 4343,
binPath: '/opt/opencode',
runtimeConfigProvider: () => runtimeConfig,
spawn,
});
const startPromise = manager.start();
const startRejection = expect(startPromise).rejects.toThrow('opencode startup was stopped');
await vi.waitFor(() => expect(manager.getStatus().state).toBe('starting'));
const stopPromise = manager.stop();
await startRejection;
await stopPromise;
resolveRuntimeConfig({ config: {}, env: {} });
await new Promise<void>((resolve) => setImmediate(resolve));
expect(manager.getStatus()).toEqual({ state: 'stopped', port: 4343 });
expect(children).toHaveLength(0);
expect(spawn).not.toHaveBeenCalled();
});
it('kills a spawned start immediately when stop is requested before listening', async () => {
const { children, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4348,
binPath: '/opt/opencode',
startupTimeoutMs: 10_000,
spawn,
});
const startPromise = manager.start();
const startRejection = expect(startPromise).rejects.toThrow('opencode startup was stopped');
expect(children).toHaveLength(1);
const stopPromise = manager.stop();
expect(children[0].kill).toHaveBeenCalledOnce();
children[0].emit('exit', 0);
await startRejection;
await stopPromise;
expect(manager.getStatus()).toEqual({ state: 'stopped', port: 4348 });
});
it('does not report stopped when a cancelled start leaves its port occupied', async () => {
const { children, spawn } = createSpawnHarness();
const port = 4353;
let portHeld = false;
const findPortOwner = vi.fn(async () => portHeld ? { pid: 33188 } : null);
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 503 })));
const manager = new OpencodeManager({
port,
binPath: '/opt/opencode',
findPortOwner,
startupTimeoutMs: 10_000,
spawn,
});
const startPromise = manager.start();
const startRejection = expect(startPromise).rejects.toThrow('opencode startup was stopped');
await vi.waitFor(() => expect(children).toHaveLength(1));
const emittedStates: string[] = [];
manager.on('status', (status: { state: string }) => emittedStates.push(status.state));
portHeld = true;
vi.useFakeTimers();
try {
const stopPromise = manager.stop();
const stopRejection = expect(stopPromise).rejects.toThrow(
`Timed out waiting for opencode port ${port} to be released`,
);
expect(children[0].kill).toHaveBeenCalledOnce();
children[0].emit('exit', 0);
await vi.advanceTimersByTimeAsync(2_000);
await startRejection;
await stopRejection;
expect(emittedStates).not.toContain('stopped');
expect(manager.getStatus()).toMatchObject({
state: 'error',
port,
error: `Timed out waiting for opencode port ${port} to be released`,
});
} finally {
vi.useRealTimers();
}
});
it('recognizes the listening URL when stdout splits it across chunks', async () => {
const { children, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4344,
binPath: '/opt/opencode',
startupTimeoutMs: 100,
spawn,
});
const startPromise = manager.start();
children[0].stdout.emit('data', Buffer.from('opencode server listening '));
children[0].stdout.emit('data', Buffer.from('on http://127.0.0.1:4344\n'));
await expect(startPromise).resolves.toMatchObject({
state: 'running',
port: 4344,
url: 'http://127.0.0.1:4344',
});
});
it('preserves start-stop-start FIFO order instead of reusing the first start', async () => {
const { children, spawn } = createSpawnHarness();
let resolveRuntimeConfig!: (value: { config: Record<string, unknown>; env: Record<string, string> }) => void;
const runtimeConfig = new Promise<{ config: Record<string, unknown>; env: Record<string, string> }>((resolve) => {
resolveRuntimeConfig = resolve;
});
const manager = new OpencodeManager({
port: 4345,
binPath: '/opt/opencode',
runtimeConfigProvider: () => runtimeConfig,
spawn,
});
const firstStart = manager.start();
const firstStartRejection = expect(firstStart).rejects.toThrow('opencode startup was stopped');
await vi.waitFor(() => expect(manager.getStatus().state).toBe('starting'));
const middleStop = manager.stop();
const finalStart = manager.start();
resolveRuntimeConfig({ config: {}, env: {} });
await firstStartRejection;
await middleStop;
await vi.waitFor(() => expect(children).toHaveLength(1));
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4345\n'),
);
await finalStart;
expect(spawn).toHaveBeenCalledOnce();
expect(manager.getStatus()).toMatchObject({ state: 'running', port: 4345 });
});
it('preserves stop-restart-stop FIFO order instead of reusing the first stop', async () => {
const { children, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4346,
binPath: '/opt/opencode',
spawn,
});
const initialStart = manager.start();
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4346\n'),
);
await initialStart;
const firstStop = manager.stop();
const middleRestart = manager.restart();
const restartRejection = expect(middleRestart).rejects.toThrow('opencode startup was stopped');
const finalStop = manager.stop();
children[0].emit('exit', 0);
await firstStop;
await restartRejection;
await finalStop;
expect(spawn).toHaveBeenCalledOnce();
expect(manager.getStatus()).toEqual({ state: 'stopped', port: 4346 });
});
it('cancels every queued start requested before stop', async () => {
const { children, spawn } = createSpawnHarness();
let resolveRuntimeConfig!: (value: { config: Record<string, unknown>; env: Record<string, string> }) => void;
const runtimeConfig = new Promise<{ config: Record<string, unknown>; env: Record<string, string> }>((resolve) => {
resolveRuntimeConfig = resolve;
});
const manager = new OpencodeManager({
port: 4349,
binPath: '/opt/opencode',
runtimeConfigProvider: () => runtimeConfig,
spawn,
});
const firstStart = manager.start();
const firstRejection = expect(firstStart).rejects.toThrow('opencode startup was stopped');
await vi.waitFor(() => expect(manager.getStatus().state).toBe('starting'));
const secondStart = manager.start();
const secondRejection = expect(secondStart).rejects.toThrow('opencode startup was stopped');
const stop = manager.stop();
await firstRejection;
await secondRejection;
await stop;
resolveRuntimeConfig({ config: {}, env: {} });
await new Promise<void>((resolve) => setImmediate(resolve));
expect(spawn).not.toHaveBeenCalled();
expect(children).toHaveLength(0);
expect(manager.getStatus()).toEqual({ state: 'stopped', port: 4349 });
});
it('cancels older queued starts but lets restart launch its own generation', async () => {
const { children, spawn } = createSpawnHarness();
let resolveFirstConfig!: (value: { config: Record<string, unknown>; env: Record<string, string> }) => void;
const firstConfig = new Promise<{ config: Record<string, unknown>; env: Record<string, string> }>((resolve) => {
resolveFirstConfig = resolve;
});
let configCalls = 0;
const manager = new OpencodeManager({
port: 4350,
binPath: '/opt/opencode',
runtimeConfigProvider: () => {
configCalls += 1;
return configCalls === 1 ? firstConfig : { config: {}, env: {} };
},
spawn,
});
const firstStart = manager.start();
const firstRejection = expect(firstStart).rejects.toThrow('opencode startup was stopped');
await vi.waitFor(() => expect(manager.getStatus().state).toBe('starting'));
const secondStart = manager.start();
const secondRejection = expect(secondStart).rejects.toThrow('opencode startup was stopped');
const restart = manager.restart();
await firstRejection;
await secondRejection;
await vi.waitFor(() => expect(children).toHaveLength(1));
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4350\n'),
);
await restart;
resolveFirstConfig({ config: {}, env: {} });
expect(spawn).toHaveBeenCalledOnce();
expect(manager.getStatus()).toMatchObject({ state: 'running', port: 4350 });
});
it('does not let an old process exit cleanup overwrite a restarted generation', async () => {
const { children, calls, spawn } = createSpawnHarness();
const preferredPort = 4347;
const actualPort = 54725;
let oldPortHeld = false;
const findPortOwner = vi.fn(async (port: number) => (
oldPortHeld && port === preferredPort ? { pid: 33188 } : null
));
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 503 })));
const manager = new OpencodeManager({
port: preferredPort,
binPath: '/opt/opencode',
findPortOwner,
spawn,
});
const initialStart = manager.start();
await vi.waitFor(() => expect(children).toHaveLength(1));
children[0].stdout.emit(
'data',
Buffer.from(`opencode server listening on http://127.0.0.1:${preferredPort}\n`),
);
await initialStart;
vi.useFakeTimers();
try {
const noOpStart = manager.start();
const restart = manager.restart();
oldPortHeld = true;
children[0].emit('exit', 0);
await noOpStart;
await vi.waitFor(() => expect(children).toHaveLength(2));
expect(calls[1].args).toContain('--port=0');
children[1].stdout.emit(
'data',
Buffer.from(`opencode server listening on http://127.0.0.1:${actualPort}\n`),
);
await restart;
await vi.advanceTimersByTimeAsync(2_000);
expect(manager.getStatus()).toMatchObject({
state: 'running',
port: actualPort,
pid: children[1].pid,
});
const emittedStates: string[] = [];
manager.on('status', (status: { state: string }) => emittedStates.push(status.state));
const stop = manager.stop();
const stopRejection = expect(stop).rejects.toThrow(
`Timed out waiting for opencode port ${preferredPort} to be released`,
);
children[1].emit('exit', 0);
await stopRejection;
expect(spawn).toHaveBeenCalledTimes(2);
expect(emittedStates).not.toContain('stopped');
expect(manager.getStatus()).toMatchObject({
state: 'error',
port: preferredPort,
});
} finally {
vi.useRealTimers();
}
});
});