// src/plugin/__tests__/plugin.test.ts import { describe, it, expect, vi, afterEach } from 'vitest' import { App } from '../app' import { Vault } from '../vault' import { FileIndex } from '../../services/file-index' import { Workspace } from '../workspace' import { PluginSettingTab } from '../settings' import { Plugin } from '../plugin' import type { PluginManifest } from '../types' import type { DocumentManager } from '../../services/document-manager' import type { VaultStateData } from '../../services/vault-state-data' import type { Extension } from '@codemirror/state' import type { FormattingAction } from '../ui/formatting-action-types' import { getCoreSemanticRegistry, RegistrationError } from '../../format-preservation' import type { ConstructSpec } from '../../format-preservation' function createMockDocumentManager(): DocumentManager { return { createFile: vi.fn().mockReturnValue('automerge:file1'), createFolder: vi.fn().mockReturnValue('automerge:folder1'), readText: vi.fn(async (url: any) => ({ status: 'available' as const, documentId: url, heads: ['h0'], content: 'content' })), updateContent: vi.fn().mockResolvedValue(undefined), } as unknown as DocumentManager } class FormattingTestPlugin extends Plugin { registerFormattingActionForTest(action: FormattingAction): void { this.addFormattingAction(action) } removeFormattingActionForTest(actionId: string): void { this.removeFormattingAction(actionId) } } describe('App', () => { it('has vault property', () => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as any), dm) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace) expect(app.vault).toBe(vault) }) it('has workspace property', () => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as any), dm) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace) expect(app.workspace).toBe(workspace) }) it('has metadataCache property after setMetadataCache', () => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as any), dm) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace) // MetadataCache matches upstream property semantics: undefined until wired. expect(app.metadataCache).toBeUndefined() const mockMC = { getFileCache: () => null } as any app.setMetadataCache(mockMC) expect(app.metadataCache).toBe(mockMC) }) describe('loadLocalStorage', () => { it('returns null for non-existent key', () => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as any), dm) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace) expect(app.loadLocalStorage('nonexistent')).toBe(null) }) }) describe('saveLocalStorage', () => { it('saves and retrieves value', () => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as any), dm) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace) app.saveLocalStorage('test-key', { foo: 'bar' }) expect(app.loadLocalStorage('test-key')).toEqual({ foo: 'bar' }) }) it('clears value when data is null', () => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as any), dm) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace) app.saveLocalStorage('test-key', 'value') app.saveLocalStorage('test-key', null) expect(app.loadLocalStorage('test-key')).toBe(null) }) }) describe('registerConstruct', () => { afterEach(() => { getCoreSemanticRegistry().unregisterPluginSpecs('test-plugin') }) it('registers a plugin spec through the shared registry', () => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as any), dm) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace) const spec = { name: 'test-construct', schemaVersion: 1, defaultExpand: 'both', categories: ['inline'], synthesisPrecedence: 100, serializeAttrs: () => '', parseAttrs: () => null, classify: () => ({ kind: 'clean' }), rewriteForLocalState: () => [], synthesizeFromText: () => [], } as ConstructSpec app.registerConstruct('test-plugin', spec) const registry = getCoreSemanticRegistry() const reg = registry.getSpec('test-construct') expect(reg).not.toBeNull() expect(reg?.owner).toBe('plugin:test-plugin') expect(reg?.spec.name).toBe('test-construct') }) it('rejects a plugin spec with spliceScopeOutsideMark > 256', () => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as any), dm) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace) const badSpec = { name: 'bad-construct', schemaVersion: 1, defaultExpand: 'both', categories: ['inline'], synthesisPrecedence: 100, spliceScopeOutsideMark: 257, serializeAttrs: () => '', parseAttrs: () => null, classify: () => ({ kind: 'clean' }), rewriteForLocalState: () => [], synthesizeFromText: () => [], } as ConstructSpec expect(() => app.registerConstruct('test-plugin', badSpec)).toThrow(RegistrationError) }) it('rejects a plugin spec with maxClaimRange > 256', () => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as any), dm) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace) const badSpec = { name: 'bad-claim', schemaVersion: 1, defaultExpand: 'both', categories: ['inline'], synthesisPrecedence: 100, maxClaimRange: 257, serializeAttrs: () => '', parseAttrs: () => null, classify: () => ({ kind: 'clean' }), rewriteForLocalState: () => [], synthesizeFromText: () => [], } as ConstructSpec expect(() => app.registerConstruct('test-plugin', badSpec)).toThrow(RegistrationError) }) it('rejects a plugin spec missing required methods', () => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as any), dm) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace) const badSpec = { name: 'incomplete', schemaVersion: 1, defaultExpand: 'both', categories: ['inline'], synthesisPrecedence: 100, serializeAttrs: () => '', parseAttrs: () => null, classify: undefined, rewriteForLocalState: () => [], synthesizeFromText: () => [], } as unknown as ConstructSpec expect(() => app.registerConstruct('test-plugin', badSpec)).toThrow(RegistrationError) }) }) }) describe('PluginSettingTab', () => { it('has app property', () => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as any), dm) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace) class TestSettingTab extends PluginSettingTab { display(): void {} } const tab = new TestSettingTab(app, null as any) expect(tab.app).toBe(app) }) it('has containerEl property', () => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as any), dm) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace) class TestSettingTab extends PluginSettingTab { display(): void {} } const tab = new TestSettingTab(app, null as any) expect(tab.containerEl).toBeInstanceOf(HTMLElement) }) it('calls display when showing', () => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as any), dm) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace) const displaySpy = vi.fn() class TestSettingTab extends PluginSettingTab { display(): void { displaySpy() } } const tab = new TestSettingTab(app, null as any) tab.display() expect(displaySpy).toHaveBeenCalled() }) }) describe('Plugin', () => { function createTestApp() { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as any), dm) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace) const values = new Map() app.reamsStateData = { load: vi.fn(async (category: string, id: string) => values.get(`${category}:${id}`) ?? null), save: vi.fn(async (category: string, id: string, value: unknown) => { values.set(`${category}:${id}`, value) }), } as unknown as VaultStateData return app } const testManifest: PluginManifest = { id: 'test-plugin', name: 'Test Plugin', author: 'Test Author', version: '1.0.0', minAppVersion: '1.0.0', description: 'A test plugin', } describe('lifecycle', () => { it('has app property', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) expect(plugin.app).toBe(app) }) it('has manifest property', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) expect(plugin.manifest).toBe(testManifest) }) it('calls onload when loaded', async () => { const app = createTestApp() const onloadSpy = vi.fn() class TestPlugin extends Plugin { async onload() { onloadSpy() } } const plugin = new TestPlugin(app, testManifest) plugin.load() await vi.waitFor(() => expect(onloadSpy).toHaveBeenCalled()) }) it('has onExternalSettingsChange method', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) expect(typeof plugin.onExternalSettingsChange).toBe('function') }) it('onExternalSettingsChange can be overridden', () => { const app = createTestApp() const onChangeSpy = vi.fn().mockReturnValue('custom-return') class TestPlugin extends Plugin { onExternalSettingsChange() { return onChangeSpy() } } const plugin = new TestPlugin(app, testManifest) const result = plugin.onExternalSettingsChange() expect(onChangeSpy).toHaveBeenCalled() expect(result).toBe('custom-return') }) it('onExternalSettingsChange base implementation returns undefined', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) const result = plugin.onExternalSettingsChange() expect(result).toBeUndefined() }) }) describe('addCommand', () => { it('registers a command with prefixed id', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() const cmd = plugin.addCommand({ id: 'test-cmd', name: 'Test Command', callback: () => {}, }) expect(cmd.id).toBe('test-plugin:test-cmd') }) it('replaces stale hotkeys when re-registering the same command id', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() plugin.addCommand({ id: 'test-cmd', name: 'Test Command', callback: () => {}, hotkeys: [{ modifiers: ['Ctrl'], key: 'k' }], }) plugin.addCommand({ id: 'test-cmd', name: 'Test Command Replacement', callback: () => {}, hotkeys: [{ modifiers: ['Ctrl'], key: 'j' }], }) expect(app.hotkeys.getHotkeyForCommand('test-plugin:test-cmd')).toEqual({ modifiers: ['Ctrl'], key: 'j', }) expect(app.hotkeys.getAllBindings().get('Ctrl+K')).toBeUndefined() expect(app.hotkeys.getAllBindings().get('Ctrl+J')).toBe('test-plugin:test-cmd') expect( app.commands.getAllCommands().filter(command => command.id === 'test-plugin:test-cmd') ).toHaveLength(1) app.destroy() }) }) describe('addSettingTab', () => { it('registers a setting tab', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() class TestTab extends PluginSettingTab { display() {} } expect(() => plugin.addSettingTab(new TestTab(app, plugin))).not.toThrow() }) }) describe('registerView', () => { it('registers a view type', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() expect(() => plugin.registerView('test-view', () => null as any)).not.toThrow() }) }) describe('registerMarkdownPostProcessor', () => { it('registers a post processor', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() const processor = plugin.registerMarkdownPostProcessor((_el) => {}) expect(processor).toBeDefined() }) }) describe('loadData / saveData', () => { it('saves and loads plugin data', async () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) await plugin.saveData({ setting: 'value' }) const data = await plugin.loadData() expect(data).toEqual({ setting: 'value' }) }) it('loadData returns null when the plugin has no vault state', async () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) const data = await plugin.loadData() expect(data).toBeNull() }) it('ignores obsolete device-local plugin data', async () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) localStorage.setItem(`reams:plugin:${testManifest.id}:data`, '{"stale":true}') const data = await plugin.loadData() expect(data).toBeNull() localStorage.removeItem(`reams:plugin:${testManifest.id}:data`) }) it('surfaces vault-state write failure instead of treating a local cache as saved', async () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) vi.mocked(app.reamsStateData!.save).mockRejectedValueOnce(new Error('state unavailable')) await expect(plugin.saveData({ test: 'value' })).rejects.toThrow('state unavailable') }) it('loadData correctly parses stored JSON objects', async () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) const testData = { nested: { array: [1, 2, 3], bool: true, num: 42 } } await plugin.saveData(testData) const loaded = await plugin.loadData() expect(loaded).toEqual(testData) }) }) describe('lifecycle guards', () => { it('addCommand throws after unload', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() plugin.unload() // Per AGENTS.md: "throw clear errors for unimplemented APIs" // A dead plugin should throw, not silently return a stub. expect(() => plugin.addCommand({ id: 'ghost-cmd', name: 'Ghost Command', callback: () => {}, })).toThrow(/not loaded/) // Verify the command was not registered expect(app.commands.getAllCommands()).not.toContainEqual( expect.objectContaining({ id: 'test-plugin:ghost-cmd' }) ) }) it('addRibbonIcon returns detached element after unload', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() plugin.unload() const el = plugin.addRibbonIcon('test', 'Test', () => {}) expect(el).toBeInstanceOf(HTMLElement) expect(el.parentElement).toBeNull() }) it('addStatusBarItem returns detached element after unload', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() plugin.unload() const el = plugin.addStatusBarItem() expect(el).toBeInstanceOf(HTMLElement) expect(el.parentElement).toBeNull() }) it('addSettingTab no-ops after unload', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() plugin.unload() class TestTab extends PluginSettingTab { display() {} } expect(() => plugin.addSettingTab(new TestTab(app, plugin))).not.toThrow() }) it('registerView no-ops after unload', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() plugin.unload() expect(() => plugin.registerView('test-view', () => null as any)).not.toThrow() }) it('registerMarkdownPostProcessor no-ops after unload', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() plugin.unload() const processor = plugin.registerMarkdownPostProcessor((_el) => {}) expect(processor).toBeDefined() }) it('registerMarkdownCodeBlockProcessor no-ops after unload', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() plugin.unload() const processor = plugin.registerMarkdownCodeBlockProcessor('test', () => {}) expect(processor).toBeDefined() }) it('addFormattingAction no-ops after unload', () => { const app = createTestApp() const plugin = new FormattingTestPlugin(app, testManifest) plugin.load() plugin.unload() expect(() => plugin.registerFormattingActionForTest({ id: 'ghost-action', icon: 'bold', title: 'Ghost', callback: () => {}, })).not.toThrow() }) it('async onload StrictMode race: addCommand throws on dead plugin (caught by Component.load)', async () => { // This tests the race condition where an async onload() calls addCommand() // after the plugin has been unloaded (e.g., React StrictMode double-mount). // The addCommand() call throws, Component.load() catches and logs the error, // and the command is not registered. vi.useFakeTimers() const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const app = createTestApp() class AsyncPlugin extends Plugin { async onload(): Promise { await new Promise(r => setTimeout(r, 10)) this.addCommand({ id: 'ghost', name: 'Ghost', callback: () => {}, }) this.addRibbonIcon('test', 'Ghost', () => {}) } } const plugin1 = new AsyncPlugin(app, testManifest) plugin1.load() plugin1.unload() // StrictMode unmount // Advance timers to let async tail complete await vi.advanceTimersByTimeAsync(50) // The error is caught and logged by Component.load() (async rejection handler) expect(consoleErrorSpy).toHaveBeenCalledWith( 'Component onload() rejected:', expect.any(Error) ) // No ghost commands should be registered (the throw prevented registration) expect(app.commands.getAllCommands()).not.toContainEqual( expect.objectContaining({ id: 'test-plugin:ghost' }) ) consoleErrorSpy.mockRestore() vi.useRealTimers() }) }) describe('Plugin.getSettingTabs', () => { it('returns empty array when no setting tabs registered', () => { const app = createTestApp() class TestPlugin extends Plugin { onload() {} } const plugin = new TestPlugin(app, testManifest) expect(plugin.getSettingTabs()).toEqual([]) app.destroy() }) it('returns registered setting tabs', () => { const app = createTestApp() class TestSettingTab extends PluginSettingTab { display() {} } class TestPlugin extends Plugin { onload() { this.addSettingTab(new TestSettingTab(this.app, this)) } } const plugin = new TestPlugin(app, testManifest) plugin.load() expect(plugin.getSettingTabs()).toHaveLength(1) expect(plugin.getSettingTabs()[0]).toBeInstanceOf(PluginSettingTab) app.destroy() }) }) describe('wrappedUnload _loaded guard', () => { it('drains commands and later host resources despite a real removal-listener failure', () => { const app = createTestApp() const subclass = vi.fn() const baseCleanup = vi.fn() class Settings extends PluginSettingTab { display() {} } class TestPlugin extends Plugin { onload() { this.addCommand({ id: 'first', name: 'First', callback() {} }) this.addCommand({ id: 'second', name: 'Second', callback() {} }) this.registerView('cleanup-probe', () => { throw new Error('not opened') }) this.addSettingTab(new Settings(this.app, this)) this.register(baseCleanup) } unload() { subclass(); super.unload() } } const plugin = new TestPlugin(app, testManifest) plugin.load() const error = new Error('removal listener failed') const unsubscribe = app.commands.onCommandRemoved(() => { throw error }) try { expect(app.commands.getAllCommands()).toHaveLength(2) expect(() => plugin.unload()).toThrow() expect(app.commands.getAllCommands()).toEqual([]) expect(app.workspace.getRegisteredViewTypes()).not.toContain('cleanup-probe') expect(plugin.getSettingTabs()).toEqual([]) expect(subclass).toHaveBeenCalledTimes(1) expect(baseCleanup).toHaveBeenCalledTimes(1) plugin.unload() expect(subclass).toHaveBeenCalledTimes(1) } finally { unsubscribe(); app.destroy() } }) it('reports subclass and base cleanup failures after attempting both', () => { const app = createTestApp() const subclassError = new Error('subclass failed') const baseError = new Error('base failed') class BrokenPlugin extends Plugin { unload() { throw subclassError } } const plugin = new BrokenPlugin(app, testManifest) plugin.load() const callback = vi.fn(() => { throw baseError }) plugin.register(callback) try { let failure: unknown try { plugin.unload() } catch (error) { failure = error } expect(failure).toMatchObject({ errors: [subclassError, baseError] }) expect(callback).toHaveBeenCalledTimes(1) } finally { app.destroy() } }) it.each(['override', 'host cleanup'])('guarantees base teardown after a throwing %s', failure => { const app = createTestApp() const cleanup = vi.fn() const onunload = vi.fn() class BrokenPlugin extends Plugin { unload() { if (failure === 'override') throw new Error('unload failed') super.unload() } onunload() { onunload() } } const plugin = new BrokenPlugin(app, testManifest) plugin.load() plugin.register(cleanup) const remove = vi.spyOn(app.commands, 'removePluginCommands') if (failure === 'host cleanup') remove.mockImplementationOnce(() => { throw new Error('unload failed') }) try { expect(() => plugin.unload()).toThrow('unload failed') expect(cleanup).toHaveBeenCalledTimes(1) expect(onunload).toHaveBeenCalledTimes(1) plugin.unload() expect(cleanup).toHaveBeenCalledTimes(1) } finally { remove.mockRestore(); app.destroy() } }) it('calling unload() twice does not call cleanupPluginResources twice', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() // Spy on the app managers to verify cleanup is only called once const removeCmdSpy = vi.spyOn(app.commands, 'removePluginCommands') plugin.unload() expect(removeCmdSpy).toHaveBeenCalledTimes(1) // Second unload should not call cleanup again plugin.unload() expect(removeCmdSpy).toHaveBeenCalledTimes(1) removeCmdSpy.mockRestore() app.destroy() }) it('_loaded guard works when unload is destructured (unbound call)', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() const removeCmdSpy = vi.spyOn(app.commands, 'removePluginCommands') // Destructure unload — this would crash if the guard uses `this` // instead of a captured reference, since `this` becomes undefined const { unload } = plugin expect(() => unload()).not.toThrow() expect(removeCmdSpy).toHaveBeenCalledTimes(1) // Second destructured call should be a no-op expect(() => unload()).not.toThrow() expect(removeCmdSpy).toHaveBeenCalledTimes(1) removeCmdSpy.mockRestore() app.destroy() }) }) describe('settingTabs cleanup on unload', () => { it('settingTabs are cleared on unload so re-enable does not duplicate', () => { const app = createTestApp() class TestSettingTab extends PluginSettingTab { display() {} } class TestPlugin extends Plugin { onload() { this.addSettingTab(new TestSettingTab(this.app, this)) } } const plugin = new TestPlugin(app, testManifest) // First cycle plugin.load() expect(plugin.getSettingTabs()).toHaveLength(1) plugin.unload() expect(plugin.getSettingTabs()).toHaveLength(0) // Second cycle — should not accumulate plugin.load() expect(plugin.getSettingTabs()).toHaveLength(1) plugin.unload() expect(plugin.getSettingTabs()).toHaveLength(0) app.destroy() }) }) describe('Plugin cleanup on re-enable cycles', () => { it('cleans up resources on every unload, not just the first', async () => { const app = createTestApp() class TestPlugin extends Plugin { onload() { this.addCommand({ id: 'test-cmd', name: 'Test Command', callback: () => {} }) this.addRibbonIcon('calendar', 'Test', () => {}) } } const plugin = new TestPlugin(app, testManifest) // First cycle: load -> unload plugin.load() expect(app.commands.getAllCommands().some((c: { id: string }) => c.id === 'test-plugin:test-cmd')).toBe(true) plugin.unload() expect(app.commands.getAllCommands().some((c: { id: string }) => c.id === 'test-plugin:test-cmd')).toBe(false) // Second cycle: load -> unload (this is the critical test) plugin.load() expect(app.commands.getAllCommands().some((c: { id: string }) => c.id === 'test-plugin:test-cmd')).toBe(true) plugin.unload() // BUG: Without the fix, the command would still exist here expect(app.commands.getAllCommands().some((c: { id: string }) => c.id === 'test-plugin:test-cmd')).toBe(false) app.destroy() }) }) describe('addRibbonIcon', () => { it('returns an HTMLElement', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() const el = plugin.addRibbonIcon('test', 'Test', () => {}) expect(el).toBeInstanceOf(HTMLElement) }) }) describe('addStatusBarItem', () => { it('returns an HTMLElement', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() const el = plugin.addStatusBarItem() expect(el).toBeInstanceOf(HTMLElement) }) it('returns element with setText() helper method', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() // addStatusBarItem() returns HTMLElement for Obsidian API compliance, // but Reams attaches setText() at runtime const el = plugin.addStatusBarItem() as HTMLElement & { setText: (text: string) => void } expect(typeof el.setText).toBe('function') }) it('setText() sets textContent of the element', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() const el = plugin.addStatusBarItem() as HTMLElement & { setText: (text: string) => void } el.setText('Hello World') expect(el.textContent).toBe('Hello World') }) it('setText() works with empty string', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() const el = plugin.addStatusBarItem() as HTMLElement & { setText: (text: string) => void } el.setText('Initial') el.setText('') expect(el.textContent).toBe('') }) it('returns stub setText() on dead plugin', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) // Don't call plugin.load() - plugin is "dead" const el = plugin.addStatusBarItem() as HTMLElement & { setText: (text: string) => void } // Should not throw expect(() => el.setText('test')).not.toThrow() }) }) describe('registerObsidianProtocolHandler', () => { it('is callable and does not throw', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() expect(typeof plugin.registerObsidianProtocolHandler).toBe('function') expect(() => { plugin.registerObsidianProtocolHandler('quickadd', async () => {}) }).not.toThrow() }) }) describe('registerExtensions (stub)', () => { it('is callable and does not throw', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() expect(typeof plugin.registerExtensions).toBe('function') expect(() => { plugin.registerExtensions(['excalidraw', 'canvas'], 'my-view-type') }).not.toThrow() }) }) describe('registerBasesView (stub)', () => { it('is callable and returns false', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() expect(typeof plugin.registerBasesView).toBe('function') // Stub returns false since Bases feature is not implemented const result = plugin.registerBasesView('my-view', { name: 'My View', icon: 'table', factory: () => null as never, }) expect(result).toBe(false) }) }) describe('registerCliHandler (stub)', () => { it('is callable and does not throw', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() expect(typeof plugin.registerCliHandler).toBe('function') expect(() => { plugin.registerCliHandler( 'my-plugin', 'Does something useful', { verbose: { description: 'Enable verbose output' } }, () => 'done' ) }).not.toThrow() }) it('accepts null flags', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() expect(() => { plugin.registerCliHandler('my-plugin', 'Description', null, () => 'done') }).not.toThrow() }) }) describe('removeCommand', () => { it('removes a registered command by id', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() plugin.addCommand({ id: 'removable-cmd', name: 'Removable Command', callback: () => {}, }) // Verify command exists expect(app.commands.getAllCommands()).toContainEqual( expect.objectContaining({ id: 'test-plugin:removable-cmd' }) ) // Remove it plugin.removeCommand('removable-cmd') // Verify command is removed expect(app.commands.getAllCommands()).not.toContainEqual( expect.objectContaining({ id: 'test-plugin:removable-cmd' }) ) app.destroy() }) it('also removes associated hotkeys', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() plugin.addCommand({ id: 'cmd-with-hotkey', name: 'Command with Hotkey', callback: () => {}, hotkeys: [{ modifiers: ['Mod'], key: 'K' }], }) // Verify hotkey is registered const hotkeyBefore = app.hotkeys.getHotkeyForCommand('test-plugin:cmd-with-hotkey') expect(hotkeyBefore).toBeDefined() // Remove command plugin.removeCommand('cmd-with-hotkey') // Verify hotkey is also removed const hotkeyAfter = app.hotkeys.getHotkeyForCommand('test-plugin:cmd-with-hotkey') expect(hotkeyAfter).toBeUndefined() app.destroy() }) }) describe('removeFormattingAction', () => { it('removes a formatting action owned by this plugin', () => { const app = createTestApp() const plugin = new FormattingTestPlugin(app, testManifest) plugin.load() plugin.registerFormattingActionForTest({ id: 'removable-action', icon: 'bold', title: 'Removable', callback: () => {}, }) // Verify action exists const actionsBefore = app.reamsFormattingActions.getActions() expect(actionsBefore.some(a => a.id === 'removable-action')).toBe(true) // Remove it plugin.removeFormattingActionForTest('removable-action') // Verify action is removed const actionsAfter = app.reamsFormattingActions.getActions() expect(actionsAfter.some(a => a.id === 'removable-action')).toBe(false) app.destroy() }) }) describe('registerCodeMirror', () => { it('is a callable no-op (CM5 legacy)', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() const callback = vi.fn() expect(() => plugin.registerCodeMirror(callback)).not.toThrow() // Callback should never be invoked (no CM5 instances) expect(callback).not.toHaveBeenCalled() app.destroy() }) }) describe('registerConstruct', () => { afterEach(() => { getCoreSemanticRegistry().unregisterPluginSpecs('test-plugin') vi.restoreAllMocks() }) it('no-ops when plugin is not loaded', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) const registerSpy = vi.spyOn(app, 'registerConstruct') plugin.registerConstruct({ name: 'dead-spec', schemaVersion: 1, defaultExpand: 'both', categories: ['inline'], synthesisPrecedence: 100, serializeAttrs: () => '', parseAttrs: () => null, classify: () => ({ kind: 'clean' }), rewriteForLocalState: () => [], synthesizeFromText: () => [], } as ConstructSpec) expect(registerSpy).not.toHaveBeenCalled() }) it('delegates to app.registerConstruct when loaded', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() const registerSpy = vi.spyOn(app, 'registerConstruct') const spec = { name: 'live-spec', schemaVersion: 1, defaultExpand: 'both', categories: ['inline'], synthesisPrecedence: 100, serializeAttrs: () => '', parseAttrs: () => null, classify: () => ({ kind: 'clean' }), rewriteForLocalState: () => [], synthesizeFromText: () => [], } as ConstructSpec plugin.registerConstruct(spec) expect(registerSpy).toHaveBeenCalledWith('test-plugin', spec) }) it('unregisters constructs on unload', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() plugin.registerConstruct({ name: 'unload-spec', schemaVersion: 1, defaultExpand: 'both', categories: ['inline'], synthesisPrecedence: 100, serializeAttrs: () => '', parseAttrs: () => null, classify: () => ({ kind: 'clean' }), rewriteForLocalState: () => [], synthesizeFromText: () => [], } as ConstructSpec) expect(getCoreSemanticRegistry().getSpec('unload-spec')).not.toBeNull() plugin.unload() expect(getCoreSemanticRegistry().getSpec('unload-spec')).toBeNull() }) }) describe('registerEditorExtension', () => { it('no-ops when plugin is not loaded', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) // Don't call load() const registerSpy = vi.spyOn(app.workspace, 'registerEditorExtension') // Create a simple extension (StateField) const extension: Extension = [] // empty extension array plugin.registerEditorExtension(extension) // Should not have called workspace.registerEditorExtension expect(registerSpy).not.toHaveBeenCalled() registerSpy.mockRestore() app.destroy() }) it('delegates to workspace.registerEditorExtension when loaded', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() const registerSpy = vi.spyOn(app.workspace, 'registerEditorExtension') const extension: Extension = [] plugin.registerEditorExtension(extension) expect(registerSpy).toHaveBeenCalledWith('test-plugin', extension) registerSpy.mockRestore() app.destroy() }) }) describe('plugin data identity', () => { it('uses the plugin manifest ID in the vault-state capability', async () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) // Save data await plugin.saveData({ test: 'value' }) expect(app.reamsStateData!.save).toHaveBeenCalledWith('plugin-data', 'test-plugin', { test: 'value' }) app.destroy() }) }) describe('registerMarkdownCodeBlockProcessor', () => { it('returns a processor that handles code blocks with the given language', async () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() const handler = vi.fn() const processor = plugin.registerMarkdownCodeBlockProcessor('test-lang', handler) // Create a mock element with the code block const el = document.createElement('div') el.innerHTML = '
code content
' // Create a minimal mock context (cast via unknown to bypass strict type checking) const ctx = { docId: 'test', sourcePath: 'test.md', frontmatter: null, addChild: vi.fn(), getSectionInfo: vi.fn() } as unknown as import('../types').MarkdownPostProcessorContext processor(el, ctx) expect(handler).toHaveBeenCalledWith('code content', expect.any(HTMLElement), ctx) app.destroy() }) it('processes multiple code blocks in the same element', async () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() const handler = vi.fn() const processor = plugin.registerMarkdownCodeBlockProcessor('multi-lang', handler) const el = document.createElement('div') el.innerHTML = `
first block
second block
` // Create a minimal mock context const ctx = { docId: 'test', sourcePath: 'test.md', frontmatter: null, addChild: vi.fn(), getSectionInfo: vi.fn() } as unknown as import('../types').MarkdownPostProcessorContext processor(el, ctx) expect(handler).toHaveBeenCalledTimes(2) expect(handler).toHaveBeenNthCalledWith(1, 'first block', expect.any(HTMLElement), ctx) expect(handler).toHaveBeenNthCalledWith(2, 'second block', expect.any(HTMLElement), ctx) app.destroy() }) it('handles empty code blocks', async () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() const handler = vi.fn() const processor = plugin.registerMarkdownCodeBlockProcessor('empty-lang', handler) const el = document.createElement('div') el.innerHTML = '
' // Create a minimal mock context const ctx = { docId: 'test', sourcePath: 'test.md', frontmatter: null, addChild: vi.fn(), getSectionInfo: vi.fn() } as unknown as import('../types').MarkdownPostProcessorContext processor(el, ctx) expect(handler).toHaveBeenCalledWith('', expect.any(HTMLElement), ctx) app.destroy() }) }) describe('onUserEnable', () => { it('is callable and can be overridden', () => { const app = createTestApp() const onUserEnableSpy = vi.fn() class TestPlugin extends Plugin { onUserEnable() { onUserEnableSpy() } } const plugin = new TestPlugin(app, testManifest) plugin.load() plugin.onUserEnable() expect(onUserEnableSpy).toHaveBeenCalled() app.destroy() }) it('default implementation does not throw', () => { const app = createTestApp() class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, testManifest) plugin.load() expect(() => plugin.onUserEnable()).not.toThrow() app.destroy() }) }) }) import { makeAppForTest, TestView as ContentTypeTestView, } from './test-helpers' import { Plugin as PluginClass } from '../plugin' describe('Plugin.registerViewForContentType', () => { const contentTypeManifest: PluginManifest = { id: 'reams-content-type-test', name: 'Content Type Test', author: 'Reams', version: '1.0.0', minAppVersion: '1.0.0', description: 'CT routing test plugin', } class TestPlugin extends PluginClass { override async onload(): Promise {} } async function makeLoadedPlugin() { const ctx = await makeAppForTest() const plugin = new TestPlugin(ctx.app, contentTypeManifest) ctx.loader.registerBuiltinInstance(plugin) await ctx.loader.enablePlugin(contentTypeManifest.id) return { plugin, ...ctx } } it('tracks bindings and tears them down on unload', async () => { const { plugin, app } = await makeLoadedPlugin() plugin.registerView('image-viewer', leaf => new ContentTypeTestView(leaf, 'image-viewer')) plugin.registerViewForContentType('public.image', 'image-viewer') expect(app.workspace.getViewTypeForContentType('public.png')).toBe('image-viewer') plugin.unload() expect(app.workspace.getViewTypeForContentType('public.png')).toBeUndefined() }) it('no-ops when called after the plugin is unloaded', async () => { const { plugin, app } = await makeLoadedPlugin() plugin.registerView('image-viewer', leaf => new ContentTypeTestView(leaf, 'image-viewer')) plugin.unload() plugin.registerViewForContentType('public.image', 'image-viewer') expect(app.workspace.getViewTypeForContentType('public.png')).toBeUndefined() }) it('Plugin.registerExtensions forwards to content-type binding for known extensions', async () => { const { plugin, app } = await makeLoadedPlugin() plugin.registerView('image-viewer', leaf => new ContentTypeTestView(leaf, 'image-viewer')) plugin.registerExtensions(['png', 'jpg', 'never-known-ext'], 'image-viewer') expect(app.workspace.getViewTypeForContentType('public.png')).toBe('image-viewer') expect(app.workspace.getViewTypeForContentType('public.jpeg')).toBe('image-viewer') plugin.unload() expect(app.workspace.getViewTypeForContentType('public.png')).toBeUndefined() expect(app.workspace.getViewTypeForContentType('public.jpeg')).toBeUndefined() }) })