Files
openchamber/packages/web/server/lib/opencode/plugin-routes.test.js
T
Quat3rnionandBohdan Triapitsyn 2b47d899c6 feat: plugin settings (#1375)
* feat(settings): add opencode plugins page

Manage opencode `plugin` array entries (npm, scoped npm, versioned,
local paths) and auto-loaded plugin files in `~/.config/opencode/plugins/`
and `<project>/.opencode/plugins/`. Mirrors MCP CRUD pattern.

- Server: `plugins.js` data layer + `plugin-routes.js` REST routes
- UI: PluginsSidebar / PluginsPage / AddPluginDialog
- Store: usePluginsStore (cache TTL, in-flight dedup, narrow selectors)
- i18n: 41 keys across 7 locales

Whitelist /api/config/plugins in JSON body-parser so POST/PATCH bodies
parse; opencode plugin specs runtime-resolve OPENCODE_CONFIG dir so
parallel test files do not cross-pollute module-frozen consts.

* feat(settings/plugins): hook npm registry for update + invalid-version detection

Plugins page now consults registry.npmjs.org with a 1h server cache. Sidebar
rows show an update badge with the latest version, group headers show how
many updates are available, the kebab adds an "Update to latest" action
that reuses the existing PATCH+restart flow, and the editor surfaces a
banner for update-available / missing-version / missing-package / malformed
/ missing-path / unreadable-path / offline-registry states. A refresh
button in the sidebar header forces a cache bypass.

- Server: `npm-registry.js` (cache + in-flight dedup + 5s timeout, 404
  cached, network failures NOT cached) + `plugin-spec.js` (parser + exact
  semver detection) + `GET /api/config/plugins/registry?specs=...&refresh=`
- Routes accept up to 100 specs/request, dedup by npm package name before
  fetching, classify each result by kind, never propagate network failure
  as 500.
- Client: `registryInfo` slice + `loadRegistryInfo` (fire-and-forget after
  loadPlugins, refreshes on mutations) + `updateToLatest(id)`.
- UI: `RegistryBadge` per-row + `RegistryBanner` per-entry editor, both
  use theme tokens (text-only color, no new bg/border tokens) and the
  shared Icon sprite. Per-spec subscriptions only.
- i18n: 24 new keys (incl. split singular/plural for "N update(s)
  available" because the runtime does not parse ICU plural format).

* fix(settings/plugins): keep registry badge visible for long specs

Sidebar entry row used `inline-flex` with `truncate` only on the spec
text. With long npm specs the badge could be pushed past the row edge
and clipped by the parent overflow. Switch to `flex` with spec
`flex-1 min-w-0 truncate` and add `shrink-0` to the badge wrapper so
the update indicator stays anchored to the right of the row.

* fix(settings/plugins): use code-box icon to distinguish from MCP

Plugins nav entry used 'plug' which is visually too close to MCP's
'plug-2' icon. Swap to 'code-box' for clearer differentiation in the
Settings nav list.

* Update packages/ui/src/components/sections/plugins/PluginsPage.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Signed-off-by: Quat3rnion <81202811+Quat3rnion@users.noreply.github.com>

* Update packages/ui/src/stores/usePluginsStore.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Signed-off-by: Quat3rnion <81202811+Quat3rnion@users.noreply.github.com>

* fix(settings/plugins): validate registry directory + surface save errors

- registry endpoint: return 400 on invalid directory query (was silently falling back to homedir, breaking relative path specs)
- save failure toast: prefer result.message over generic 'Reload failed'

* fix(settings/plugins): address review follow-ups

---------

Signed-off-by: Quat3rnion <81202811+Quat3rnion@users.noreply.github.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-05-25 19:20:04 +03:00

385 lines
16 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 requires reload', async () => {
const response = await createEntry('a');
expect(response.body).toMatchObject({ success: true, requiresReload: true, reloadDelayMs: 25 });
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry creation');
});
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);
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).toHaveBeenCalledWith('plugin entry update');
});
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;
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(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry deletion');
});
test('POST /file writes plugin dir file', async () => {
const response = await createFile('test.js', '//x');
expect(response.body).toMatchObject({ success: true, requiresReload: true });
expect(fs.readFileSync(path.join(rootDir, 'plugins', 'test.js'), 'utf8')).toBe('//x');
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file creation');
});
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;
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(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file update');
});
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;
await request(app).delete(`/api/config/plugins/file/${encodeURIComponent(id)}`).expect(200);
expect(fs.existsSync(path.join(rootDir, 'plugins', 'test.js'))).toBe(false);
expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file deletion');
});
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');
});
});