Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
424 lines
17 KiB
JavaScript
424 lines
17 KiB
JavaScript
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
import express from 'express';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import request from 'supertest';
|
|
|
|
import { registerPluginRoutes } from './plugin-routes.js';
|
|
|
|
let projectDir;
|
|
let userConfigPath;
|
|
let rootDir;
|
|
let plugins;
|
|
let refreshOpenCodeAfterConfigChange;
|
|
let app;
|
|
let cleanupPaths;
|
|
|
|
const testUnlessRoot = typeof process.getuid === 'function' && process.getuid() === 0 ? test.skip : test;
|
|
|
|
function readJson(filePath) {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
}
|
|
|
|
function createApp(overrides = {}) {
|
|
const testApp = express();
|
|
testApp.use(express.json());
|
|
registerPluginRoutes(testApp, {
|
|
resolveOptionalProjectDirectory: async () => ({ directory: projectDir, error: null }),
|
|
refreshOpenCodeAfterConfigChange,
|
|
clientReloadDelayMs: 25,
|
|
listPluginEntries: plugins.listPluginEntries,
|
|
getPluginEntry: plugins.getPluginEntry,
|
|
createPluginEntry: plugins.createPluginEntry,
|
|
updatePluginEntry: plugins.updatePluginEntry,
|
|
deletePluginEntry: plugins.deletePluginEntry,
|
|
listPluginDirFiles: plugins.listPluginDirFiles,
|
|
readPluginDirFile: plugins.readPluginDirFile,
|
|
writePluginDirFile: plugins.writePluginDirFile,
|
|
deletePluginDirFile: plugins.deletePluginDirFile,
|
|
encodePluginId: plugins.encodePluginId,
|
|
decodePluginId: plugins.decodePluginId,
|
|
...overrides,
|
|
});
|
|
return testApp;
|
|
}
|
|
|
|
function createRegistryApp(getNpmInfo) {
|
|
app = createApp({ getNpmInfo });
|
|
return app;
|
|
}
|
|
|
|
async function createEntry(spec = 'a') {
|
|
return request(app)
|
|
.post('/api/config/plugins/entry')
|
|
.send({ spec, scope: 'user' })
|
|
.expect(200);
|
|
}
|
|
|
|
async function createFile(fileName = 'test.js', content = '//x') {
|
|
return request(app)
|
|
.post('/api/config/plugins/file')
|
|
.send({ fileName, content, scope: 'user' })
|
|
.expect(200);
|
|
}
|
|
|
|
describe('opencode plugin routes', () => {
|
|
beforeAll(async () => {
|
|
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-plugin-routes-'));
|
|
userConfigPath = path.join(rootDir, 'user-opencode.json');
|
|
process.env.OPENCODE_CONFIG = userConfigPath;
|
|
plugins = await import('./plugins.js');
|
|
});
|
|
|
|
beforeEach(() => {
|
|
projectDir = fs.mkdtempSync(path.join(rootDir, 'project-'));
|
|
fs.rmSync(userConfigPath, { force: true });
|
|
fs.rmSync(path.join(rootDir, 'plugins'), { recursive: true, force: true });
|
|
refreshOpenCodeAfterConfigChange = mock(async () => undefined);
|
|
cleanupPaths = [];
|
|
app = createApp();
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const target of cleanupPaths) {
|
|
try {
|
|
fs.chmodSync(target, 0o600);
|
|
} catch {
|
|
}
|
|
}
|
|
});
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
delete process.env.OPENCODE_CONFIG;
|
|
});
|
|
|
|
test('GET /api/config/plugins empty returns entries and files arrays', async () => {
|
|
const response = await request(app).get('/api/config/plugins').expect(200);
|
|
|
|
expect(response.body).toEqual({ entries: [], files: [] });
|
|
});
|
|
|
|
test('GET /registry with empty specs returns empty results', async () => {
|
|
const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
|
|
createRegistryApp(getNpmInfo);
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=').expect(200);
|
|
|
|
expect(response.body).toEqual({ results: [] });
|
|
expect(getNpmInfo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('GET /registry reports update for exact npm version behind latest', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=foo@1.0.0').expect(200);
|
|
|
|
expect(response.body.results[0]).toMatchObject({
|
|
kind: 'npm-ok',
|
|
spec: 'foo@1.0.0',
|
|
name: 'foo',
|
|
currentVersion: '1.0.0',
|
|
latestVersion: '2.0.0',
|
|
hasUpdate: true,
|
|
});
|
|
});
|
|
|
|
test('GET /registry reports no update when exact npm version matches latest', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=foo@1.0.0').expect(200);
|
|
|
|
expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', hasUpdate: false, latestVersion: '1.0.0', currentVersion: '1.0.0' });
|
|
});
|
|
|
|
test('GET /registry reports missing exact npm version', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=foo@99.99.99').expect(200);
|
|
|
|
expect(response.body.results[0]).toMatchObject({ kind: 'npm-missing-version', name: 'foo', currentVersion: '99.99.99', latestVersion: '2.0.0' });
|
|
});
|
|
|
|
test('GET /registry reports missing npm package', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: false, status: 404, error: 'Package not found' })));
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=nonexistent@1.0.0').expect(200);
|
|
|
|
expect(response.body.results[0]).toMatchObject({ kind: 'npm-missing-package', spec: 'nonexistent@1.0.0', name: 'nonexistent', error: 'Package not found' });
|
|
});
|
|
|
|
test('GET /registry reports malformed npm spec', async () => {
|
|
const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
|
|
createRegistryApp(getNpmInfo);
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=%40%40malformed').expect(200);
|
|
|
|
expect(response.body.results[0]).toEqual({ kind: 'npm-malformed', spec: '@@malformed', error: 'Spec syntax is malformed' });
|
|
expect(getNpmInfo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('GET /registry reports existing path plugin ok', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));
|
|
const tmpFile = path.join(fs.mkdtempSync(path.join(rootDir, 'plugin-path-')), 'plugin.js');
|
|
fs.writeFileSync(tmpFile, '// plugin', 'utf8');
|
|
|
|
const response = await request(app).get(`/api/config/plugins/registry?specs=${encodeURIComponent(tmpFile)}`).expect(200);
|
|
|
|
expect(response.body.results[0]).toEqual({ kind: 'path-ok', spec: tmpFile, absolutePath: tmpFile });
|
|
});
|
|
|
|
test('GET /registry reports missing path plugin', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=%2Fnonexistent%2F__path%2Fxyz.js').expect(200);
|
|
|
|
expect(response.body.results[0]).toEqual({ kind: 'path-missing', spec: '/nonexistent/__path/xyz.js', absolutePath: '/nonexistent/__path/xyz.js' });
|
|
});
|
|
|
|
test('GET /registry treats Windows absolute paths as local paths', async () => {
|
|
const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
|
|
createRegistryApp(getNpmInfo);
|
|
|
|
const windowsPath = 'C:\\Users\\me\\plugin.js';
|
|
const response = await request(app)
|
|
.get(`/api/config/plugins/registry?specs=${encodeURIComponent(windowsPath)}`)
|
|
.expect(200);
|
|
|
|
expect(response.body.results[0]).toEqual({ kind: 'path-missing', spec: windowsPath, absolutePath: windowsPath });
|
|
expect(getNpmInfo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
testUnlessRoot('GET /registry reports unreadable path plugin', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } })));
|
|
const tmpFile = path.join(fs.mkdtempSync(path.join(rootDir, 'plugin-unreadable-')), 'plugin.js');
|
|
fs.writeFileSync(tmpFile, '// plugin', 'utf8');
|
|
cleanupPaths.push(tmpFile);
|
|
fs.chmodSync(tmpFile, 0);
|
|
|
|
const response = await request(app).get(`/api/config/plugins/registry?specs=${encodeURIComponent(tmpFile)}`).expect(200);
|
|
|
|
expect(response.body.results[0]).toEqual({ kind: 'path-unreadable', spec: tmpFile, absolutePath: tmpFile });
|
|
});
|
|
|
|
test('GET /registry reports npm network failure without failing route', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: false, status: 'network', error: 'socket closed' })));
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=foo@1.0.0').expect(200);
|
|
|
|
expect(response.body.results[0]).toMatchObject({ kind: 'npm-network', spec: 'foo@1.0.0', error: 'socket closed' });
|
|
});
|
|
|
|
test('GET /registry deduplicates npm package lookups by name', async () => {
|
|
const getNpmInfo = mock(async () => ({ ok: true, latest: '3.0.0', versions: ['1', '2', '3'], distTags: { latest: '3.0.0' } }));
|
|
createRegistryApp(getNpmInfo);
|
|
|
|
await request(app).get('/api/config/plugins/registry?specs=foo@1,foo@2,foo@3').expect(200);
|
|
|
|
expect(getNpmInfo).toHaveBeenCalledTimes(1);
|
|
expect(getNpmInfo).toHaveBeenCalledWith('foo', { forceRefresh: false });
|
|
});
|
|
|
|
test('GET /registry forwards refresh true to npm lookup', async () => {
|
|
const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
|
|
createRegistryApp(getNpmInfo);
|
|
|
|
await request(app).get('/api/config/plugins/registry?specs=foo&refresh=true').expect(200);
|
|
|
|
expect(getNpmInfo).toHaveBeenCalledWith('foo', { forceRefresh: true });
|
|
});
|
|
|
|
test('GET /registry rejects more than 100 unique specs', async () => {
|
|
const specs = Array.from({ length: 101 }, (_, index) => `pkg-${index}`).join(',');
|
|
|
|
const response = await request(app).get(`/api/config/plugins/registry?specs=${specs}`).expect(400);
|
|
|
|
expect(response.body).toEqual({ error: 'too many specs' });
|
|
});
|
|
|
|
test('GET /registry reports bare npm name with null current version', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=foo').expect(200);
|
|
|
|
expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', spec: 'foo', name: 'foo', currentVersion: null, hasUpdate: false });
|
|
});
|
|
|
|
test('GET /registry accepts non-exact npm range without missing-version noise', async () => {
|
|
createRegistryApp(mock(async () => ({ ok: true, latest: '2.0.0', versions: ['1.0.0', '2.0.0'], distTags: { latest: '2.0.0' } })));
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=foo@%5E1.0').expect(200);
|
|
|
|
expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', spec: 'foo@^1.0', name: 'foo', currentVersion: '^1.0', hasUpdate: false });
|
|
});
|
|
|
|
test('GET /registry supports scoped npm package specs', async () => {
|
|
const getNpmInfo = mock(async () => ({ ok: true, latest: '1.0.0', versions: ['1.0.0'], distTags: { latest: '1.0.0' } }));
|
|
createRegistryApp(getNpmInfo);
|
|
|
|
const response = await request(app).get('/api/config/plugins/registry?specs=%40scope%2Ffoo%401.0.0').expect(200);
|
|
|
|
expect(getNpmInfo).toHaveBeenCalledWith('@scope/foo', { forceRefresh: false });
|
|
expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', spec: '@scope/foo@1.0.0', name: '@scope/foo' });
|
|
});
|
|
|
|
test('POST /entry creates entry and defers restart', async () => {
|
|
const response = await createEntry('a');
|
|
|
|
expect(response.body).toMatchObject({
|
|
success: true,
|
|
requiresReload: false,
|
|
requiresRestart: true,
|
|
restartDeferred: true,
|
|
message: 'Plugin entry created. Restart OpenCode to apply.',
|
|
});
|
|
expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('GET after POST returns created entry', async () => {
|
|
await createEntry('a');
|
|
|
|
const response = await request(app).get('/api/config/plugins').expect(200);
|
|
|
|
expect(response.body.entries).toEqual([expect.objectContaining({ spec: 'a', scope: 'user' })]);
|
|
});
|
|
|
|
test('POST duplicate entry returns 409', async () => {
|
|
await createEntry('a');
|
|
|
|
const response = await request(app)
|
|
.post('/api/config/plugins/entry')
|
|
.send({ spec: 'a', scope: 'user' })
|
|
.expect(409);
|
|
|
|
expect(response.body.error).toContain('already exists');
|
|
});
|
|
|
|
test('PATCH /entry/:id updates entry in same array index', async () => {
|
|
await createEntry('a');
|
|
const before = await request(app).get('/api/config/plugins').expect(200);
|
|
const id = before.body.entries[0].id;
|
|
|
|
const response = await request(app)
|
|
.patch(`/api/config/plugins/entry/${encodeURIComponent(id)}`)
|
|
.send({ spec: 'b' })
|
|
.expect(200);
|
|
|
|
expect(response.body.success).toBe(true);
|
|
expect(response.body).toMatchObject({
|
|
requiresReload: false,
|
|
requiresRestart: true,
|
|
restartDeferred: true,
|
|
message: 'Plugin entry updated. Restart OpenCode to apply.',
|
|
});
|
|
const after = await request(app).get('/api/config/plugins').expect(200);
|
|
expect(after.body.entries[0]).toEqual(expect.objectContaining({ spec: 'b', scope: 'user' }));
|
|
expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('DELETE /entry/:id removes entry and prunes plugin key', async () => {
|
|
await createEntry('a');
|
|
const listed = await request(app).get('/api/config/plugins').expect(200);
|
|
const id = listed.body.entries[0].id;
|
|
|
|
const response = await request(app).delete(`/api/config/plugins/entry/${encodeURIComponent(id)}`).expect(200);
|
|
|
|
const after = await request(app).get('/api/config/plugins').expect(200);
|
|
expect(after.body.entries).toEqual([]);
|
|
expect(readJson(userConfigPath).plugin).toBeUndefined();
|
|
expect(response.body).toMatchObject({
|
|
success: true,
|
|
requiresReload: false,
|
|
requiresRestart: true,
|
|
restartDeferred: true,
|
|
message: 'Plugin entry deleted. Restart OpenCode to apply.',
|
|
});
|
|
expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('POST /file writes plugin dir file', async () => {
|
|
const response = await createFile('test.js', '//x');
|
|
|
|
expect(response.body).toMatchObject({
|
|
success: true,
|
|
requiresReload: false,
|
|
requiresRestart: true,
|
|
restartDeferred: true,
|
|
message: 'Plugin file created. Restart OpenCode to apply.',
|
|
});
|
|
expect(fs.readFileSync(path.join(rootDir, 'plugins', 'test.js'), 'utf8')).toBe('//x');
|
|
expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('POST duplicate file returns 409', async () => {
|
|
await createFile('test.js', '//x');
|
|
|
|
const response = await request(app)
|
|
.post('/api/config/plugins/file')
|
|
.send({ fileName: 'test.js', content: '//again', scope: 'user' })
|
|
.expect(409);
|
|
|
|
expect(response.body.error).toContain('already exists');
|
|
});
|
|
|
|
test('PUT /file/:id updates file content', async () => {
|
|
await createFile('test.js', '//x');
|
|
const listed = await request(app).get('/api/config/plugins').expect(200);
|
|
const id = listed.body.files[0].id;
|
|
|
|
const response = await request(app)
|
|
.put(`/api/config/plugins/file/${encodeURIComponent(id)}`)
|
|
.send({ content: '//y' })
|
|
.expect(200);
|
|
|
|
expect(fs.readFileSync(path.join(rootDir, 'plugins', 'test.js'), 'utf8')).toBe('//y');
|
|
expect(response.body).toMatchObject({
|
|
success: true,
|
|
requiresReload: false,
|
|
requiresRestart: true,
|
|
restartDeferred: true,
|
|
message: 'Plugin file updated. Restart OpenCode to apply.',
|
|
});
|
|
expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('DELETE /file/:id unlinks file', async () => {
|
|
await createFile('test.js', '//x');
|
|
const listed = await request(app).get('/api/config/plugins').expect(200);
|
|
const id = listed.body.files[0].id;
|
|
|
|
const response = await request(app).delete(`/api/config/plugins/file/${encodeURIComponent(id)}`).expect(200);
|
|
|
|
expect(fs.existsSync(path.join(rootDir, 'plugins', 'test.js'))).toBe(false);
|
|
expect(response.body).toMatchObject({
|
|
success: true,
|
|
requiresReload: false,
|
|
requiresRestart: true,
|
|
restartDeferred: true,
|
|
message: 'Plugin file deleted. Restart OpenCode to apply.',
|
|
});
|
|
expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('PATCH unknown entry id returns 404', async () => {
|
|
const id = plugins.encodePluginId('config', 'user:missing');
|
|
|
|
const response = await request(app)
|
|
.patch(`/api/config/plugins/entry/${encodeURIComponent(id)}`)
|
|
.send({ spec: 'b' })
|
|
.expect(404);
|
|
|
|
expect(response.body.error).toContain('not found');
|
|
});
|
|
|
|
test('POST invalid fileName returns 400', async () => {
|
|
const response = await request(app)
|
|
.post('/api/config/plugins/file')
|
|
.send({ fileName: '../escape.js', content: '//x', scope: 'user' })
|
|
.expect(400);
|
|
|
|
expect(response.body.error).toContain('Plugin file name');
|
|
});
|
|
});
|