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((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((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'); try { mkdirSync(retiredSkillDir, { recursive: true }); writeFileSync(join(retiredSkillDir, 'SKILL.md'), '---\nname: student-growth-logger\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); } 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((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((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; env: Record }) => void; const runtimeConfig = new Promise<{ config: Record; env: Record }>((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((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; env: Record }) => void; const runtimeConfig = new Promise<{ config: Record; env: Record }>((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; env: Record }) => void; const runtimeConfig = new Promise<{ config: Record; env: Record }>((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((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; env: Record }) => void; const firstConfig = new Promise<{ config: Record; env: Record }>((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(); } }); });