import { describe, it, expect, beforeEach, vi } from 'vitest' import { ExternalPluginStore } from '../external-plugin-store' vi.mock('../../../app/capabilities', async importOriginal => ({ ...await importOriginal(), areThirdPartyPluginsEnabled: () => true, })) import { PluginRegistry } from '../plugin-registry' import { createTestManifest, createMockLocalStorage } from './test-utils' // Mock URL.createObjectURL / revokeObjectURL for Node environment const blobUrls = new Map() let blobCounter = 0 beforeEach(() => { blobUrls.clear() blobCounter = 0 vi.stubGlobal('URL', { ...URL, createObjectURL: vi.fn((_blob: Blob) => { const url = `blob:test-${blobCounter++}` blobUrls.set(url, 'blob') return url }), revokeObjectURL: vi.fn((url: string) => { blobUrls.delete(url) }), }) }) describe('ExternalPluginStore', () => { let store: ExternalPluginStore let mockStorage: ReturnType beforeEach(() => { mockStorage = createMockLocalStorage() vi.stubGlobal('localStorage', mockStorage) store = new ExternalPluginStore() }) describe('install', () => { it('stores manifest and main.js source', () => { const manifest = createTestManifest({ id: 'ext-plugin' }) store.install(manifest, 'export default class extends Plugin {}', 'https://example.com/plugins/test') expect(store.isExternal('ext-plugin')).toBe(true) expect(store.getAll()).toHaveLength(1) expect(store.getAll()[0].manifest.id).toBe('ext-plugin') }) it('persists to localStorage', () => { const manifest = createTestManifest({ id: 'ext-plugin' }) store.install(manifest, 'export default class {}') const stored = mockStorage._storage['reams:external-plugins'] expect(stored).toBeDefined() const parsed = JSON.parse(stored) expect(parsed['ext-plugin']).toBeDefined() expect(parsed['ext-plugin'].mainJs).toBe('export default class {}') }) it('creates a blob URL for the main.js source', () => { const manifest = createTestManifest({ id: 'ext-plugin' }) store.install(manifest, 'export default class {}') const entry = store.get('ext-plugin') expect(entry).not.toBeNull() expect(entry!.blobUrl).toMatch(/^blob:/) }) it('throws on duplicate plugin ID', () => { const manifest = createTestManifest({ id: 'ext-plugin' }) store.install(manifest, 'export default class {}') expect(() => store.install(manifest, 'other code')).toThrow(/already installed/) }) it('records sourceUrl when provided', () => { const manifest = createTestManifest({ id: 'ext-plugin' }) store.install(manifest, 'code', 'https://example.com/plugin') const entry = store.get('ext-plugin') expect(entry!.sourceUrl).toBe('https://example.com/plugin') }) it('emits change event on install', () => { const cb = vi.fn() store.on('change', cb) const manifest = createTestManifest({ id: 'ext-plugin' }) store.install(manifest, 'code') expect(cb).toHaveBeenCalledTimes(1) }) }) describe('uninstall', () => { it('removes the plugin entry', () => { const manifest = createTestManifest({ id: 'ext-plugin' }) store.install(manifest, 'code') store.uninstall('ext-plugin') expect(store.isExternal('ext-plugin')).toBe(false) expect(store.getAll()).toHaveLength(0) }) it('revokes the blob URL', () => { const manifest = createTestManifest({ id: 'ext-plugin' }) store.install(manifest, 'code') store.uninstall('ext-plugin') expect(URL.revokeObjectURL).toHaveBeenCalled() }) it('removes from localStorage', () => { const manifest = createTestManifest({ id: 'ext-plugin' }) store.install(manifest, 'code') store.uninstall('ext-plugin') const stored = JSON.parse(mockStorage._storage['reams:external-plugins'] ?? '{}') expect(stored['ext-plugin']).toBeUndefined() }) it('is a no-op for unknown plugins', () => { expect(() => store.uninstall('unknown')).not.toThrow() }) it('emits change event on uninstall', () => { const manifest = createTestManifest({ id: 'ext-plugin' }) store.install(manifest, 'code') const cb = vi.fn() store.on('change', cb) store.uninstall('ext-plugin') expect(cb).toHaveBeenCalledTimes(1) }) }) describe('restoreAll', () => { it('re-registers all persisted plugins with a PluginRegistry', () => { const manifest = createTestManifest({ id: 'ext-plugin' }) store.install(manifest, 'export default class {}') // Create a fresh store (simulating page reload) const store2 = new ExternalPluginStore() const registry = new PluginRegistry() store2.restoreAll(registry) expect(registry.has('ext-plugin')).toBe(true) expect(store2.isExternal('ext-plugin')).toBe(true) }) it('creates new blob URLs for restored plugins', () => { const manifest = createTestManifest({ id: 'ext-plugin' }) store.install(manifest, 'code') const originalBlobUrl = store.get('ext-plugin')!.blobUrl const store2 = new ExternalPluginStore() const registry = new PluginRegistry() store2.restoreAll(registry) const restoredBlobUrl = store2.get('ext-plugin')!.blobUrl expect(restoredBlobUrl).toMatch(/^blob:/) // New blob URL should be different from original (different session) expect(restoredBlobUrl).not.toBe(originalBlobUrl) }) it('skips plugins that are already registered (idempotent)', () => { const manifest = createTestManifest({ id: 'ext-plugin' }) store.install(manifest, 'code') const store2 = new ExternalPluginStore() const registry = new PluginRegistry() store2.restoreAll(registry) // Calling again should not throw expect(() => store2.restoreAll(registry)).not.toThrow() }) it('handles corrupted localStorage gracefully', () => { mockStorage._storage['reams:external-plugins'] = 'not valid json' const store2 = new ExternalPluginStore() const registry = new PluginRegistry() expect(() => store2.restoreAll(registry)).not.toThrow() expect(store2.getAll()).toHaveLength(0) }) }) describe('isExternal', () => { it('returns false for unknown plugins', () => { expect(store.isExternal('unknown')).toBe(false) }) it('returns true for installed external plugins', () => { const manifest = createTestManifest({ id: 'ext-plugin' }) store.install(manifest, 'code') expect(store.isExternal('ext-plugin')).toBe(true) }) }) describe('getAll', () => { it('returns all installed external plugins', () => { store.install(createTestManifest({ id: 'a' }), 'code-a') store.install(createTestManifest({ id: 'b' }), 'code-b') const all = store.getAll() expect(all).toHaveLength(2) expect(all.map(e => e.manifest.id).sort()).toEqual(['a', 'b']) }) }) describe('cleanup', () => { it('revokes all blob URLs', () => { store.install(createTestManifest({ id: 'a' }), 'code-a') store.install(createTestManifest({ id: 'b' }), 'code-b') store.cleanup() expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2) }) }) })