Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843import { 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<typeof import('../../../app/capabilities')>(), 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<void>(resolve => { release = resolve }) let began!: () => void const started = new Promise<void>(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<void>(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<void>(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<void>(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<void>((_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<void> { // 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<void>(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<void>(resolve => { release = resolve }) let entered!: () => void const started = new Promise<void>(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<void>(resolve => { release = resolve }) let entered!: () => void const started = new Promise<void>(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() }) })})