import { describe, it, expect, vi, beforeEach } from 'vitest' import { PluginLoader } from '../plugin-loader' vi.mock('../../../app/capabilities', async importOriginal => ({ ...await importOriginal(), areThirdPartyPluginsEnabled: () => true, })) import { PluginRegistry } from '../plugin-registry' import { PluginSettingsStore } from '../plugin-settings-store' import { EnabledStateStore } from '../enabled-state-store' import { createTestApp, createTestManifest, createMockLocalStorage, createMockPluginClass, createMockImporter, } from './test-utils' import type { App } from '../../app' import type { ModuleImporter } from '../types' describe('Error Isolation', () => { let app: App let registry: PluginRegistry let settingsStore: PluginSettingsStore let enabledStore: EnabledStateStore beforeEach(async () => { const mockLocalStorage = createMockLocalStorage() vi.stubGlobal('localStorage', mockLocalStorage) app = await createTestApp() registry = new PluginRegistry() settingsStore = new PluginSettingsStore(app.reamsStateData!) enabledStore = new EnabledStateStore() }) describe('import errors', () => { it('captures import error in plugin record', async () => { const failingImporter: ModuleImporter = async () => { throw new Error('Module not found') } const loader = new PluginLoader(app, registry, settingsStore, enabledStore, failingImporter) registry.register(createTestManifest({ id: 'bad-plugin' }), '/bad.js') await expect(loader.loadPlugin('bad-plugin')).rejects.toThrow() const error = loader.getPluginError('bad-plugin') expect(error).not.toBeNull() expect(error?.message).toBeTruthy() }) it('allows retry after import error with fresh loader', async () => { const failingImporter: ModuleImporter = async () => { throw new Error('Import failed') } const loader1 = new PluginLoader(app, registry, settingsStore, enabledStore, failingImporter) registry.register(createTestManifest({ id: 'retry-plugin' }), '/test.js') await expect(loader1.loadPlugin('retry-plugin')).rejects.toThrow() const MockPlugin = await createMockPluginClass() const workingImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) const loader2 = new PluginLoader(app, registry, settingsStore, enabledStore, workingImporter) await loader2.loadPlugin('retry-plugin') expect(loader2.getPluginState('retry-plugin')).toBe('loaded') }) }) describe('onload errors', () => { it('captures onload error and sets error state', async () => { const MockPlugin = await createMockPluginClass({ onload: () => { throw new Error('Initialization failed') } }) const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) const loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'crash-plugin' }), '/test.js') await expect(loader.enablePlugin('crash-plugin')).rejects.toThrow('Initialization failed') expect(loader.getPluginState('crash-plugin')).toBe('error') expect(loader.getPluginError('crash-plugin')?.message).toBe('Initialization failed') }) it('emits plugin-error event on onload failure', async () => { const errorCallback = vi.fn() const MockPlugin = await createMockPluginClass({ onload: () => { throw new Error('Test error') } }) const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) const loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) loader.on('plugin-error', errorCallback) registry.register(createTestManifest({ id: 'error-plugin' }), '/test.js') await loader.enablePlugin('error-plugin').catch(() => {}) expect(errorCallback).toHaveBeenCalledWith('error-plugin', expect.any(Error)) }) }) describe('multiple plugin isolation', () => { it('one plugin failing does not affect others', async () => { let goodPluginLoaded = false const GoodPlugin = await createMockPluginClass({ onload: () => { goodPluginLoaded = true } }) const BadPlugin = await createMockPluginClass({ onload: () => { throw new Error('I crash') } }) const mockImporter = createMockImporter(new Map([ ['/good.js', GoodPlugin], ['/bad.js', BadPlugin], ])) const loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'good-plugin' }), '/good.js') registry.register(createTestManifest({ id: 'bad-plugin' }), '/bad.js') await loader.enablePlugin('good-plugin') expect(goodPluginLoaded).toBe(true) await loader.enablePlugin('bad-plugin').catch(() => {}) expect(loader.getPluginState('good-plugin')).toBe('active') expect(loader.getPluginState('bad-plugin')).toBe('error') }) }) describe('onunload error handling', () => { it('completes disable even if onunload throws', async () => { const MockPlugin = await createMockPluginClass({ onunload: () => { throw new Error('Cleanup failed') } }) const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) const loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'cleanup-fail' }), '/test.js') await loader.enablePlugin('cleanup-fail') expect(() => loader.disablePlugin('cleanup-fail')).not.toThrow() expect(loader.getPluginState('cleanup-fail')).toBe('disabled') expect(loader.getPlugin('cleanup-fail')).toBe(null) }) }) describe('malformed module handling', () => { it('reports clear error for module without default export', async () => { const malformedImporter: ModuleImporter = async () => { return {} as any // Module with no default export } const loader = new PluginLoader(app, registry, settingsStore, enabledStore, malformedImporter) registry.register(createTestManifest({ id: 'malformed-plugin' }), '/malformed.js') await expect(loader.loadPlugin('malformed-plugin')).rejects.toThrow(/default export/) expect(loader.getPluginState('malformed-plugin')).toBe('error') expect(loader.getPluginError('malformed-plugin')?.message).toContain('default export') }) it('reports clear error when default export is not a Plugin subclass', async () => { // A class that is not a Plugin class NotAPlugin { constructor() {} onload() {} } const badImporter: ModuleImporter = async () => { return { default: NotAPlugin } as any } const loader = new PluginLoader(app, registry, settingsStore, enabledStore, badImporter) registry.register(createTestManifest({ id: 'not-a-plugin' }), '/not-plugin.js') await expect(loader.enablePlugin('not-a-plugin')).rejects.toThrow(/extending Plugin/) expect(loader.getPluginState('not-a-plugin')).toBe('error') expect(loader.getPluginError('not-a-plugin')?.message).toContain('extending Plugin') }) }) })