Plugin listing still called readConfigFile per layer, so one unparseable project file made GET /api/config/plugins and VS Code listPluginEntries fail. Reuse readConfigLayer isolation and pin comment-only empty parse in the VS Code suite. Co-authored-by: serkraser <serkraser@gmail.com>
195 lines
10 KiB
JavaScript
195 lines
10 KiB
JavaScript
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
|
|
let rootDir;
|
|
let projectDir;
|
|
let userConfigPath;
|
|
let plugins;
|
|
|
|
function thrownBy(fn) {
|
|
try {
|
|
fn();
|
|
} catch (error) {
|
|
return error;
|
|
}
|
|
throw new Error('Expected function to throw');
|
|
}
|
|
|
|
function writeJson(filePath, data) {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
|
|
}
|
|
|
|
function readJson(filePath) {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
}
|
|
|
|
describe('opencode plugins data layer', () => {
|
|
beforeAll(async () => {
|
|
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-plugins-'));
|
|
userConfigPath = path.join(rootDir, 'user-opencode.json');
|
|
process.env.OPENCODE_CONFIG = userConfigPath;
|
|
plugins = await import('./plugins.js');
|
|
});
|
|
|
|
beforeEach(() => {
|
|
process.env.OPENCODE_CONFIG = userConfigPath;
|
|
projectDir = fs.mkdtempSync(path.join(rootDir, 'project-'));
|
|
fs.rmSync(userConfigPath, { force: true });
|
|
});
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
delete process.env.OPENCODE_CONFIG;
|
|
});
|
|
|
|
test('parses raw plugin entries', () => {
|
|
expect(plugins.parsePluginRaw('foo')).toEqual({ spec: 'foo' });
|
|
expect(plugins.parsePluginRaw('foo@1.0.0')).toEqual({ spec: 'foo@1.0.0' });
|
|
expect(plugins.parsePluginRaw(['foo', { a: 1 }])).toEqual({ spec: 'foo', options: { a: 1 } });
|
|
expect(plugins.parsePluginRaw(['foo', {}])).toEqual({ spec: 'foo', options: {} });
|
|
expect(() => plugins.parsePluginRaw(123)).toThrow('Plugin spec');
|
|
});
|
|
|
|
test('serializes plugin entries', () => {
|
|
expect(plugins.serializePluginEntry({ spec: 'foo' })).toBe('foo');
|
|
expect(plugins.serializePluginEntry({ spec: 'foo', options: undefined })).toBe('foo');
|
|
expect(plugins.serializePluginEntry({ spec: 'foo', options: {} })).toBe('foo');
|
|
expect(plugins.serializePluginEntry({ spec: 'foo', options: { a: 1 } })).toEqual(['foo', { a: 1 }]);
|
|
});
|
|
|
|
test('rejects invalid specs and file names', () => {
|
|
expect(() => plugins.createPluginEntry({ spec: 123, scope: 'user' }, projectDir)).toThrow('Plugin spec');
|
|
expect(() => plugins.writePluginDirFile({ fileName: '', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
|
|
expect(() => plugins.writePluginDirFile({ fileName: '../bad.js', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
|
|
expect(() => plugins.writePluginDirFile({ fileName: 'a/b.js', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
|
|
expect(() => plugins.writePluginDirFile({ fileName: 'A.js', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
|
|
expect(() => plugins.writePluginDirFile({ fileName: 'foo.txt', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
|
|
});
|
|
|
|
test('creates string and tuple entries with duplicate rejection', () => {
|
|
plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir);
|
|
plugins.createPluginEntry({ spec: 'tuple-plugin', options: { apiKey: 'x' }, scope: 'user' }, projectDir);
|
|
|
|
expect(readJson(userConfigPath).plugin).toEqual(['plain-plugin', ['tuple-plugin', { apiKey: 'x' }]]);
|
|
expect(() => plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir)).toThrow('already exists');
|
|
expect(thrownBy(() => plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir))).toHaveProperty('code', 'ENTRY_EXISTS');
|
|
});
|
|
|
|
test('routes project entries to project config and user entries to custom user config', () => {
|
|
plugins.createPluginEntry({ spec: 'user-plugin', scope: 'user' }, projectDir);
|
|
plugins.createPluginEntry({ spec: 'project-plugin', scope: 'project' }, projectDir);
|
|
|
|
expect(readJson(userConfigPath).plugin).toEqual(['user-plugin']);
|
|
expect(readJson(path.join(projectDir, '.opencode', 'opencode.json')).plugin).toEqual(['project-plugin']);
|
|
});
|
|
|
|
test('re-resolves custom config env between calls', () => {
|
|
const firstConfigPath = path.join(rootDir, 'first', 'opencode.json');
|
|
const secondConfigPath = path.join(rootDir, 'second', 'opencode.json');
|
|
|
|
process.env.OPENCODE_CONFIG = firstConfigPath;
|
|
plugins.createPluginEntry({ spec: 'first-plugin', scope: 'user' }, projectDir);
|
|
plugins.writePluginDirFile({ fileName: 'first.js', content: 'one', scope: 'user' }, projectDir);
|
|
|
|
process.env.OPENCODE_CONFIG = secondConfigPath;
|
|
plugins.createPluginEntry({ spec: 'second-plugin', scope: 'user' }, projectDir);
|
|
plugins.writePluginDirFile({ fileName: 'second.js', content: 'two', scope: 'user' }, projectDir);
|
|
|
|
expect(readJson(firstConfigPath).plugin).toEqual(['first-plugin']);
|
|
expect(readJson(secondConfigPath).plugin).toEqual(['second-plugin']);
|
|
expect(fs.existsSync(path.join(path.dirname(firstConfigPath), 'plugins', 'first.js'))).toBe(true);
|
|
expect(fs.existsSync(path.join(path.dirname(secondConfigPath), 'plugins', 'second.js'))).toBe(true);
|
|
});
|
|
|
|
test('updates entries in place and transitions between string and tuple', () => {
|
|
writeJson(userConfigPath, { plugin: ['first', ['second', { a: 1 }], 'third'] });
|
|
|
|
plugins.updatePluginEntry(plugins.encodePluginId('config', 'user:second'), { spec: 'second-new', options: {} }, projectDir);
|
|
expect(readJson(userConfigPath).plugin).toEqual(['first', 'second-new', 'third']);
|
|
|
|
plugins.updatePluginEntry(plugins.encodePluginId('config', 'user:first'), { spec: 'first-new', options: { b: 2 } }, projectDir);
|
|
expect(readJson(userConfigPath).plugin).toEqual([['first-new', { b: 2 }], 'second-new', 'third']);
|
|
});
|
|
|
|
test('deletes entries and prunes empty plugin key', () => {
|
|
writeJson(userConfigPath, { plugin: ['only'] });
|
|
|
|
plugins.deletePluginEntry(plugins.encodePluginId('config', 'user:only'), projectDir);
|
|
expect(readJson(userConfigPath)).toEqual({});
|
|
});
|
|
|
|
test('lists user plugins when a project layer is unparseable', () => {
|
|
const partialProject = [
|
|
'{',
|
|
' "$schema": "https://opencode.ai/config.json",',
|
|
' plugin: ["broken-project-plugin"],',
|
|
'}',
|
|
'',
|
|
].join('\n');
|
|
writeJson(userConfigPath, { plugin: ['user-plugin'] });
|
|
const projectFile = path.join(projectDir, '.opencode', 'opencode.jsonc');
|
|
fs.mkdirSync(path.dirname(projectFile), { recursive: true });
|
|
fs.writeFileSync(projectFile, partialProject, 'utf8');
|
|
|
|
expect(plugins.listPluginEntries(projectDir).map((entry) => entry.spec)).toEqual(['user-plugin']);
|
|
expect(fs.readFileSync(projectFile, 'utf8')).toBe(partialProject);
|
|
expect(fs.existsSync(`${projectFile}.openchamber.backup`)).toBe(false);
|
|
});
|
|
|
|
test('lists entries from user and project layers with scopes and parsed kinds', () => {
|
|
writeJson(userConfigPath, { plugin: ['npm-plugin', '/abs/plugin.js', '@scope/pkg@1.0.0'] });
|
|
writeJson(path.join(projectDir, '.opencode', 'opencode.json'), { plugin: ['./local-plugin.js'] });
|
|
|
|
const entries = plugins.listPluginEntries(projectDir);
|
|
expect(entries).toEqual([
|
|
expect.objectContaining({ spec: 'npm-plugin', scope: 'user', kind: 'config', parsedKind: 'npm', sourcePath: userConfigPath }),
|
|
expect.objectContaining({ spec: '/abs/plugin.js', scope: 'user', kind: 'config', parsedKind: 'path', sourcePath: userConfigPath }),
|
|
expect.objectContaining({ spec: '@scope/pkg@1.0.0', scope: 'user', kind: 'config', parsedKind: 'npm', sourcePath: userConfigPath }),
|
|
expect.objectContaining({ spec: './local-plugin.js', scope: 'project', kind: 'config', parsedKind: 'path', sourcePath: path.join(projectDir, '.opencode', 'opencode.json') }),
|
|
]);
|
|
fs.rmSync(userConfigPath, { force: true });
|
|
expect(plugins.listPluginEntries(projectDir)).toEqual([
|
|
expect.objectContaining({ spec: './local-plugin.js', scope: 'project' }),
|
|
]);
|
|
});
|
|
|
|
test('encodes and decodes ids', () => {
|
|
const id = plugins.encodePluginId('config', 'user:oh-my-openagent@4.3.0');
|
|
expect(plugins.decodePluginId(id)).toEqual({ prefix: 'config', value: 'user:oh-my-openagent@4.3.0' });
|
|
});
|
|
|
|
test('round-trips plugin dir files', () => {
|
|
plugins.writePluginDirFile({ fileName: 'my-plugin.ts', content: 'export default {}', scope: 'project' }, projectDir);
|
|
const file = plugins.listPluginDirFiles(projectDir).find((candidate) => candidate.fileName === 'my-plugin.ts');
|
|
|
|
expect(file).toEqual(expect.objectContaining({ fileName: 'my-plugin.ts', scope: 'project', kind: 'file' }));
|
|
expect(plugins.readPluginDirFile(file.id, projectDir)).toEqual({ fileName: 'my-plugin.ts', scope: 'project', content: 'export default {}' });
|
|
plugins.deletePluginDirFile(file.id, projectDir);
|
|
expect(plugins.listPluginDirFiles(projectDir).filter((candidate) => candidate.scope === 'project')).toEqual([]);
|
|
expect(() => plugins.deletePluginDirFile(file.id, projectDir)).toThrow('not found');
|
|
});
|
|
|
|
test('rejects duplicate plugin dir files unless overwrite is true', () => {
|
|
plugins.writePluginDirFile({ fileName: 'dup.js', content: 'one', scope: 'project' }, projectDir);
|
|
expect(() => plugins.writePluginDirFile({ fileName: 'dup.js', content: 'two', scope: 'project' }, projectDir)).toThrow('already exists');
|
|
expect(thrownBy(() => plugins.writePluginDirFile({ fileName: 'dup.js', content: 'two', scope: 'project' }, projectDir))).toHaveProperty('code', 'FILE_EXISTS');
|
|
|
|
plugins.writePluginDirFile({ fileName: 'dup.js', content: 'two', scope: 'project' }, projectDir, { overwrite: true });
|
|
expect(fs.readFileSync(path.join(projectDir, '.opencode', 'plugins', 'dup.js'), 'utf8')).toBe('two');
|
|
});
|
|
|
|
test('lists only valid plugin dir files', () => {
|
|
const dir = path.join(projectDir, '.opencode', 'plugins');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'valid.mjs'), '', 'utf8');
|
|
fs.writeFileSync(path.join(dir, 'README.md'), '', 'utf8');
|
|
|
|
expect(plugins.listPluginDirFiles(projectDir).filter((file) => file.scope === 'project')).toEqual([
|
|
expect.objectContaining({ fileName: 'valid.mjs', scope: 'project' }),
|
|
]);
|
|
});
|
|
});
|