diff --git a/ui-electrobun/tests/unit/plugin-store.test.ts b/ui-electrobun/tests/unit/plugin-store.test.ts new file mode 100644 index 0000000..b92aae4 --- /dev/null +++ b/ui-electrobun/tests/unit/plugin-store.test.ts @@ -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>(); + 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; + + 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; + + 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([]); + }); +}); diff --git a/ui-electrobun/tests/unit/workspace-store.test.ts b/ui-electrobun/tests/unit/workspace-store.test.ts new file mode 100644 index 0000000..be80662 --- /dev/null +++ b/ui-electrobun/tests/unit/workspace-store.test.ts @@ -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; + + 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; + + 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; + + 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); + }); +});