fix: 修复 OpenCode 运行时启动与本地打包
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
10
tests/unit/playwright-mcp.test.ts
Normal file
10
tests/unit/playwright-mcp.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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')}`);
|
||||
});
|
||||
});
|
||||
|
||||
53
tests/unit/route-utils.test.ts
Normal file
53
tests/unit/route-utils.test.ts
Normal 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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user