Omit unset agent fields on create Delete cleared agent fields in VS Code config updates Cover null field removal with a regression test
278 lines
9.7 KiB
JavaScript
278 lines
9.7 KiB
JavaScript
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
mock.module('vscode', () => ({
|
|
workspace: {
|
|
workspaceFolders: [],
|
|
getConfiguration: () => ({ get: () => undefined }),
|
|
},
|
|
}));
|
|
|
|
const { handleConfigBridgeMessage } = await import('./bridge-config-runtime.ts');
|
|
|
|
const tempRoots = [];
|
|
const originalOpencodeConfig = process.env.OPENCODE_CONFIG;
|
|
|
|
const createCtx = (workingDirectory, restartImpl = async () => undefined) => {
|
|
const restart = mock(restartImpl);
|
|
return {
|
|
restart,
|
|
manager: {
|
|
getWorkingDirectory: () => workingDirectory,
|
|
restart,
|
|
},
|
|
};
|
|
};
|
|
|
|
const deps = {
|
|
readSettings: () => ({}),
|
|
persistSettings: async (changes) => changes,
|
|
readMagicPromptOverrides: () => ({ version: 1, overrides: {} }),
|
|
saveMagicPromptOverride: async () => ({ version: 1, overrides: {} }),
|
|
resetMagicPromptOverride: async () => ({ version: 1, overrides: {} }),
|
|
resetAllMagicPromptOverrides: async () => ({ version: 1, overrides: {} }),
|
|
fetchOpenCodeSkillsFromApi: async () => null,
|
|
clientReloadDelayMs: 800,
|
|
};
|
|
|
|
afterEach(() => {
|
|
if (originalOpencodeConfig === undefined) {
|
|
delete process.env.OPENCODE_CONFIG;
|
|
} else {
|
|
process.env.OPENCODE_CONFIG = originalOpencodeConfig;
|
|
}
|
|
|
|
for (const root of tempRoots.splice(0)) {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
|
|
describe('VS Code config bridge plugin parity', () => {
|
|
test('removes agent fields when update payload sends null', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-agent-null-'));
|
|
tempRoots.push(root);
|
|
const ctx = createCtx(root);
|
|
const configDir = path.join(root, '.opencode');
|
|
const configPath = path.join(configDir, 'opencode.json');
|
|
fs.mkdirSync(configDir, { recursive: true });
|
|
fs.writeFileSync(configPath, JSON.stringify({
|
|
agent: {
|
|
build: {
|
|
variant: 'fast',
|
|
temperature: 0.3,
|
|
top_p: 0.8,
|
|
mode: 'subagent',
|
|
},
|
|
},
|
|
}, null, 2), 'utf8');
|
|
|
|
const updated = await handleConfigBridgeMessage({
|
|
id: 'update-agent-null-fields',
|
|
type: 'api:config/agents',
|
|
payload: {
|
|
method: 'PATCH',
|
|
name: 'build',
|
|
directory: root,
|
|
body: { variant: null, temperature: null, top_p: null },
|
|
},
|
|
}, ctx, deps);
|
|
|
|
expect(updated?.success).toBe(true);
|
|
expect(readJson(configPath).agent.build).toEqual({ mode: 'subagent' });
|
|
});
|
|
|
|
test('creates, lists, updates, and deletes project plugin entries', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-plugins-'));
|
|
tempRoots.push(root);
|
|
const ctx = createCtx(root);
|
|
|
|
const created = await handleConfigBridgeMessage({
|
|
id: 'create',
|
|
type: 'api:config/plugins',
|
|
payload: {
|
|
method: 'POST',
|
|
target: 'entry',
|
|
directory: root,
|
|
body: { scope: 'project', spec: 'plugin-a', options: { enabled: true } },
|
|
},
|
|
}, ctx, deps);
|
|
|
|
expect(created?.success).toBe(true);
|
|
expect(ctx.restart).toHaveBeenCalledTimes(1);
|
|
|
|
const listed = await handleConfigBridgeMessage({
|
|
id: 'list',
|
|
type: 'api:config/plugins',
|
|
payload: { method: 'GET', target: 'list', directory: root },
|
|
}, ctx, deps);
|
|
const entries = listed?.data?.entries || [];
|
|
const entry = entries.find((candidate) => candidate.spec === 'plugin-a');
|
|
expect(entry?.scope).toBe('project');
|
|
|
|
const updated = await handleConfigBridgeMessage({
|
|
id: 'update',
|
|
type: 'api:config/plugins',
|
|
payload: {
|
|
method: 'PATCH',
|
|
target: 'entry',
|
|
directory: root,
|
|
pluginId: entry?.id,
|
|
body: { spec: 'plugin-b' },
|
|
},
|
|
}, ctx, deps);
|
|
expect(updated?.success).toBe(true);
|
|
|
|
const config = JSON.parse(fs.readFileSync(path.join(root, '.opencode', 'opencode.json'), 'utf8'));
|
|
expect(config.plugin).toEqual([['plugin-b', { enabled: true }]]);
|
|
|
|
const relisted = await handleConfigBridgeMessage({
|
|
id: 'relist',
|
|
type: 'api:config/plugins',
|
|
payload: { method: 'GET', target: 'list', directory: root },
|
|
}, ctx, deps);
|
|
const updatedEntry = (relisted?.data?.entries || []).find((candidate) => candidate.spec === 'plugin-b');
|
|
|
|
const deleted = await handleConfigBridgeMessage({
|
|
id: 'delete',
|
|
type: 'api:config/plugins',
|
|
payload: { method: 'DELETE', target: 'entry', directory: root, pluginId: updatedEntry?.id },
|
|
}, ctx, deps);
|
|
expect(deleted?.success).toBe(true);
|
|
});
|
|
|
|
test('creates and reads project plugin files', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-plugin-files-'));
|
|
tempRoots.push(root);
|
|
const ctx = createCtx(root);
|
|
|
|
const created = await handleConfigBridgeMessage({
|
|
id: 'create-file',
|
|
type: 'api:config/plugins',
|
|
payload: {
|
|
method: 'POST',
|
|
target: 'file',
|
|
directory: root,
|
|
body: { scope: 'project', fileName: 'demo-plugin.ts', content: 'export default {}' },
|
|
},
|
|
}, ctx, deps);
|
|
expect(created?.success).toBe(true);
|
|
|
|
const listed = await handleConfigBridgeMessage({
|
|
id: 'list',
|
|
type: 'api:config/plugins',
|
|
payload: { method: 'GET', target: 'list', directory: root },
|
|
}, ctx, deps);
|
|
const files = listed?.data?.files || [];
|
|
const file = files.find((candidate) => candidate.fileName === 'demo-plugin.ts');
|
|
expect(file?.scope).toBe('project');
|
|
|
|
const read = await handleConfigBridgeMessage({
|
|
id: 'read-file',
|
|
type: 'api:config/plugins',
|
|
payload: { method: 'GET', target: 'file', directory: root, pluginId: file?.id },
|
|
}, ctx, deps);
|
|
expect(read?.data).toEqual({ fileName: 'demo-plugin.ts', scope: 'project', content: 'export default {}' });
|
|
});
|
|
|
|
test('updates and deletes user plugin entries from OPENCODE_CONFIG source', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-custom-config-'));
|
|
tempRoots.push(root);
|
|
const configDir = path.join(root, 'custom-config');
|
|
const configPath = path.join(configDir, 'opencode.json');
|
|
fs.mkdirSync(configDir, { recursive: true });
|
|
fs.writeFileSync(configPath, JSON.stringify({ plugin: ['custom-plugin'] }, null, 2), 'utf8');
|
|
process.env.OPENCODE_CONFIG = configPath;
|
|
const ctx = createCtx(root);
|
|
|
|
const listed = await handleConfigBridgeMessage({
|
|
id: 'list-custom',
|
|
type: 'api:config/plugins',
|
|
payload: { method: 'GET', target: 'list', directory: root },
|
|
}, ctx, deps);
|
|
const entry = (listed?.data?.entries || []).find((candidate) => candidate.spec === 'custom-plugin');
|
|
expect(entry?.scope).toBe('user');
|
|
|
|
const updated = await handleConfigBridgeMessage({
|
|
id: 'update-custom',
|
|
type: 'api:config/plugins',
|
|
payload: {
|
|
method: 'PATCH',
|
|
target: 'entry',
|
|
directory: root,
|
|
pluginId: entry?.id,
|
|
body: { spec: 'custom-plugin-next' },
|
|
},
|
|
}, ctx, deps);
|
|
expect(updated?.success).toBe(true);
|
|
expect(readJson(configPath).plugin).toEqual(['custom-plugin-next']);
|
|
|
|
const relisted = await handleConfigBridgeMessage({
|
|
id: 'relist-custom',
|
|
type: 'api:config/plugins',
|
|
payload: { method: 'GET', target: 'list', directory: root },
|
|
}, ctx, deps);
|
|
const updatedEntry = (relisted?.data?.entries || []).find((candidate) => candidate.spec === 'custom-plugin-next');
|
|
|
|
const deleted = await handleConfigBridgeMessage({
|
|
id: 'delete-custom',
|
|
type: 'api:config/plugins',
|
|
payload: { method: 'DELETE', target: 'entry', directory: root, pluginId: updatedEntry?.id },
|
|
}, ctx, deps);
|
|
expect(deleted?.success).toBe(true);
|
|
expect(readJson(configPath).plugin).toBeUndefined();
|
|
});
|
|
|
|
test('writes user plugin files next to OPENCODE_CONFIG', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-custom-files-'));
|
|
tempRoots.push(root);
|
|
const configDir = path.join(root, 'custom-config');
|
|
const configPath = path.join(configDir, 'opencode.json');
|
|
fs.mkdirSync(configDir, { recursive: true });
|
|
fs.writeFileSync(configPath, '{}', 'utf8');
|
|
process.env.OPENCODE_CONFIG = configPath;
|
|
const ctx = createCtx(root);
|
|
|
|
const created = await handleConfigBridgeMessage({
|
|
id: 'create-custom-file',
|
|
type: 'api:config/plugins',
|
|
payload: {
|
|
method: 'POST',
|
|
target: 'file',
|
|
directory: root,
|
|
body: { scope: 'user', fileName: 'demo-plugin.ts', content: 'export default {}' },
|
|
},
|
|
}, ctx, deps);
|
|
|
|
expect(created?.success).toBe(true);
|
|
expect(fs.readFileSync(path.join(configDir, 'plugins', 'demo-plugin.ts'), 'utf8')).toBe('export default {}');
|
|
});
|
|
|
|
test('reports plugin mutation success when restart fails after writing config', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-plugin-restart-'));
|
|
tempRoots.push(root);
|
|
const ctx = createCtx(root, async () => {
|
|
throw new Error('restart failed');
|
|
});
|
|
|
|
const created = await handleConfigBridgeMessage({
|
|
id: 'create-restart-failure',
|
|
type: 'api:config/plugins',
|
|
payload: {
|
|
method: 'POST',
|
|
target: 'entry',
|
|
directory: root,
|
|
body: { scope: 'project', spec: 'plugin-restart' },
|
|
},
|
|
}, ctx, deps);
|
|
|
|
expect(created?.success).toBe(true);
|
|
expect(created?.data).toMatchObject({ success: true, requiresReload: false, reloadFailed: true });
|
|
expect(created?.data?.warning).toContain('restart failed');
|
|
expect(readJson(path.join(root, '.opencode', 'opencode.json')).plugin).toEqual(['plugin-restart']);
|
|
});
|
|
});
|