Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358// src/plugin/__tests__/plugin.test.tsimport { 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<string, unknown>() 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<void> { 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 = '<pre><code class="language-test-lang">code content</code></pre>'
// 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 = ` <pre><code class="language-multi-lang">first block</code></pre> <pre><code class="language-multi-lang">second block</code></pre> `
// 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 = '<pre><code class="language-empty-lang"></code></pre>'
// 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<void> {} }
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() })})