Files
makelore/tests/unit/opencode-manager.test.ts
2026-07-29 17:22:35 +08:00

975 lines
33 KiB
TypeScript

import { EventEmitter } from 'node:events';
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,
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('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('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 originalPath = process.env.PATH ?? '';
try {
const manager = new OpencodeManager({
port: 4325,
binPath: '/opt/opencode',
userDataDir,
pythonRuntime: {
executable: pythonExecutable,
binDir: dirname(pythonExecutable),
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 });
expect(String(calls[0].options.env?.PATH).split(delimiter)[0]).toBe(dirname(pythonExecutable));
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();
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();
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();
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('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('removes retired bundled skills from the managed config before spawning', async () => {
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
const bundledCourseSkillsDir = mkdtempSync(join(tmpdir(), 'niancode-course-skills-source-'));
const retiredSkillDir = join(userDataDir, 'opencode', 'niancode-config', 'skills', 'student-growth-logger');
try {
mkdirSync(retiredSkillDir, { recursive: true });
writeFileSync(join(retiredSkillDir, 'SKILL.md'), '---\nname: student-growth-logger\n---\n');
const { children, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4331,
binPath: '/opt/opencode',
userDataDir,
bundledCourseSkillsDir,
runtimeConfigProvider: () => ({ config: {}, env: {} }),
spawn,
});
const startPromise = manager.start();
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4331\n'),
);
await startPromise;
expect(existsSync(retiredSkillDir)).toBe(false);
} finally {
rmSync(userDataDir, { recursive: true, force: true });
rmSync(bundledCourseSkillsDir, { recursive: true, force: true });
}
});
it('installs bundled course agents into the managed opencode config directory before spawning', async () => {
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
const bundledCourseAgentsDir = mkdtempSync(join(tmpdir(), 'niancode-course-agents-source-'));
try {
writeFileSync(
join(bundledCourseAgentsDir, 'reviewer.md'),
'---\ndescription: Review course stages.\nmode: all\n---\nReview course evidence.\n',
);
const { children, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4333,
binPath: '/opt/opencode',
userDataDir,
bundledCourseAgentsDir,
runtimeConfigProvider: () => ({ config: {}, env: {} }),
spawn,
});
const startPromise = manager.start();
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4333\n'),
);
await startPromise;
const installedAgentPath = join(
userDataDir,
'opencode',
'niancode-config',
'agent',
'reviewer.md',
);
expect(existsSync(installedAgentPath)).toBe(true);
expect(readFileSync(installedAgentPath, 'utf8')).toContain('Review course evidence.');
} finally {
rmSync(userDataDir, { recursive: true, force: true });
rmSync(bundledCourseAgentsDir, { 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('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;
await manager.stop();
expect(children[0].kill).toHaveBeenCalledOnce();
expect(manager.getStatus()).toEqual({ state: 'stopped', port: 4323 });
});
});