test(electrobun): workspace-store + plugin-store unit tests
This commit is contained in:
parent
e75f90407b
commit
c0eca4964a
2 changed files with 448 additions and 0 deletions
191
ui-electrobun/tests/unit/plugin-store.test.ts
Normal file
191
ui-electrobun/tests/unit/plugin-store.test.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
// Tests for Electrobun plugin-store — pure logic.
|
||||||
|
// Uses bun:test. Tests command registry, event bus, and permission validation.
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'bun:test';
|
||||||
|
|
||||||
|
// ── Replicated types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PluginMeta {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
main: string;
|
||||||
|
permissions: string[];
|
||||||
|
allowedOrigins?: string[];
|
||||||
|
maxRuntime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginCommand {
|
||||||
|
pluginId: string;
|
||||||
|
label: string;
|
||||||
|
callback: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Replicated event bus ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type EventHandler = (data: unknown) => void;
|
||||||
|
|
||||||
|
function createEventBus() {
|
||||||
|
const listeners = new Map<string, Set<EventHandler>>();
|
||||||
|
return {
|
||||||
|
on(event: string, handler: EventHandler): void {
|
||||||
|
let set = listeners.get(event);
|
||||||
|
if (!set) { set = new Set(); listeners.set(event, set); }
|
||||||
|
set.add(handler);
|
||||||
|
},
|
||||||
|
off(event: string, handler: EventHandler): void {
|
||||||
|
listeners.get(event)?.delete(handler);
|
||||||
|
},
|
||||||
|
emit(event: string, data: unknown): void {
|
||||||
|
const set = listeners.get(event);
|
||||||
|
if (!set) return;
|
||||||
|
for (const handler of set) {
|
||||||
|
try { handler(data); } catch { /* swallow */ }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
listenerCount(event: string): number {
|
||||||
|
return listeners.get(event)?.size ?? 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Replicated command registry ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createCommandRegistry() {
|
||||||
|
let commands: PluginCommand[] = [];
|
||||||
|
return {
|
||||||
|
add(pluginId: string, label: string, callback: () => void): void {
|
||||||
|
commands = [...commands, { pluginId, label, callback }];
|
||||||
|
},
|
||||||
|
remove(pluginId: string): void {
|
||||||
|
commands = commands.filter(c => c.pluginId !== pluginId);
|
||||||
|
},
|
||||||
|
getAll(): PluginCommand[] { return commands; },
|
||||||
|
getByPlugin(pluginId: string): PluginCommand[] {
|
||||||
|
return commands.filter(c => c.pluginId === pluginId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Permission validation (replicated from plugin-host.ts) ──────────────────
|
||||||
|
|
||||||
|
const VALID_PERMISSIONS = new Set(['palette', 'notifications', 'messages', 'events', 'network']);
|
||||||
|
|
||||||
|
function validatePermissions(perms: string[]): { valid: boolean; invalid: string[] } {
|
||||||
|
const invalid = perms.filter(p => !VALID_PERMISSIONS.has(p));
|
||||||
|
return { valid: invalid.length === 0, invalid };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('command registry', () => {
|
||||||
|
let registry: ReturnType<typeof createCommandRegistry>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registry = createCommandRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts empty', () => {
|
||||||
|
expect(registry.getAll()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add registers a command', () => {
|
||||||
|
registry.add('my-plugin', 'Say Hello', () => {});
|
||||||
|
expect(registry.getAll()).toHaveLength(1);
|
||||||
|
expect(registry.getAll()[0].label).toBe('Say Hello');
|
||||||
|
expect(registry.getAll()[0].pluginId).toBe('my-plugin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove clears commands for a plugin', () => {
|
||||||
|
registry.add('p1', 'Cmd A', () => {});
|
||||||
|
registry.add('p1', 'Cmd B', () => {});
|
||||||
|
registry.add('p2', 'Cmd C', () => {});
|
||||||
|
registry.remove('p1');
|
||||||
|
expect(registry.getAll()).toHaveLength(1);
|
||||||
|
expect(registry.getAll()[0].pluginId).toBe('p2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getByPlugin filters correctly', () => {
|
||||||
|
registry.add('p1', 'A', () => {});
|
||||||
|
registry.add('p2', 'B', () => {});
|
||||||
|
registry.add('p1', 'C', () => {});
|
||||||
|
expect(registry.getByPlugin('p1')).toHaveLength(2);
|
||||||
|
expect(registry.getByPlugin('p2')).toHaveLength(1);
|
||||||
|
expect(registry.getByPlugin('p3')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('event bus', () => {
|
||||||
|
let bus: ReturnType<typeof createEventBus>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bus = createEventBus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on registers listener', () => {
|
||||||
|
bus.on('test', () => {});
|
||||||
|
expect(bus.listenerCount('test')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emit calls registered handlers', () => {
|
||||||
|
let received: unknown = null;
|
||||||
|
bus.on('data', (d) => { received = d; });
|
||||||
|
bus.emit('data', { value: 42 });
|
||||||
|
expect(received).toEqual({ value: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('off removes listener', () => {
|
||||||
|
const handler = () => {};
|
||||||
|
bus.on('event', handler);
|
||||||
|
bus.off('event', handler);
|
||||||
|
expect(bus.listenerCount('event')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emit does not throw for unregistered events', () => {
|
||||||
|
expect(() => bus.emit('nonexistent', null)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handler error is swallowed', () => {
|
||||||
|
bus.on('crash', () => { throw new Error('boom'); });
|
||||||
|
expect(() => bus.emit('crash', null)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('permission validation', () => {
|
||||||
|
it('accepts all valid permissions', () => {
|
||||||
|
const result = validatePermissions(['palette', 'notifications', 'messages', 'events', 'network']);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.invalid).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown permissions', () => {
|
||||||
|
const result = validatePermissions(['palette', 'filesystem', 'exec']);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.invalid).toEqual(['filesystem', 'exec']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts empty permissions list', () => {
|
||||||
|
const result = validatePermissions([]);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('plugin meta validation', () => {
|
||||||
|
it('maxRuntime defaults to 30 seconds', () => {
|
||||||
|
const meta: PluginMeta = {
|
||||||
|
id: 'test', name: 'Test', version: '1.0', description: 'desc',
|
||||||
|
main: 'index.js', permissions: [],
|
||||||
|
};
|
||||||
|
const maxRuntime = (meta.maxRuntime ?? 30) * 1000;
|
||||||
|
expect(maxRuntime).toBe(30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allowedOrigins defaults to empty array', () => {
|
||||||
|
const meta: PluginMeta = {
|
||||||
|
id: 'test', name: 'Test', version: '1.0', description: 'desc',
|
||||||
|
main: 'index.js', permissions: ['network'],
|
||||||
|
};
|
||||||
|
expect(meta.allowedOrigins ?? []).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
257
ui-electrobun/tests/unit/workspace-store.test.ts
Normal file
257
ui-electrobun/tests/unit/workspace-store.test.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
// Tests for Electrobun workspace-store — pure logic.
|
||||||
|
// Uses bun:test. Tests CRUD operations and derived state logic.
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'bun:test';
|
||||||
|
|
||||||
|
// ── Replicated types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cwd: string;
|
||||||
|
accent: string;
|
||||||
|
status: 'running' | 'idle' | 'stalled';
|
||||||
|
costUsd: number;
|
||||||
|
tokens: number;
|
||||||
|
messages: Array<{ id: number; role: string; content: string }>;
|
||||||
|
provider?: string;
|
||||||
|
groupId?: string;
|
||||||
|
cloneOf?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCENTS = [
|
||||||
|
'var(--ctp-mauve)', 'var(--ctp-sapphire)', 'var(--ctp-teal)',
|
||||||
|
'var(--ctp-peach)', 'var(--ctp-pink)', 'var(--ctp-lavender)',
|
||||||
|
'var(--ctp-green)', 'var(--ctp-blue)', 'var(--ctp-flamingo)',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Pure store logic (no runes, no RPC) ──────────────────────────────────────
|
||||||
|
|
||||||
|
function createWorkspaceState() {
|
||||||
|
let projects: Project[] = [];
|
||||||
|
let groups: Group[] = [{ id: 'dev', name: 'Development', icon: '1', position: 0 }];
|
||||||
|
let activeGroupId = 'dev';
|
||||||
|
let previousGroupId: string | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
getProjects: () => projects,
|
||||||
|
getGroups: () => groups,
|
||||||
|
getActiveGroupId: () => activeGroupId,
|
||||||
|
getMountedGroupIds: () => new Set([activeGroupId, ...(previousGroupId ? [previousGroupId] : [])]),
|
||||||
|
getActiveGroup: () => groups.find(g => g.id === activeGroupId) ?? groups[0],
|
||||||
|
getFilteredProjects: () => projects.filter(p => (p.groupId ?? 'dev') === activeGroupId),
|
||||||
|
|
||||||
|
addProject(name: string, cwd: string): void {
|
||||||
|
if (!name.trim() || !cwd.trim()) return;
|
||||||
|
const id = `p-${Date.now()}`;
|
||||||
|
const accent = ACCENTS[projects.length % ACCENTS.length];
|
||||||
|
projects = [...projects, {
|
||||||
|
id, name: name.trim(), cwd: cwd.trim(), accent,
|
||||||
|
status: 'idle', costUsd: 0, tokens: 0, messages: [],
|
||||||
|
provider: 'claude', groupId: activeGroupId,
|
||||||
|
}];
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteProject(projectId: string): void {
|
||||||
|
projects = projects.filter(p => p.id !== projectId);
|
||||||
|
},
|
||||||
|
|
||||||
|
cloneCountForProject(projectId: string): number {
|
||||||
|
return projects.filter(p => p.cloneOf === projectId).length;
|
||||||
|
},
|
||||||
|
|
||||||
|
addGroup(name: string): void {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
const id = `grp-${Date.now()}`;
|
||||||
|
const position = groups.length;
|
||||||
|
groups = [...groups, { id, name: name.trim(), icon: String(position + 1), position }];
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveGroup(id: string): void {
|
||||||
|
if (activeGroupId !== id) previousGroupId = activeGroupId;
|
||||||
|
activeGroupId = id;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTotalCost: () => projects.reduce((s, p) => s + p.costUsd, 0),
|
||||||
|
getTotalTokens: () => projects.reduce((s, p) => s + p.tokens, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('workspace store — project CRUD', () => {
|
||||||
|
let ws: ReturnType<typeof createWorkspaceState>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ws = createWorkspaceState();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with empty projects', () => {
|
||||||
|
expect(ws.getProjects()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addProject creates a project with correct defaults', () => {
|
||||||
|
ws.addProject('My Project', '/home/user/code');
|
||||||
|
const projects = ws.getProjects();
|
||||||
|
expect(projects).toHaveLength(1);
|
||||||
|
expect(projects[0].name).toBe('My Project');
|
||||||
|
expect(projects[0].cwd).toBe('/home/user/code');
|
||||||
|
expect(projects[0].status).toBe('idle');
|
||||||
|
expect(projects[0].provider).toBe('claude');
|
||||||
|
expect(projects[0].groupId).toBe('dev');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addProject trims whitespace', () => {
|
||||||
|
ws.addProject(' padded ', ' /path ');
|
||||||
|
expect(ws.getProjects()[0].name).toBe('padded');
|
||||||
|
expect(ws.getProjects()[0].cwd).toBe('/path');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addProject rejects empty name or cwd', () => {
|
||||||
|
ws.addProject('', '/path');
|
||||||
|
ws.addProject('name', '');
|
||||||
|
ws.addProject(' ', ' ');
|
||||||
|
expect(ws.getProjects()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addProject assigns accent colors cyclically', () => {
|
||||||
|
for (let i = 0; i < ACCENTS.length + 1; i++) {
|
||||||
|
ws.addProject(`P${i}`, `/p${i}`);
|
||||||
|
}
|
||||||
|
const projects = ws.getProjects();
|
||||||
|
expect(projects[0].accent).toBe(ACCENTS[0]);
|
||||||
|
expect(projects[ACCENTS.length].accent).toBe(ACCENTS[0]); // wraps around
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteProject removes the project', () => {
|
||||||
|
ws.addProject('A', '/a');
|
||||||
|
const id = ws.getProjects()[0].id;
|
||||||
|
ws.deleteProject(id);
|
||||||
|
expect(ws.getProjects()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteProject ignores unknown id', () => {
|
||||||
|
ws.addProject('A', '/a');
|
||||||
|
ws.deleteProject('nonexistent');
|
||||||
|
expect(ws.getProjects()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cloneCountForProject counts clones', () => {
|
||||||
|
ws.addProject('Main', '/main');
|
||||||
|
const mainId = ws.getProjects()[0].id;
|
||||||
|
// Manually add clones (addProject doesn't set cloneOf)
|
||||||
|
const projects = ws.getProjects();
|
||||||
|
projects.push({
|
||||||
|
id: 'clone-1', name: 'Clone 1', cwd: '/clone1', accent: ACCENTS[0],
|
||||||
|
status: 'idle', costUsd: 0, tokens: 0, messages: [],
|
||||||
|
cloneOf: mainId,
|
||||||
|
});
|
||||||
|
expect(ws.cloneCountForProject(mainId)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('workspace store — group CRUD', () => {
|
||||||
|
let ws: ReturnType<typeof createWorkspaceState>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ws = createWorkspaceState();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with default Development group', () => {
|
||||||
|
expect(ws.getGroups()).toHaveLength(1);
|
||||||
|
expect(ws.getGroups()[0].name).toBe('Development');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addGroup creates group with incremented position', () => {
|
||||||
|
ws.addGroup('Production');
|
||||||
|
expect(ws.getGroups()).toHaveLength(2);
|
||||||
|
expect(ws.getGroups()[1].name).toBe('Production');
|
||||||
|
expect(ws.getGroups()[1].position).toBe(1);
|
||||||
|
expect(ws.getGroups()[1].icon).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addGroup rejects empty name', () => {
|
||||||
|
ws.addGroup('');
|
||||||
|
ws.addGroup(' ');
|
||||||
|
expect(ws.getGroups()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activeGroup defaults to first group', () => {
|
||||||
|
expect(ws.getActiveGroup().id).toBe('dev');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setActiveGroup changes active and records previous', () => {
|
||||||
|
ws.addGroup('Staging');
|
||||||
|
const stagingId = ws.getGroups()[1].id;
|
||||||
|
ws.setActiveGroup(stagingId);
|
||||||
|
expect(ws.getActiveGroupId()).toBe(stagingId);
|
||||||
|
// mountedGroupIds should include both active and previous
|
||||||
|
expect(ws.getMountedGroupIds().has('dev')).toBe(true);
|
||||||
|
expect(ws.getMountedGroupIds().has(stagingId)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('workspace store — derived state', () => {
|
||||||
|
let ws: ReturnType<typeof createWorkspaceState>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ws = createWorkspaceState();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filteredProjects returns only active group projects', () => {
|
||||||
|
ws.addProject('DevProject', '/dev');
|
||||||
|
ws.addGroup('Staging');
|
||||||
|
const stagingId = ws.getGroups()[1].id;
|
||||||
|
ws.setActiveGroup(stagingId);
|
||||||
|
ws.addProject('StagingProject', '/staging');
|
||||||
|
|
||||||
|
// Switch back to dev
|
||||||
|
ws.setActiveGroup('dev');
|
||||||
|
const filtered = ws.getFilteredProjects();
|
||||||
|
expect(filtered).toHaveLength(1);
|
||||||
|
expect(filtered[0].name).toBe('DevProject');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mountedGroupIds only includes active + previous', () => {
|
||||||
|
ws.addGroup('G1');
|
||||||
|
ws.addGroup('G2');
|
||||||
|
const g1Id = ws.getGroups()[1].id;
|
||||||
|
const g2Id = ws.getGroups()[2].id;
|
||||||
|
|
||||||
|
ws.setActiveGroup(g1Id);
|
||||||
|
ws.setActiveGroup(g2Id);
|
||||||
|
const mounted = ws.getMountedGroupIds();
|
||||||
|
expect(mounted.size).toBe(2);
|
||||||
|
expect(mounted.has(g2Id)).toBe(true); // active
|
||||||
|
expect(mounted.has(g1Id)).toBe(true); // previous
|
||||||
|
expect(mounted.has('dev')).toBe(false); // two switches ago — not mounted
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('workspace store — aggregates', () => {
|
||||||
|
it('getTotalCost sums across projects', () => {
|
||||||
|
const ws = createWorkspaceState();
|
||||||
|
ws.addProject('A', '/a');
|
||||||
|
ws.addProject('B', '/b');
|
||||||
|
// Mutate costs directly
|
||||||
|
ws.getProjects()[0].costUsd = 1.50;
|
||||||
|
ws.getProjects()[1].costUsd = 2.75;
|
||||||
|
expect(ws.getTotalCost()).toBeCloseTo(4.25, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getTotalTokens sums across projects', () => {
|
||||||
|
const ws = createWorkspaceState();
|
||||||
|
ws.addProject('A', '/a');
|
||||||
|
ws.addProject('B', '/b');
|
||||||
|
ws.getProjects()[0].tokens = 10000;
|
||||||
|
ws.getProjects()[1].tokens = 25000;
|
||||||
|
expect(ws.getTotalTokens()).toBe(35000);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue