import { describe, it, expect, vi, beforeEach } from 'vitest' import { PluginLoader } from '../plugin-loader' // Retained compatibility lane explicitly admits third-party plugins. 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('PluginLoader', () => { let app: App let registry: PluginRegistry let settingsStore: PluginSettingsStore let enabledStore: EnabledStateStore let loader: PluginLoader beforeEach(async () => { const mockLocalStorage = createMockLocalStorage() vi.stubGlobal('localStorage', mockLocalStorage) app = await createTestApp() registry = new PluginRegistry() settingsStore = new PluginSettingsStore(app.reamsStateData!) enabledStore = new EnabledStateStore() loader = new PluginLoader(app, registry, settingsStore, enabledStore) }) describe('getPluginState', () => { it.each(['metadata', 'onload'] as const)('stops two abandoned startup batches during %s and lets a live host recover', async phase => { let release!: () => void const pending = new Promise(resolve => { release = resolve }) let began!: () => void const started = new Promise(resolve => { began = resolve }) const First = await createMockPluginClass({ onload: phase === 'onload' ? () => { began(); return pending } : undefined }) const secondOnload = vi.fn() const Second = await createMockPluginClass({ onload: secondOnload }) if (phase === 'metadata') app.setBackgroundIndexer({ whenReady: () => { began(); return pending } } as any) registry.register(createTestManifest({ id: 'first' }), '/first.js', true, phase === 'metadata') registry.register(createTestManifest({ id: 'second' }), '/second.js', true) enabledStore.setEnabled('first', true) enabledStore.setEnabled('second', true) const importer = vi.fn(createMockImporter(new Map([['/first.js', First], ['/second.js', Second]]))) const loader = new PluginLoader(app, registry, settingsStore, enabledStore, importer) const host = new AbortController() const starting = loader.enableStoredPlugins(host.signal) await started host.abort() const replacement = new AbortController() const replay = loader.enableStoredPlugins(replacement.signal) replacement.abort() release() await Promise.all([starting, replay]) expect(importer).toHaveBeenCalledTimes(phase === 'metadata' ? 0 : 1) expect(importer).not.toHaveBeenCalledWith('/second.js') expect(secondOnload).not.toHaveBeenCalled() expect(loader.getPluginState('first')).toBe(phase === 'metadata' ? 'unloaded' : 'disabled') expect(loader.getPluginState('second')).toBe('unloaded') expect(enabledStore.getEnabledPluginIds().sort()).toEqual(['first', 'second']) await loader.enableStoredPlugins(new AbortController().signal) expect(loader.isPluginEnabled('first')).toBe(true) expect(loader.isPluginEnabled('second')).toBe(true) expect(secondOnload).toHaveBeenCalledOnce() loader.disablePlugin('first', { persist: false }) loader.disablePlugin('second', { persist: false }) }) it('waits for host metadata before importing an admitted indexer, and cancels safely during that wait', async () => { let ready!: () => void const readiness = new Promise(resolve => { ready = resolve }) app.setBackgroundIndexer({ whenReady: () => readiness } as any) const PluginClass = await createMockPluginClass() const importer = vi.fn(async () => ({ default: PluginClass })) const manifest = createTestManifest({ id: 'metadata-indexer' }) registry.register(manifest, '/indexer.js', true, true) const loader = new PluginLoader(app, registry, settingsStore, enabledStore, importer) const enabling = loader.enablePlugin(manifest.id) expect(loader.getPluginState(manifest.id)).toBe('loading') expect(importer).not.toHaveBeenCalled() loader.cancelPendingEnable(manifest.id) ready() await enabling expect(importer).not.toHaveBeenCalled() expect(enabledStore.isEnabled(manifest.id)).toBe(false) await loader.enablePlugin(manifest.id) expect(importer).toHaveBeenCalledOnce() expect(loader.getPluginState(manifest.id)).toBe('active') loader.disablePlugin(manifest.id) }) it('returns unloaded for registered but not loaded plugin', () => { const manifest = createTestManifest({ id: 'test-plugin' }) registry.register(manifest, '/test.js') expect(loader.getPluginState('test-plugin')).toBe('unloaded') }) it('returns null for unknown plugin', () => { expect(loader.getPluginState('unknown')).toBe(null) }) }) describe('getPluginManifest', () => { it('returns manifest for registered plugin', () => { const manifest = createTestManifest({ id: 'test-plugin', name: 'Test' }) registry.register(manifest, '/test.js') const result = loader.getPluginManifest('test-plugin') expect(result).not.toBeNull() expect(result?.name).toBe('Test') }) it('returns null for unknown plugin', () => { expect(loader.getPluginManifest('unknown')).toBe(null) }) }) describe('getPluginIds', () => { it('returns all registered plugin IDs', () => { registry.register(createTestManifest({ id: 'a' }), '/a.js') registry.register(createTestManifest({ id: 'b' }), '/b.js') const ids = loader.getPluginIds() expect(ids.sort()).toEqual(['a', 'b']) }) }) describe('getPlugin', () => { it('returns null for unloaded plugin', () => { const manifest = createTestManifest({ id: 'test-plugin' }) registry.register(manifest, '/test.js') expect(loader.getPlugin('test-plugin')).toBe(null) }) }) describe('getPluginError', () => { it('clears a failed import when enabling is retried successfully', async () => { const manifest = createTestManifest({ id: 'retryable' }) registry.register(manifest, '/retry.js') const PluginClass = await createMockPluginClass() const importer = vi.fn().mockRejectedValueOnce(new Error('Temporary import failure')).mockResolvedValue({ default: PluginClass }) const loader = new PluginLoader(app, registry, settingsStore, enabledStore, importer) await expect(loader.enablePlugin(manifest.id)).rejects.toThrow('Temporary import failure') expect(loader.getPluginError(manifest.id)).not.toBeNull() await loader.enablePlugin(manifest.id) expect(loader.getPluginState(manifest.id)).toBe('active') expect(loader.getPluginError(manifest.id)).toBeNull() loader.disablePlugin(manifest.id) }) it('returns null for plugin not in error state', () => { const manifest = createTestManifest({ id: 'test-plugin' }) registry.register(manifest, '/test.js') expect(loader.getPluginError('test-plugin')).toBe(null) }) }) describe('loadPlugin', () => { it('loads a plugin module via importer', async () => { const MockPlugin = await createMockPluginClass() const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) const manifest = createTestManifest({ id: 'test-plugin' }) registry.register(manifest, '/test.js') await loader.loadPlugin('test-plugin') expect(loader.getPluginState('test-plugin')).toBe('loaded') }) it('transitions through loading state', async () => { const states: string[] = [] const MockPlugin = await createMockPluginClass() const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) loader.on('plugin-state-change', (id: string, newState: string) => { if (id === 'test-plugin') states.push(newState) }) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.loadPlugin('test-plugin') expect(states).toContain('loading') expect(states).toContain('loaded') }) it('rejects incompatible minAppVersion', async () => { const manifest = createTestManifest({ id: 'future-plugin', minAppVersion: '999.0.0', }) registry.register(manifest, '/test.js') await expect(loader.loadPlugin('future-plugin')).rejects.toThrow(/requires Obsidian/) expect(loader.getPluginState('future-plugin')).toBe('error') }) it('sets error state on import failure', async () => { const failingImporter: ModuleImporter = async () => { throw new Error('Import failed') } loader = new PluginLoader(app, registry, settingsStore, enabledStore, failingImporter) const manifest = createTestManifest({ id: 'bad-plugin' }) registry.register(manifest, '/bad.js') await expect(loader.loadPlugin('bad-plugin')).rejects.toThrow('Import failed') expect(loader.getPluginState('bad-plugin')).toBe('error') expect(loader.getPluginError('bad-plugin')).not.toBeNull() }) it('throws for unregistered plugin', async () => { await expect(loader.loadPlugin('unknown')).rejects.toThrow(/not registered/) }) it('is idempotent - loading already loaded plugin is no-op', async () => { const MockPlugin = await createMockPluginClass() const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.loadPlugin('test-plugin') await loader.loadPlugin('test-plugin') expect(loader.getPluginState('test-plugin')).toBe('loaded') }) }) describe('enablePlugin', () => { it('does not activate an imported plugin after its host cancels startup', async () => { let finishImport!: () => void const importHeld = new Promise(resolve => { finishImport = resolve }) let loads = 0 const MockPlugin = await createMockPluginClass({ onload: () => { loads++ } }) registry.register(createTestManifest({ id: 'importing' }), '/importing.js') loader = new PluginLoader(app, registry, settingsStore, enabledStore, async () => { await importHeld return { default: MockPlugin } }) const first = loader.enablePlugin('importing') expect(loader.getPluginState('importing')).toBe('loading') loader.cancelPendingEnable('importing') finishImport() await first expect(loads).toBe(0) expect(loader.getPluginState('importing')).toBe('loaded') await loader.enablePlugin('importing') expect(loads).toBe(1) expect(loader.getPluginState('importing')).toBe('active') }) it('waits for a cancelled async onload before re-enabling the same instance', async () => { let finishFirst!: () => void const firstLoad = new Promise(resolve => { finishFirst = resolve }) let loads = 0 const MockPlugin = await createMockPluginClass({ onload: () => ++loads === 1 ? firstLoad : undefined }) const instance = new MockPlugin(app, createTestManifest({ id: 'replayed' })) loader.registerBuiltinInstance(instance) const first = loader.enablePlugin('replayed') expect(loader.getPluginState('replayed')).toBe('enabling') loader.disablePlugin('replayed', { persist: false }) const replay = loader.enablePlugin('replayed') await Promise.resolve() expect(loads).toBe(1) expect(loader.getPluginState('replayed')).toBe('disabled') finishFirst() await Promise.all([first, replay]) expect(loads).toBe(2) expect(loader.getPluginState('replayed')).toBe('active') }) it('retries after a cancelled onload rejects without clobbering the disabled state', async () => { let rejectFirst!: (reason: Error) => void const firstLoad = new Promise((_resolve, reject) => { rejectFirst = reject }) let loads = 0 const MockPlugin = await createMockPluginClass({ onload: () => ++loads === 1 ? firstLoad : undefined }) loader.registerBuiltinInstance(new MockPlugin(app, createTestManifest({ id: 'rejected-replay' }))) const first = loader.enablePlugin('rejected-replay') loader.disablePlugin('rejected-replay', { persist: false }) const replay = loader.enablePlugin('rejected-replay') rejectFirst(new Error('old onload failed')) await Promise.all([first, replay]) expect(loads).toBe(2) expect(loader.getPluginState('rejected-replay')).toBe('active') }) it('instantiates and loads plugin', async () => { let onloadCalled = false const MockPlugin = await createMockPluginClass({ onload: () => { onloadCalled = true } }) const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') expect(loader.getPluginState('test-plugin')).toBe('active') expect(onloadCalled).toBe(true) }) it('auto-loads plugin if not already loaded', async () => { const MockPlugin = await createMockPluginClass() const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') expect(loader.getPluginState('test-plugin')).toBe('active') }) it('emits plugin-enabled event', async () => { const callback = vi.fn() const MockPlugin = await createMockPluginClass() const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) loader.on('plugin-enabled', callback) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') expect(callback).toHaveBeenCalledWith('test-plugin', expect.any(Object)) }) it('handles async onload', async () => { let asyncDone = false const MockPlugin = await createMockPluginClass({ onload: async () => { await new Promise(r => setTimeout(r, 10)) asyncDone = true } }) const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') expect(asyncDone).toBe(true) }) it('sets error state if onload throws', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const MockPlugin = await createMockPluginClass({ onload: () => { throw new Error('onload failed') } }) const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await expect(loader.enablePlugin('test-plugin')).rejects.toThrow('onload failed') expect(loader.getPluginState('test-plugin')).toBe('error') consoleSpy.mockRestore() }) it('component is loaded during onload (unified loading path)', async () => { let loadedDuringOnload = false const { Plugin: PluginBase, isComponentLoaded } = await import('../../plugin') class InspectPlugin extends PluginBase { onload() { loadedDuringOnload = isComponentLoaded(this) } } const mockImporter = createMockImporter(new Map([['/test.js', InspectPlugin as any]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') expect(loadedDuringOnload).toBe(true) }) it('handles async onload rejection and sets error state', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const MockPlugin = await createMockPluginClass({ onload: async () => { await new Promise(r => setTimeout(r, 10)) throw new Error('async onload failed') } }) const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await expect(loader.enablePlugin('test-plugin')).rejects.toThrow('async onload failed') expect(loader.getPluginState('test-plugin')).toBe('error') consoleSpy.mockRestore() }) it('cleans up resources registered before async onload rejection', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const { Plugin: PluginBase } = await import('../../plugin') class PartialPlugin extends PluginBase { async onload(): Promise { // Register a command before failing this.addCommand({ id: 'partial-cmd', name: 'Partial Command', callback: () => {}, }) // Then fail throw new Error('partial onload failed') } } const mockImporter = createMockImporter(new Map([['/test.js', PartialPlugin as any]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) const manifest = createTestManifest({ id: 'test-plugin' }) registry.register(manifest, '/test.js') await expect(loader.enablePlugin('test-plugin')).rejects.toThrow('partial onload failed') expect(loader.getPluginState('test-plugin')).toBe('error') // The command registered before the error should have been cleaned up const commands = app.commands.getAllCommands() const ghostCmd = commands.find(c => c.id === 'test-plugin:partial-cmd') expect(ghostCmd).toBeUndefined() consoleSpy.mockRestore() }) it('is idempotent - enabling active plugin is no-op', async () => { let onloadCount = 0 const MockPlugin = await createMockPluginClass({ onload: () => { onloadCount++ } }) const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') await loader.enablePlugin('test-plugin') expect(onloadCount).toBe(1) }) it('persists enabled state', async () => { const MockPlugin = await createMockPluginClass() const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') expect(enabledStore.isEnabled('test-plugin')).toBe(true) }) }) describe('disablePlugin', () => { it('calls onunload and destroys instance', async () => { let onunloadCalled = false const MockPlugin = await createMockPluginClass({ onunload: () => { onunloadCalled = true } }) const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') loader.disablePlugin('test-plugin') expect(loader.getPluginState('test-plugin')).toBe('disabled') expect(onunloadCalled).toBe(true) expect(loader.getPlugin('test-plugin')).toBe(null) }) it('emits plugin-disabled event', async () => { const callback = vi.fn() const MockPlugin = await createMockPluginClass() const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) loader.on('plugin-disabled', callback) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') loader.disablePlugin('test-plugin') expect(callback).toHaveBeenCalledWith('test-plugin') }) it('handles onunload throwing without crashing', async () => { const MockPlugin = await createMockPluginClass({ onunload: () => { throw new Error('onunload failed') } }) const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') expect(() => loader.disablePlugin('test-plugin')).not.toThrow() expect(loader.getPluginState('test-plugin')).toBe('disabled') }) it('is idempotent - disabling disabled plugin is no-op', async () => { const MockPlugin = await createMockPluginClass() const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') loader.disablePlugin('test-plugin') loader.disablePlugin('test-plugin') expect(loader.getPluginState('test-plugin')).toBe('disabled') }) it('persists disabled state', async () => { const MockPlugin = await createMockPluginClass() const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') loader.disablePlugin('test-plugin') expect(enabledStore.isEnabled('test-plugin')).toBe(false) }) }) describe('unloadPlugin', () => { it('invalidates an in-flight onload and cleans up instead of activating its removed record', async () => { let finish!: () => void const waiting = new Promise(resolve => { finish = resolve }) const cleanup = vi.fn() const MockPlugin = await createMockPluginClass({ onload: () => waiting, onunload: cleanup }) registry.register(createTestManifest({ id: 'pending-unload' }), '/pending.js') loader = new PluginLoader(app, registry, settingsStore, enabledStore, createMockImporter(new Map([['/pending.js', MockPlugin]]))) const enabling = loader.enablePlugin('pending-unload') await vi.waitFor(() => expect(loader.getPluginState('pending-unload')).toBe('enabling')) loader.unloadPlugin('pending-unload') expect(cleanup).not.toHaveBeenCalled() finish() await enabling expect(cleanup).toHaveBeenCalledOnce() expect(loader.getPlugin('pending-unload')).toBeNull() expect(enabledStore.isEnabled('pending-unload')).toBe(false) }) it('removes plugin record completely', async () => { const MockPlugin = await createMockPluginClass() const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') loader.unloadPlugin('test-plugin') // Plugin should be completely gone from the loader expect(loader.getPluginState('test-plugin')).toBe('unloaded') // falls through to registry check }) it('disables active plugin before removing', async () => { let onunloadCalled = false const MockPlugin = await createMockPluginClass({ onunload: () => { onunloadCalled = true } }) const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') loader.unloadPlugin('test-plugin') expect(onunloadCalled).toBe(true) }) it('emits plugin-state-change with unloaded state', async () => { const states: string[] = [] const MockPlugin = await createMockPluginClass() const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') loader.on('plugin-state-change', (_id: string, newState: string) => { states.push(newState) }) loader.unloadPlugin('test-plugin') expect(states).toContain('unloaded') }) it('emits original state as oldState (not intermediate disabled state)', async () => { const events: Array<{ newState: string; oldState: string }> = [] const MockPlugin = await createMockPluginClass() const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') loader.on('plugin-state-change', (_id: string, newState: string, oldState: string) => { events.push({ newState, oldState }) }) loader.unloadPlugin('test-plugin') // The final unloaded event should report 'active' as the oldState, // not 'disabled' (which is the intermediate state after disablePlugin) const unloadedEvent = events.find(e => e.newState === 'unloaded') expect(unloadedEvent).toBeDefined() expect(unloadedEvent!.oldState).toBe('active') }) it('clears enabled state from localStorage on unload', async () => { const MockPlugin = await createMockPluginClass() const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') expect(enabledStore.isEnabled('test-plugin')).toBe(true) // Unload without prior disable — enabled state should still be cleared loader.unloadPlugin('test-plugin') expect(enabledStore.isEnabled('test-plugin')).toBe(false) }) it('removes plugin from getPluginIds() when registry is also cleared', async () => { const MockPlugin = await createMockPluginClass() const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') loader.unloadPlugin('test-plugin') registry.unregister('test-plugin') expect(loader.getPluginIds()).not.toContain('test-plugin') }) it('is a no-op for unknown plugins', () => { expect(() => loader.unloadPlugin('unknown')).not.toThrow() }) it('works on disabled plugins', async () => { const MockPlugin = await createMockPluginClass() const mockImporter = createMockImporter(new Map([['/test.js', MockPlugin]])) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'test-plugin' }), '/test.js') await loader.enablePlugin('test-plugin') loader.disablePlugin('test-plugin') loader.unloadPlugin('test-plugin') // Record should be removed expect(loader.getPlugin('test-plugin')).toBe(null) }) }) describe('uninstallPlugin', () => { it('completes resource teardown and deletion despite throwing lifecycle observers', async () => { const { Plugin } = await import('../../plugin') const { ItemView } = await import('../../views/item-view') const observer = vi.fn() const cleanup = vi.fn() class TestView extends ItemView { getViewType() { return 'observer-removal-view' } getDisplayText() { return 'Observer removal view' } } class ResourcePlugin extends Plugin { onload() { this.addCommand({ id: 'owned', name: 'Owned command', callback: () => {} }) this.registerEvent(this.app.workspace.on('layout-change', observer)) this.registerView('observer-removal-view', leaf => new TestView(leaf)) } onunload() { cleanup() } } const manifest = createTestManifest({ id: 'observer-removal' }) registry.register(manifest, '/observer.js', true) loader = new PluginLoader(app, registry, settingsStore, enabledStore, async () => ({ default: ResourcePlugin })) await loader.enablePlugin(manifest.id) loader.on('plugin-state-change', () => { throw new Error('State observer failed') }) loader.on('plugin-disabled', () => { throw new Error('Disabled observer failed') }) const removeBundle = vi.fn(async () => {}) await loader.uninstallPlugin(manifest.id, removeBundle) expect(cleanup).toHaveBeenCalledOnce() expect(removeBundle).toHaveBeenCalledOnce() expect(loader.isPluginUninstalling(manifest.id)).toBe(false) expect(loader.getPluginState(manifest.id)).toBeNull() expect(registry.has(manifest.id)).toBe(false) expect(enabledStore.isEnabled(manifest.id)).toBe(false) expect(app.commands.getCommand(`${manifest.id}:owned`)).toBeUndefined() expect(app.workspace.getViewCreator('observer-removal-view')).toBeUndefined() observer.mockClear() app.workspace.trigger('layout-change') expect(observer).not.toHaveBeenCalled() }) it.each(['import', 'onload'] as const)('drains a pending %s before deleting a bundle, and permits clean reinstallation', async phase => { const { Plugin } = await import('../../plugin') const { ItemView } = await import('../../views/item-view') let release!: () => void const blocked = new Promise(resolve => { release = resolve }) let entered!: () => void const started = new Promise(resolve => { entered = resolve }) const observer = vi.fn() const onload = vi.fn() class TestView extends ItemView { getViewType() { return 'deferred-removal-view' } getDisplayText() { return 'Deferred removal view' } } class DeferredPlugin extends Plugin { async onload() { onload() this.addCommand({ id: 'early', name: 'Early command', callback: () => {} }) this.registerEvent(this.app.workspace.on('layout-change', observer)) this.registerView('deferred-removal-view', leaf => new TestView(leaf)) if (phase === 'onload') { entered(); await blocked } this.addCommand({ id: 'late', name: 'Late command', callback: () => {} }) } } const importer = vi.fn(async () => { if (phase === 'import') { entered(); await blocked } return { default: DeferredPlugin } }) const manifest = createTestManifest({ id: 'deferred-removal' }) registry.register(manifest, '/deferred.js', true) enabledStore.setEnabled(manifest.id, true) loader = new PluginLoader(app, registry, settingsStore, enabledStore, importer) const enabling = loader.enablePlugin(manifest.id) await started const removeBundle = vi.fn(async () => {}) const removing = loader.uninstallPlugin(manifest.id, removeBundle) expect(loader.isPluginUninstalling(manifest.id)).toBe(true) await expect(loader.enablePlugin(manifest.id)).rejects.toThrow(/being uninstalled/) expect(removeBundle).not.toHaveBeenCalled() release() await Promise.all([enabling, removing]) expect(removeBundle).toHaveBeenCalledOnce() expect(loader.getPluginState(manifest.id)).toBeNull() expect(registry.has(manifest.id)).toBe(false) expect(enabledStore.isEnabled(manifest.id)).toBe(false) expect(app.commands.getCommand(`${manifest.id}:early`)).toBeUndefined() expect(app.commands.getCommand(`${manifest.id}:late`)).toBeUndefined() expect(app.workspace.getViewCreator('deferred-removal-view')).toBeUndefined() observer.mockClear() app.workspace.trigger('layout-change') expect(observer).not.toHaveBeenCalled() expect(onload).toHaveBeenCalledTimes(phase === 'import' ? 0 : 1) registry.register(manifest, '/reinstalled.js', true) await loader.enablePlugin(manifest.id) expect(loader.isPluginEnabled(manifest.id)).toBe(true) expect(app.commands.getCommand(`${manifest.id}:late`)).toBeDefined() loader.disablePlugin(manifest.id) }) }) describe('enableStoredPlugins', () => { it('does not activate a plugin removed while an earlier startup enable was pending', async () => { let release!: () => void const blocked = new Promise(resolve => { release = resolve }) let entered!: () => void const started = new Promise(resolve => { entered = resolve }) const First = await createMockPluginClass({ onload: () => { entered(); return blocked } }) const Removed = await createMockPluginClass() const importer = vi.fn(createMockImporter(new Map([['/first.js', First], ['/removed.js', Removed]]))) loader = new PluginLoader(app, registry, settingsStore, enabledStore, importer) registry.register(createTestManifest({ id: 'first' }), '/first.js') registry.register(createTestManifest({ id: 'removed' }), '/removed.js') enabledStore.setEnabled('first', true) enabledStore.setEnabled('removed', true) const starting = loader.enableStoredPlugins() await started await loader.uninstallPlugin('removed', async () => {}) release() await starting expect(importer).not.toHaveBeenCalledWith('/removed.js') expect(loader.getPluginState('removed')).toBeNull() expect(enabledStore.isEnabled('removed')).toBe(false) loader.disablePlugin('first') }) it('enables all previously enabled plugins', async () => { const MockPluginA = await createMockPluginClass() const MockPluginB = await createMockPluginClass() const mockImporter = createMockImporter(new Map([ ['/a.js', MockPluginA], ['/b.js', MockPluginB], ])) // Pre-populate enabled state enabledStore.setEnabled('plugin-a', true) enabledStore.setEnabled('plugin-b', true) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'plugin-a' }), '/a.js') registry.register(createTestManifest({ id: 'plugin-b' }), '/b.js') await loader.enableStoredPlugins() expect(loader.getPluginState('plugin-a')).toBe('active') expect(loader.getPluginState('plugin-b')).toBe('active') }) it('skips plugins not in registry', async () => { enabledStore.setEnabled('nonexistent-plugin', true) // Should not throw await loader.enableStoredPlugins() }) it('continues enabling other plugins if one fails', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const BadPlugin = await createMockPluginClass({ onload: () => { throw new Error('bad') } }) const GoodPlugin = await createMockPluginClass() const mockImporter = createMockImporter(new Map([ ['/bad.js', BadPlugin], ['/good.js', GoodPlugin], ])) enabledStore.setEnabled('bad-plugin', true) enabledStore.setEnabled('good-plugin', true) loader = new PluginLoader(app, registry, settingsStore, enabledStore, mockImporter) registry.register(createTestManifest({ id: 'bad-plugin' }), '/bad.js') registry.register(createTestManifest({ id: 'good-plugin' }), '/good.js') await loader.enableStoredPlugins() expect(loader.getPluginState('bad-plugin')).toBe('error') expect(loader.getPluginState('good-plugin')).toBe('active') consoleSpy.mockRestore() }) }) })