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