fix: 修复 OpenCode 运行时启动与本地打包

This commit is contained in:
2026-08-09 00:02:49 +08:00
parent d092132d86
commit a6fe14a8d6
20 changed files with 1847 additions and 154 deletions

View File

@@ -1,4 +1,6 @@
import { EventEmitter } from 'node:events';
import { createServer as createHttpServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import {
existsSync,
mkdirSync,
@@ -134,6 +136,82 @@ describe('OpencodeManager', () => {
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'] } } };
@@ -234,6 +312,7 @@ describe('OpencodeManager', () => {
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({
@@ -245,6 +324,11 @@ describe('OpencodeManager', () => {
binDir: dirname(pythonExecutable),
source: 'bundled',
},
uvRuntime: {
executable: uvExecutable,
binDir: dirname(uvExecutable),
source: 'bundled',
},
spawn,
});
@@ -252,8 +336,16 @@ describe('OpencodeManager', () => {
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 });
expect(String(calls[0].options.env?.PATH).split(delimiter)[0]).toBe(dirname(pythonExecutable));
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 });
@@ -407,6 +499,7 @@ describe('OpencodeManager', () => {
});
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({
@@ -459,6 +552,7 @@ describe('OpencodeManager', () => {
});
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({
@@ -507,6 +601,7 @@ describe('OpencodeManager', () => {
});
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;
@@ -518,6 +613,50 @@ describe('OpencodeManager', () => {
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 {
@@ -972,6 +1111,42 @@ describe('OpencodeManager', () => {
});
});
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({
@@ -987,9 +1162,597 @@ describe('OpencodeManager', () => {
);
await startPromise;
await manager.stop();
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();
}
});
});

View File

@@ -205,6 +205,25 @@ describe('opencode host api routes', () => {
});
});
it('returns the authoritative runtime status after stopping', async () => {
const response = createResponse();
const status = { state: 'stopped', port: 4096 } as const;
const stop = vi.fn(async () => undefined);
const getStatus = vi.fn(() => status);
const handled = await handleOpencodeRoutes(
createRequest('POST'),
response.res,
new URL('http://127.0.0.1/api/opencode/stop'),
{ opencodeManager: { stop, getStatus } } as never,
);
expect(handled).toBe(true);
expect(stop).toHaveBeenCalledOnce();
expect(getStatus).toHaveBeenCalledOnce();
expect(response.json()).toEqual({ success: true, status });
});
it('reports health through the manager', async () => {
const response = createResponse();
const checkHealth = vi.fn(async () => ({

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { resolvePlaywrightMcpServer } from '@electron/opencode/playwright-mcp';
describe('Playwright MCP runtime resolution', () => {
it('fails closed instead of falling back to npx when the local package is missing', () => {
expect(() => resolvePlaywrightMcpServer(() => {
throw new Error('missing package');
})).toThrow('Bundled @playwright/mcp is missing');
});
});

View File

@@ -2,7 +2,12 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { delimiter, dirname, join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { prependPythonToPath, resolvePythonRuntime } from '@electron/utils/python-runtime';
import {
prependManagedRuntimesToPath,
prependPythonToPath,
resolvePythonRuntime,
resolveUvRuntime,
} from '@electron/utils/python-runtime';
const temporaryDirectories: string[] = [];
@@ -54,6 +59,21 @@ describe('managed Python runtime resolution', () => {
})).toEqual({ executable, binDir: dirname(executable), source: 'bundled' });
});
it('does not allow a packaged build to override Python outside the installation', async () => {
const resourcesPath = await temporaryDirectory('niancode-python-packaged-local-');
const executable = await createFile(join(resourcesPath, 'python', 'python.exe'));
const configuredPath = await createFile(join(resourcesPath, 'external', 'python.exe'));
expect(resolvePythonRuntime({
isPackaged: true,
resourcesPath,
appPath: resourcesPath,
platform: 'win32',
arch: 'x64',
configuredPath,
})).toEqual({ executable, binDir: dirname(executable), source: 'bundled' });
});
it('resolves the packaged Unix interpreter', async () => {
const resourcesPath = await temporaryDirectory('niancode-python-packaged-unix-');
const executable = await createFile(join(resourcesPath, 'python', 'bin', 'python3'));
@@ -110,4 +130,45 @@ describe('managed Python runtime resolution', () => {
expect(result.Path).toBe(`${runtime.binDir}${delimiter}existing-bin`);
expect(result).not.toHaveProperty('PATH');
});
it('resolves packaged uv and prepends both managed runtime directories', async () => {
const resourcesPath = await temporaryDirectory('niancode-uv-packaged-');
const uvExecutable = await createFile(join(resourcesPath, 'bin', 'uv.exe'));
const pythonExecutable = await createFile(join(resourcesPath, 'python', 'python.exe'));
const uvRuntime = resolveUvRuntime({
isPackaged: true,
resourcesPath,
appPath: resourcesPath,
platform: 'win32',
arch: 'x64',
});
expect(uvRuntime).toEqual({
executable: uvExecutable,
binDir: dirname(uvExecutable),
source: 'bundled',
});
expect(prependManagedRuntimesToPath(
{ Path: 'C:\\Windows\\System32' },
{ executable: pythonExecutable, binDir: dirname(pythonExecutable), source: 'bundled' },
uvRuntime,
'win32',
)).toEqual({
Path: `${dirname(uvExecutable)}${delimiter}${dirname(pythonExecutable)}${delimiter}C:\\Windows\\System32`,
NIANCODE_PYTHON_PATH: pythonExecutable,
NIANCODE_UV_PATH: uvExecutable,
});
});
it('fails closed when packaged uv is missing', async () => {
const resourcesPath = await temporaryDirectory('niancode-uv-packaged-missing-');
expect(() => resolveUvRuntime({
isPackaged: true,
resourcesPath,
appPath: resourcesPath,
platform: 'win32',
arch: 'x64',
})).toThrow(`Bundled uv runtime is missing: ${join(resourcesPath, 'bin', 'uv.exe')}`);
});
});

View File

@@ -0,0 +1,53 @@
import type { ServerResponse } from 'node:http';
import { describe, expect, it, vi } from 'vitest';
import { setCorsHeaders } from '@electron/api/route-utils';
describe('Host API CORS', () => {
it('allows only the current dynamic OpenCode runtime origin', () => {
const setHeader = vi.fn();
const response = { setHeader } as unknown as ServerResponse;
setCorsHeaders(
response,
'http://127.0.0.1:54721',
'http://127.0.0.1:54721',
);
expect(setHeader).toHaveBeenCalledWith(
'Access-Control-Allow-Origin',
'http://127.0.0.1:54721',
);
});
it('does not allow another loopback port when the runtime uses a dynamic port', () => {
const setHeader = vi.fn();
const response = { setHeader } as unknown as ServerResponse;
setCorsHeaders(
response,
'http://127.0.0.1:54722',
'http://127.0.0.1:54721',
);
expect(setHeader).not.toHaveBeenCalledWith(
'Access-Control-Allow-Origin',
expect.anything(),
);
});
it('does not retain the preferred runtime port after falling back dynamically', () => {
const setHeader = vi.fn();
const response = { setHeader } as unknown as ServerResponse;
setCorsHeaders(
response,
'http://127.0.0.1:4096',
'http://127.0.0.1:54721',
);
expect(setHeader).not.toHaveBeenCalledWith(
'Access-Control-Allow-Origin',
expect.anything(),
);
});
});