Files
openchamber/packages/ui/src/stores/usePluginsStore.test.ts
T
Serhii DziupinandSerhii Dziupin af380081c2 feat(settings): add third-party integrations dashboard
Add a Settings → Integrations page for installing and managing the three
supported OpenCode provider plugins (Claude Code, Command Code, Cursor),
with search, i18n, and plugin-registry status wiring.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
2026-08-14 15:19:25 +00:00

421 lines
16 KiB
TypeScript

import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
const originalFetch = globalThis.fetch;
import type { PluginEntry, PluginFile, RegistryResult } from './usePluginsStore';
const activeProjectPath = '/workspace/project';
const refreshAfterOpenCodeRestartMock = mock(async () => undefined);
const startConfigUpdateMock = mock(() => undefined);
const finishConfigUpdateMock = mock(() => undefined);
mock.module('@/stores/useProjectsStore', () => ({
useProjectsStore: {
getState: () => ({
getActiveProject: () => ({ path: activeProjectPath }),
}),
},
}));
mock.module('@/lib/opencode/client', () => ({
opencodeClient: {
getDirectory: () => '/fallback/project',
},
}));
mock.module('@/stores/useAgentsStore', () => ({
refreshAfterOpenCodeRestart: refreshAfterOpenCodeRestartMock,
}));
mock.module('@/lib/configUpdate', () => ({
startConfigUpdate: startConfigUpdateMock,
finishConfigUpdate: finishConfigUpdateMock,
}));
// mock.module is process-global in bun: another test file (e.g.
// useCommandsStore.test.ts) may have replaced '@/lib/runtime-fetch' with its
// own stub before this file runs. Register our own mock so this suite always
// reaches its fetch double regardless of test file ordering. Delegating to
// globalThis.fetch (instead of this file's double directly) keeps later test
// files that stub global fetch working if this registration outlives us.
mock.module('@/lib/runtime-fetch', () => ({
runtimeFetch: (input: RequestInfo | URL, init?: RequestInit) => globalThis.fetch(input, init),
}));
const { usePluginsStore } = await import('./usePluginsStore');
const entry: PluginEntry = {
id: 'config:user:plugin-a',
spec: 'plugin-a',
scope: 'user',
kind: 'config',
parsedKind: 'npm',
};
const file: PluginFile = {
id: 'file:user:plugin.ts',
fileName: 'plugin.ts',
scope: 'user',
kind: 'file',
};
const pluginListPayload = {
entries: [entry],
files: [file],
};
const okMutationPayload = {
success: true,
requiresReload: false,
message: 'ok',
reloadDelayMs: 800,
reloadFailed: false,
};
const registryOk: RegistryResult = {
kind: 'npm-ok',
spec: 'plugin-a',
name: 'plugin-a',
currentVersion: null,
latestVersion: '1.0.0',
versions: ['1.0.0'],
hasUpdate: false,
};
const jsonResponse = (body: unknown, init?: ResponseInit): Response =>
new Response(JSON.stringify(body), {
status: init?.status ?? 200,
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
});
type FetchCall = {
input: RequestInfo | URL;
init?: RequestInit;
};
const fetchCalls: FetchCall[] = [];
let queuedResponses: Response[] = [];
const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
fetchCalls.push({ input, init });
return queuedResponses.shift() ?? jsonResponse(pluginListPayload);
});
const queueFetchResponses = (responses: Response[]) => {
queuedResponses = [...responses];
};
const resetStore = () => {
usePluginsStore.setState({
entries: [],
files: [],
selectedId: null,
isLoading: false,
registryInfo: {},
isLoadingRegistry: false,
draft: null,
});
};
const registryCalls = (): FetchCall[] => fetchCalls.filter((call) => String(call.input).includes('/api/config/plugins/registry'));
const requestBody = (callIndex: number): unknown => {
const init = fetchCalls[callIndex]?.init;
return init?.body ? JSON.parse(String(init.body)) : undefined;
};
const flushPluginFollowUps = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
};
describe('usePluginsStore', () => {
beforeEach(() => {
resetStore();
fetchCalls.length = 0;
queuedResponses = [];
globalThis.fetch = fetchMock as unknown as typeof fetch;
});
afterAll(() => {
globalThis.fetch = originalFetch;
});
test('loadPlugins calls config plugins endpoint once and populates entries/files', async () => {
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] })]);
const result = await usePluginsStore.getState().loadPlugins();
await flushPluginFollowUps();
expect(result).toBe(true);
expect(fetchCalls).toHaveLength(2);
expect(fetchCalls[0]?.input).toBe('/api/config/plugins?directory=%2Fworkspace%2Fproject');
expect(usePluginsStore.getState().entries).toEqual([entry]);
expect(usePluginsStore.getState().files).toEqual([file]);
expect(usePluginsStore.getState().isLoading).toBe(false);
});
test('second loadPlugins within TTL reuses cached store data', async () => {
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] })]);
await usePluginsStore.getState().loadPlugins();
await usePluginsStore.getState().loadPlugins();
await flushPluginFollowUps();
expect(fetchCalls).toHaveLength(2);
});
test('force loadPlugins bypasses TTL cache', async () => {
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] }), jsonResponse(pluginListPayload)]);
await usePluginsStore.getState().loadPlugins();
await usePluginsStore.getState().loadPlugins({ force: true });
expect(fetchCalls).toHaveLength(3);
});
test('createEntry posts spec and scope in request body', async () => {
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload)]);
const result = await usePluginsStore.getState().createEntry({ spec: 'a', scope: 'user' });
expect(result.ok).toBe(true);
expect(fetchCalls[0]?.input).toBe('/api/config/plugins/entry?directory=%2Fworkspace%2Fproject');
expect(fetchCalls[0]?.init?.method).toBe('POST');
expect(requestBody(0)).toEqual({ spec: 'a', scope: 'user' });
});
test('createEntry includes options when provided', async () => {
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload)]);
await usePluginsStore.getState().createEntry({ spec: 'a', options: { enabled: true }, scope: 'project' });
expect(requestBody(0)).toEqual({ spec: 'a', options: { enabled: true }, scope: 'project' });
});
test('updateEntry patches entry id path', async () => {
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload)]);
const result = await usePluginsStore.getState().updateEntry('entry-id', { spec: 'b' });
expect(result.ok).toBe(true);
expect(fetchCalls[0]?.input).toBe('/api/config/plugins/entry/entry-id?directory=%2Fworkspace%2Fproject');
expect(fetchCalls[0]?.init?.method).toBe('PATCH');
expect(requestBody(0)).toEqual({ spec: 'b' });
});
test('deleteEntry deletes entry id, invalidates cache, reloads, and clears selected id', async () => {
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] }), jsonResponse(okMutationPayload), jsonResponse({ entries: [], files: [file] })]);
await usePluginsStore.getState().loadPlugins();
usePluginsStore.getState().setSelected(entry.id);
const result = await usePluginsStore.getState().deleteEntry(entry.id);
expect(result.ok).toBe(true);
expect(fetchCalls[2]?.input).toBe(`/api/config/plugins/entry/${encodeURIComponent(entry.id)}?directory=%2Fworkspace%2Fproject`);
expect(fetchCalls[2]?.init?.method).toBe('DELETE');
expect(fetchCalls[3]?.input).toBe('/api/config/plugins?directory=%2Fworkspace%2Fproject');
expect(usePluginsStore.getState().entries).toEqual([]);
expect(usePluginsStore.getState().selectedId).toBeNull();
});
test('createFile posts file name, content, and scope', async () => {
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload)]);
const result = await usePluginsStore.getState().createFile({ fileName: 'plugin.ts', content: 'export {}', scope: 'user' });
expect(result.ok).toBe(true);
expect(fetchCalls[0]?.input).toBe('/api/config/plugins/file?directory=%2Fworkspace%2Fproject');
expect(fetchCalls[0]?.init?.method).toBe('POST');
expect(requestBody(0)).toEqual({ fileName: 'plugin.ts', content: 'export {}', scope: 'user' });
});
test('failed mutation returns ok false and leaves plugins unchanged', async () => {
usePluginsStore.setState({ entries: [entry], files: [file] });
queueFetchResponses([jsonResponse({ error: 'boom' }, { status: 500 })]);
const result = await usePluginsStore.getState().createEntry({ spec: 'bad', scope: 'user' });
expect(result).toEqual({ ok: false });
expect(usePluginsStore.getState().entries).toEqual([entry]);
expect(usePluginsStore.getState().files).toEqual([file]);
});
test('getById returns entries and files by id', () => {
usePluginsStore.setState({ entries: [entry], files: [file] });
expect(usePluginsStore.getState().getById(entry.id)).toEqual(entry);
expect(usePluginsStore.getState().getById(file.id)).toEqual(file);
});
test('readFile fetches plugin file content', async () => {
queueFetchResponses([jsonResponse({ fileName: 'plugin.ts', scope: 'user', content: 'export {}' })]);
const result = await usePluginsStore.getState().readFile(file.id);
expect(fetchCalls[0]?.input).toBe(`/api/config/plugins/file/${encodeURIComponent(file.id)}?directory=%2Fworkspace%2Fproject`);
expect(result).toEqual({ fileName: 'plugin.ts', scope: 'user', content: 'export {}' });
});
test('loadRegistryInfo derives specs from entries and stores registry results', async () => {
usePluginsStore.setState({ entries: [{ ...entry, spec: 'foo@1' }] });
queueFetchResponses([
jsonResponse({
results: [{ kind: 'npm-ok', spec: 'foo@1', name: 'foo', currentVersion: '1', latestVersion: '2', hasUpdate: true, versions: ['1', '2'] }],
}),
]);
await usePluginsStore.getState().loadRegistryInfo();
expect(String(fetchCalls[0]?.input)).toContain('specs=foo%401');
expect(usePluginsStore.getState().registryInfo['foo@1']?.kind).toBe('npm-ok');
expect(usePluginsStore.getState().isLoadingRegistry).toBe(false);
});
test('loadRegistryInfo force adds refresh flag', async () => {
queueFetchResponses([jsonResponse({ results: [] })]);
await usePluginsStore.getState().loadRegistryInfo({ specs: ['foo@1'], force: true });
expect(String(fetchCalls[0]?.input)).toContain('refresh=true');
});
test('loadRegistryInfo accepts explicit comma-joined specs', async () => {
queueFetchResponses([jsonResponse({ results: [] })]);
await usePluginsStore.getState().loadRegistryInfo({ specs: ['x@1', 'y@2'] });
expect(String(fetchCalls[0]?.input)).toContain('specs=x%401,y%402');
});
test('loadRegistryInfo skips empty specs and clears loading flag', async () => {
usePluginsStore.setState({ isLoadingRegistry: true });
const result = await usePluginsStore.getState().loadRegistryInfo({ specs: [] });
expect(result).toBe(true);
expect(fetchCalls).toHaveLength(0);
expect(usePluginsStore.getState().isLoadingRegistry).toBe(false);
});
test('loadRegistryInfo reports a failed request without clearing prior registry data', async () => {
usePluginsStore.setState({ registryInfo: { [entry.spec]: registryOk } });
queueFetchResponses([jsonResponse({ error: 'registry unavailable' }, { status: 500 })]);
const result = await usePluginsStore.getState().loadRegistryInfo({ specs: [entry.spec] });
expect(result).toBe(false);
expect(usePluginsStore.getState().registryInfo).toEqual({ [entry.spec]: registryOk });
expect(usePluginsStore.getState().isLoadingRegistry).toBe(false);
});
test('loadPlugins success triggers registry load without blocking result', async () => {
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] })]);
const result = await usePluginsStore.getState().loadPlugins();
await flushPluginFollowUps();
expect(result).toBe(true);
expect(fetchCalls[0]?.input).toBe('/api/config/plugins?directory=%2Fworkspace%2Fproject');
expect(registryCalls()).toHaveLength(1);
});
test('createEntry success refreshes registry for new spec with force', async () => {
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload), jsonResponse({ results: [] })]);
const result = await usePluginsStore.getState().createEntry({ spec: 'new-plugin@1', scope: 'user' });
await flushPluginFollowUps();
expect(result.ok).toBe(true);
expect(registryCalls()).toHaveLength(1);
expect(String(registryCalls()[0]?.input)).toContain('specs=new-plugin%401');
expect(String(registryCalls()[0]?.input)).toContain('refresh=true');
});
test('updateEntry success refreshes changed spec with force', async () => {
usePluginsStore.setState({ entries: [entry] });
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload), jsonResponse({ results: [] })]);
const result = await usePluginsStore.getState().updateEntry(entry.id, { spec: 'plugin-b@2' });
await flushPluginFollowUps();
expect(result.ok).toBe(true);
expect(registryCalls()).toHaveLength(1);
expect(String(registryCalls()[0]?.input)).toContain('specs=plugin-b%402');
expect(String(registryCalls()[0]?.input)).toContain('refresh=true');
});
test('updateEntry success refreshes existing spec when spec unchanged', async () => {
usePluginsStore.setState({ entries: [entry] });
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload), jsonResponse({ results: [] })]);
const result = await usePluginsStore.getState().updateEntry(entry.id, { options: { enabled: true } });
await flushPluginFollowUps();
expect(result.ok).toBe(true);
expect(String(registryCalls()[0]?.input)).toContain('specs=plugin-a');
});
test('deleteEntry success removes deleted spec from registryInfo', async () => {
usePluginsStore.setState({ entries: [entry], registryInfo: { [entry.spec]: registryOk } });
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse({ entries: [], files: [] })]);
const result = await usePluginsStore.getState().deleteEntry(entry.id);
expect(result.ok).toBe(true);
expect(usePluginsStore.getState().registryInfo[entry.spec]).toBe(undefined);
});
test('updateToLatest updates npm-ok entry to latest version', async () => {
usePluginsStore.setState({
entries: [{ ...entry, id: 'X', spec: 'foo@1' }],
registryInfo: {
'foo@1': { kind: 'npm-ok', spec: 'foo@1', name: 'foo', currentVersion: '1', latestVersion: '2', versions: ['1', '2'], hasUpdate: true },
},
});
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload), jsonResponse({ results: [] })]);
const result = await usePluginsStore.getState().updateToLatest('X');
expect(result.ok).toBe(true);
expect(fetchCalls[0]?.input).toBe('/api/config/plugins/entry/X?directory=%2Fworkspace%2Fproject');
expect(requestBody(0)).toEqual({ spec: 'foo@2' });
});
test('updateToLatest returns ok false when hasUpdate is false', async () => {
usePluginsStore.setState({ entries: [entry], registryInfo: { [entry.spec]: registryOk } });
const result = await usePluginsStore.getState().updateToLatest(entry.id);
expect(result).toEqual({ ok: false });
expect(fetchCalls).toHaveLength(0);
});
test('updateToLatest returns ok false for missing package registry result', async () => {
usePluginsStore.setState({
entries: [entry],
registryInfo: { [entry.spec]: { kind: 'npm-missing-package', spec: entry.spec, name: entry.spec, error: 'missing' } },
});
const result = await usePluginsStore.getState().updateToLatest(entry.id);
expect(result).toEqual({ ok: false });
expect(fetchCalls).toHaveLength(0);
});
test('loadRegistryInfo chunks long spec lists into multiple registry requests', async () => {
const entries = Array.from({ length: 50 }, (_, index): PluginEntry => ({
...entry,
id: `config:user:plugin-${index}`,
spec: `plugin-${index}-${'x'.repeat(20)}@1.0.0`,
}));
usePluginsStore.setState({ entries });
queueFetchResponses([jsonResponse({ results: [] }), jsonResponse({ results: [] })]);
await usePluginsStore.getState().loadRegistryInfo();
expect(registryCalls()).toHaveLength(2);
});
});