Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183// src/plugin/__tests__/app-managers.test.tsimport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'import { App } from '../app'import { Vault } from '../vault'import { FileIndex } from '../../services/file-index'import { Workspace } from '../workspace'import { Plugin } from '../plugin'import { CommandRegistry } from '../commands'import { HotkeyManager } from '../commands'import { RibbonActionManager, StatusBarActionManager, ToolbarChromeStore } from '../ui'import { FileManager } from '../file-manager'import { Scope, SecretStorage } from '../obsidian-stubs'import type { PluginManifest } from '../types'import type { AutomergeUrl } from '@automerge/automerge-repo'import type { EventRefImpl } from '../events'import { withMockDirectoryEvidence } from './mock-directory-evidence'
function createMockDocumentManager() { return withMockDirectoryEvidence({ createFile: vi.fn(), createFolder: vi.fn(), createFileInFolder: vi.fn().mockImplementation(async () => `automerge:file-${Math.random().toString(36).slice(2)}` as AutomergeUrl), listFolder: vi.fn().mockResolvedValue([]), readText: vi.fn(async (url: AutomergeUrl) => ({ status: 'unavailable' as const, documentId: url })), updateContent: vi.fn(), } as any)}
describe('App manager integration', () => { let app: App
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as any), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace) })
afterEach(() => { app.destroy() })
it('has commands registry', () => { expect(app.commands).toBeInstanceOf(CommandRegistry) })
it('has hotkeys manager', () => { expect(app.hotkeys).toBeInstanceOf(HotkeyManager) })
it('has internal reamsRibbon manager', () => { expect(app.reamsRibbon).toBeInstanceOf(RibbonActionManager) })
it('has internal reamsStatusBar manager', () => { expect(app.reamsStatusBar).toBeInstanceOf(StatusBarActionManager) })
it('has internal reamsToolbarChrome store', () => { expect(app.reamsToolbarChrome).toBeInstanceOf(ToolbarChromeStore) })
it('has fileManager', () => { expect(app.fileManager).toBeInstanceOf(FileManager) })
it('has scope (global Scope instance)', () => { expect(app.scope).toBeInstanceOf(Scope) })
it('has keymap (Keymap instance for scope/modifier key handling)', () => { // keymap is a Keymap instance (matching upstream Obsidian contract) // Note: This is a separate object from hotkeys (HotkeyManager), which manages // the actual key bindings. The Keymap class provides pushScope/popScope stubs // and static isModifier/isModEvent helpers. expect(app.keymap).toBeDefined() expect(typeof app.keymap.pushScope).toBe('function') expect(typeof app.keymap.popScope).toBe('function') })
it('has internalPlugins API that returns null for unknown IDs', () => { expect(app.internalPlugins).toBeDefined() // Unknown IDs (not in the Obsidian-to-Reams mapping) return null const unknownResult = app.internalPlugins.getPluginById('anything') expect(unknownResult).toBeNull() // Known IDs without the plugin loaded also return null const knownButUnloadedResult = app.internalPlugins.getPluginById('daily-notes') expect(knownButUnloadedResult).toBeNull() })
it('has plugins wrapper (returns null when no loader set)', () => { expect(app.plugins).toBeDefined() expect(app.plugins.getPlugin('anything')).toBeNull() expect(app.plugins.plugins).toEqual({}) })})
describe('Plugin delegation to App managers', () => { let app: App const manifest: PluginManifest = { id: 'test-plugin', name: 'Test', author: 'Test', version: '1.0.0', minAppVersion: '0.0.0', description: 'Test', }
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as any), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace) })
afterEach(() => { app.destroy() })
describe('addCommand', () => { it('registers command in App.commands', () => { class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, manifest) plugin.load()
plugin.addCommand({ id: 'my-cmd', name: 'My Command', callback: vi.fn(), })
expect(app.commands.getCommand('test-plugin:my-cmd')).toBeDefined() })
it('registers default hotkeys', () => { class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, manifest) plugin.load()
plugin.addCommand({ id: 'my-cmd', name: 'My Command', callback: vi.fn(), hotkeys: [{ modifiers: ['Mod'], key: 'm' }], })
expect(app.hotkeys.getHotkeyForCommand('test-plugin:my-cmd')).toBeDefined() }) })
describe('addRibbonIcon', () => { it('adds icon to App.reamsRibbon', () => { class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, manifest) plugin.load()
plugin.addRibbonIcon('settings', 'Settings', vi.fn())
expect(app.reamsRibbon.getIcons().some(i => i.icon === 'settings')).toBe(true) }) })
describe('addStatusBarItem', () => { it('adds item to App.reamsStatusBar', () => { class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, manifest) plugin.load()
const el = plugin.addStatusBarItem()
expect(el).toBeInstanceOf(HTMLElement) expect(app.reamsStatusBar.getItems().some(i => i.el === el)).toBe(true) }) })
describe('App.plugins with PluginLoader', () => { let app: App let loader: any // PluginLoader mock
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as any), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace)
// Create a mock PluginLoader const activePlugins = new Map<string, Plugin>() loader = { getPlugin: vi.fn((id: string) => activePlugins.get(id) ?? null), getPluginIds: vi.fn(() => Array.from(activePlugins.keys())), enablePlugin: vi.fn(), disablePlugin: vi.fn(), _activePlugins: activePlugins, } app.setPluginLoader(loader) })
afterEach(() => { app.destroy() })
it('getPlugin delegates to loader', () => { app.plugins.getPlugin('test') expect(loader.getPlugin).toHaveBeenCalledWith('test') })
it('enablePlugin delegates to loader', async () => { await app.plugins.enablePlugin('test') expect(loader.enablePlugin).toHaveBeenCalledWith('test', undefined) })
it('disablePlugin delegates to loader', () => { app.plugins.disablePlugin('test') expect(loader.disablePlugin).toHaveBeenCalledWith('test') })
it('plugins record contains active plugins', () => { class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, { id: 'test', name: 'Test', author: 'A', version: '1.0.0', minAppVersion: '0.0.0', description: 'Test', }) loader._activePlugins.set('test', plugin)
const record = app.plugins.plugins expect(record['test']).toBe(plugin) })
it('plugins record is a plain object (Record), not a Map', () => { const record = app.plugins.plugins expect(typeof record).toBe('object') expect(record).not.toBeInstanceOf(Map) })})
describe('cleanup on unload', () => { it('removes commands when plugin unloads', () => { class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, manifest) plugin.load() // Must load before unload plugin.addCommand({ id: 'cmd', name: 'Cmd', callback: vi.fn() })
plugin.unload()
expect(app.commands.getCommand('test-plugin:cmd')).toBeUndefined() })
it('removes ribbon icons when plugin unloads', () => { class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, manifest) plugin.load() plugin.addRibbonIcon('icon', 'Icon', vi.fn())
plugin.unload()
expect(app.reamsRibbon.getIcons()).toHaveLength(0) })
it('removes status bar items when plugin unloads', () => { class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, manifest) plugin.load() plugin.addStatusBarItem()
plugin.unload()
expect(app.reamsStatusBar.getItems()).toHaveLength(0) })
it('removes hotkey bindings when plugin unloads', () => { class TestPlugin extends Plugin {} const plugin = new TestPlugin(app, manifest) plugin.load() plugin.addCommand({ id: 'shortcut-cmd', name: 'Shortcut Command', callback: vi.fn(), hotkeys: [{ modifiers: ['Mod'], key: 'k' }], })
expect(app.hotkeys.getHotkeyForCommand('test-plugin:shortcut-cmd')).toBeDefined()
plugin.unload()
expect(app.hotkeys.getHotkeyForCommand('test-plugin:shortcut-cmd')).toBeUndefined() }) })})
describe('App.navigateToFile', () => { let app: App
beforeEach(async () => { const dm = createMockDocumentManager() const fileIndex = new FileIndex(dm, 'automerge:root' as AutomergeUrl) const vault = new Vault('Test', fileIndex, dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace) // Create a test file in the vault await vault.create('test.md', '# Test') })
afterEach(() => { app.destroy() })
it('returns true and calls onNavigate when file exists and handler is set', async () => { const handler = vi.fn() app.setNavigateHandler(handler)
const result = app.navigateToFile('test.md', 'test.md')
expect(result).toBe(true) expect(handler).toHaveBeenCalledWith(expect.any(String), 'test.md') })
it('returns false when file does not exist in vault', () => { const handler = vi.fn() app.setNavigateHandler(handler)
const result = app.navigateToFile('nonexistent.md', 'nonexistent.md')
expect(result).toBe(false) expect(handler).not.toHaveBeenCalled() })
it('returns false when navigate handler is not set', async () => { // No handler set const result = app.navigateToFile('test.md', 'test.md')
expect(result).toBe(false) })
it('returns false when file exists but handler is null', async () => { app.setNavigateHandler(null)
const result = app.navigateToFile('test.md', 'test.md')
expect(result).toBe(false) })})
describe('App.isDarkMode', () => { let app: App let originalMatchMedia: typeof window.matchMedia
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace) originalMatchMedia = window.matchMedia })
afterEach(() => { window.matchMedia = originalMatchMedia app.destroy() })
it('returns true when prefers-color-scheme is dark', () => { window.matchMedia = vi.fn().mockReturnValue({ matches: true })
expect(app.isDarkMode()).toBe(true) expect(window.matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)') })
it('returns false when prefers-color-scheme is light', () => { window.matchMedia = vi.fn().mockReturnValue({ matches: false })
expect(app.isDarkMode()).toBe(false) })
it('returns true when matchMedia is undefined (fallback)', () => { // @ts-expect-error - testing undefined matchMedia window.matchMedia = undefined
expect(app.isDarkMode()).toBe(true) })})
describe('App.loadLocalStorage and App.saveLocalStorage', () => { let app: App
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('TestVault', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace) // Clear relevant localStorage keys localStorage.clear() })
afterEach(() => { localStorage.clear() app.destroy() })
it('saves and loads a string value', () => { app.saveLocalStorage('test-key', 'test-value')
const loaded = app.loadLocalStorage('test-key')
expect(loaded).toBe('test-value') })
it('saves and loads an object value', () => { const obj = { foo: 'bar', num: 42 } app.saveLocalStorage('object-key', obj)
const loaded = app.loadLocalStorage('object-key')
expect(loaded).toEqual(obj) })
it('saves and loads an array value', () => { const arr = [1, 2, 3] app.saveLocalStorage('array-key', arr)
const loaded = app.loadLocalStorage('array-key')
expect(loaded).toEqual(arr) })
it('returns null for non-existent key', () => { const loaded = app.loadLocalStorage('nonexistent-key')
expect(loaded).toBeNull() })
it('clears value when saving null', () => { app.saveLocalStorage('to-clear', 'value') expect(app.loadLocalStorage('to-clear')).toBe('value')
app.saveLocalStorage('to-clear', null)
expect(app.loadLocalStorage('to-clear')).toBeNull() })
it('returns null for invalid JSON', () => { // Manually set invalid JSON in localStorage const key = `reams:vault:TestVault:invalid-json` localStorage.setItem(key, '{not valid json')
const loaded = app.loadLocalStorage('invalid-json')
expect(loaded).toBeNull() })
it('uses vault name in storage prefix', () => { app.saveLocalStorage('prefix-test', 'value')
// Check that the key is prefixed correctly const storedValue = localStorage.getItem('reams:vault:TestVault:prefix-test') expect(storedValue).toBe('"value"') })
it('does not throw when localStorage is unavailable (save)', () => { const originalSetItem = localStorage.setItem localStorage.setItem = vi.fn().mockImplementation(() => { throw new Error('QuotaExceededError') })
expect(() => app.saveLocalStorage('key', 'value')).not.toThrow()
localStorage.setItem = originalSetItem })
it('does not throw when localStorage is unavailable (load)', () => { const originalGetItem = localStorage.getItem localStorage.getItem = vi.fn().mockImplementation(() => { throw new Error('SecurityError') })
expect(() => app.loadLocalStorage('key')).not.toThrow() expect(app.loadLocalStorage('key')).toBeNull()
localStorage.getItem = originalGetItem })})
describe('App.destroy and App.reattach', () => { let app: App
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace) })
it('detaches hotkeys on destroy', () => { const detachSpy = vi.spyOn(app.hotkeys, 'detach')
app.destroy()
expect(detachSpy).toHaveBeenCalled() })
it('destroys scope on destroy', () => { // Register a handler so we can verify it's cleaned up const cb = vi.fn() app.scope.register([], 'a', cb)
// Verify handler works before destroy document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })) expect(cb).toHaveBeenCalledTimes(1)
app.destroy()
// After destroy, handler should no longer fire cb.mockClear() document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })) expect(cb).not.toHaveBeenCalled() })
it('reattaches hotkeys on reattach', () => { const reattachSpy = vi.spyOn(app.hotkeys, 'reattach')
app.reattach()
expect(reattachSpy).toHaveBeenCalled() })
it('can survive destroy/reattach cycle (StrictMode pattern)', () => { // Register a command before destroy // addCommand takes (pluginId, {id: localId, ...}) and returns prefixed id app.commands.addCommand('test-plugin', { id: 'test', name: 'Test', callback: vi.fn() })
app.destroy() app.reattach()
// Command should still be registered after cycle (stored as pluginId:localId) expect(app.commands.getCommand('test-plugin:test')).toBeDefined() })})
describe('App.setDocumentManager and internal App.reamsDocumentManager getter', () => { let app: App
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace) })
afterEach(() => { app.destroy() })
it('throws when reamsDocumentManager is accessed before being set', () => { expect(() => app.reamsDocumentManager).toThrow('DocumentManager not initialized') })
it('returns reamsDocumentManager after it is set', () => { const mockDm = { mock: true } as any app.setDocumentManager(mockDm)
expect(app.reamsDocumentManager).toBe(mockDm) })})
describe('App.setMetadataCache and App.metadataCache getter', () => { let app: App
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace) })
afterEach(() => { app.destroy() })
it('returns undefined when metadataCache is accessed before being set', () => { // metadataCache is now a property (per upstream Obsidian API contract), // so accessing before initialization returns undefined rather than throwing expect(app.metadataCache).toBeUndefined() })
it('returns metadataCache after it is set', () => { const mockCache = { mock: true } as any app.setMetadataCache(mockCache)
expect(app.metadataCache).toBe(mockCache) })})
describe('App.setSearchService and internal App.reamsSearch getter (B.1)', () => { let app: App
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace) })
afterEach(() => { app.destroy() })
it('throws when reamsSearch is accessed before being set', () => { expect(() => app.reamsSearch).toThrow('SearchService not initialized') })
it('returns reamsSearch after it is set', () => { const mockService = { mock: true } as any app.setSearchService(mockService)
expect(app.reamsSearch).toBe(mockService) })
it('hasSearchService returns false before set, true after set', () => { expect(app.hasSearchService()).toBe(false) const mockService = { mock: true } as any app.setSearchService(mockService) expect(app.hasSearchService()).toBe(true) })
it('setSearchService replaces existing service', () => { const first = { id: 1 } as any const second = { id: 2 } as any app.setSearchService(first) expect(app.reamsSearch).toBe(first) app.setSearchService(second) expect(app.reamsSearch).toBe(second) })})
describe('App.internalPlugins stubs', () => { let app: App
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace) })
afterEach(() => { app.destroy() })
it('getPluginById returns null when reams plugin is not loaded', () => { // Without a plugin loader, known IDs return null const result = app.internalPlugins.getPluginById('daily-notes') expect(result).toBeNull() })
it('getEnabledPluginById returns null', () => { const result = app.internalPlugins.getEnabledPluginById('any-id') expect(result).toBeNull() })})
describe('App.dragManager stubs', () => { let app: App
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace) })
afterEach(() => { app.destroy() })
it('dragFile returns null', () => { // Use a plain object since DragEvent is not available in jsdom const mockEvent = { type: 'drag' } as DragEvent const result = app.dragManager.dragFile(mockEvent, {}) expect(result).toBeNull() })
it('onDragStart does not throw', () => { const mockEvent = { type: 'dragstart' } as DragEvent expect(() => app.dragManager.onDragStart(mockEvent, {})).not.toThrow() })})
describe('App.foldManager stubs', () => { let app: App
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace) })
afterEach(() => { app.destroy() })
it('save does not throw', () => { expect(() => app.foldManager.save({})).not.toThrow() })
it('load returns null', () => { const result = app.foldManager.load({}) expect(result).toBeNull() })})
describe('App.plugins.enabledPlugins', () => { let app: App
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace) })
afterEach(() => { app.destroy() })
it('returns empty set when no loader is set', () => { const enabled = app.plugins.enabledPlugins expect(enabled).toBeInstanceOf(Set) expect(enabled.size).toBe(0) })
it('returns set of active plugin IDs when loader is set', () => { const loader = { getPlugin: vi.fn(), getPluginIds: vi.fn().mockReturnValue(['plugin-a', 'plugin-b', 'plugin-c']), getPluginState: vi.fn((id: string) => { if (id === 'plugin-a' || id === 'plugin-c') return 'active' return 'disabled' }), enablePlugin: vi.fn(), disablePlugin: vi.fn(), on: vi.fn().mockReturnValue({ id: Symbol() }), offref: vi.fn(), } app.setPluginLoader(loader)
const enabled = app.plugins.enabledPlugins
expect(enabled.size).toBe(2) expect(enabled.has('plugin-a')).toBe(true) expect(enabled.has('plugin-c')).toBe(true) expect(enabled.has('plugin-b')).toBe(false) })
it('enabledPlugins is recomputed on each access (not stale)', () => { const activeIds = new Set<string>() const loader = { getPlugin: vi.fn(), getPluginIds: vi.fn().mockReturnValue(['p1']), getPluginState: vi.fn((id: string) => activeIds.has(id) ? 'active' : 'disabled'), enablePlugin: vi.fn(), disablePlugin: vi.fn(), on: vi.fn().mockReturnValue({ id: Symbol() }), offref: vi.fn(), } app.setPluginLoader(loader)
expect(app.plugins.enabledPlugins.has('p1')).toBe(false)
activeIds.add('p1') expect(app.plugins.enabledPlugins.has('p1')).toBe(true) })})
describe('App.plugins.enablePlugin without loader', () => { let app: App
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace) // NO loader set })
afterEach(() => { app.destroy() })
it('returns resolved promise when no loader is set', async () => { await expect(app.plugins.enablePlugin('any-plugin')).resolves.toBeUndefined() })})
describe('App.plugins.disablePlugin without loader', () => { let app: App
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace) // NO loader set })
afterEach(() => { app.destroy() })
it('does not throw when no loader is set', () => { expect(() => app.plugins.disablePlugin('any-plugin')).not.toThrow() })})
describe('App constructor side effects', () => { it('sets workspace.app back-reference', () => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div'))
const app = new App(vault, workspace)
expect(workspace.app).toBe(app) app.destroy() })
it('sets window.app global', () => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div'))
const app = new App(vault, workspace)
expect(window.app).toBe(app) app.destroy() })
it('sets reamsDom.appContainerEl to workspace.containerEl', () => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const container = document.createElement('div') const workspace = new Workspace(container)
const app = new App(vault, workspace)
expect(app.reamsDom.appContainerEl).toBe(container) app.destroy() })
it('creates reamsFormattingActions without builtin actions (registered via BuiltinFormattingPlugin)', () => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div'))
const app = new App(vault, workspace)
// FormattingActionManager exists but is empty - builtin actions are now // registered via BuiltinFormattingPlugin (chainlink #355) expect(app.reamsFormattingActions).toBeDefined() const actions = app.reamsFormattingActions.getActions() expect(actions.length).toBe(0) app.destroy() })
it('customCss.theme is an empty string by default', () => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div'))
const app = new App(vault, workspace)
// Periodic Notes checks this stub - it should be an empty string expect(app.customCss.theme).toBe('') app.destroy() })})
describe('App.plugins.plugins record filtering', () => { let app: App
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace) })
afterEach(() => { app.destroy() })
it('does not include null plugins in the record', () => { // Create a loader where getPlugin returns null for some IDs const loader = { getPlugin: vi.fn((id: string) => { if (id === 'exists') { return { id: 'exists' } as any } return null // 'missing' returns null }), getPluginIds: vi.fn().mockReturnValue(['exists', 'missing']), getPluginState: vi.fn(), enablePlugin: vi.fn(), disablePlugin: vi.fn(), on: vi.fn().mockReturnValue({ id: Symbol() }), offref: vi.fn(), } app.setPluginLoader(loader)
const record = app.plugins.plugins
// Should only contain 'exists', not 'missing' expect(Object.keys(record)).toEqual(['exists']) expect(record['missing']).toBeUndefined() })})
describe('App.loadLocalStorage edge cases', () => { let app: App
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('TestVault', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace) localStorage.clear() })
afterEach(() => { localStorage.clear() app.destroy() })
it('returns stored value when not null', () => { app.saveLocalStorage('key', { data: 'value' })
const result = app.loadLocalStorage('key')
expect(result).toEqual({ data: 'value' }) })
it('returns null for explicitly stored null (cleared key)', () => { app.saveLocalStorage('key', { data: 'value' }) app.saveLocalStorage('key', null) // Clear it
const result = app.loadLocalStorage('key')
expect(result).toBeNull() })
it('returns null immediately when getItem returns null (key never existed)', () => { // This exercises the early return: if (value === null) return null // Key was never set, so getItem returns null const result = app.loadLocalStorage('never-existed') expect(result).toBeNull() })
it('returns parsed value when getItem returns a valid JSON string', () => { // Manually set a value to ensure we hit the JSON.parse branch const key = 'reams:vault:TestVault:manual-key' localStorage.setItem(key, '"hello"')
const result = app.loadLocalStorage('manual-key') expect(result).toBe('hello') })})
describe('App.saveLocalStorage edge cases', () => { let app: App
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('TestVault', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace) localStorage.clear() })
afterEach(() => { localStorage.clear() app.destroy() })
it('removes key when saving null', () => { const key = 'reams:vault:TestVault:to-remove' localStorage.setItem(key, '"existing"')
app.saveLocalStorage('to-remove', null)
expect(localStorage.getItem(key)).toBeNull() })
it('saves non-null value to localStorage', () => { app.saveLocalStorage('new-key', { foo: 'bar' })
const key = 'reams:vault:TestVault:new-key' expect(localStorage.getItem(key)).toBe('{"foo":"bar"}') })})
describe('App storage scopes', () => { const createScopedApp = (scope: string) => { const dm = createMockDocumentManager() const vault = new Vault('SharedVault', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) return new App(vault, workspace, { storageScope: scope }) }
afterEach(() => { localStorage.clear() })
it('isolates App local storage between ATProto accounts while preserving each account value', () => { const appA = createScopedApp('atproto:account-a') const appB = createScopedApp('atproto:account-b') try { appA.saveLocalStorage('plugin-settings', { account: 'A' }) appB.saveLocalStorage('plugin-settings', { account: 'B' })
expect(appA.loadLocalStorage('plugin-settings')).toEqual({ account: 'A' }) expect(appB.loadLocalStorage('plugin-settings')).toEqual({ account: 'B' }) } finally { appA.destroy() appB.destroy() } })
it('isolates SecretStorage between ATProto accounts', () => { const appA = createScopedApp('atproto:account-a') const appB = createScopedApp('atproto:account-b') try { appA.secretStorage.setSecret('plugin-token', 'token-A') appB.secretStorage.setSecret('plugin-token', 'token-B')
expect(appA.secretStorage.getSecret('plugin-token')).toBe('token-A') expect(appB.secretStorage.getSecret('plugin-token')).toBe('token-B') } finally { appA.destroy() appB.destroy() } })
it('scopes plugin-constructed SecretStorage to the active ATProto app', () => { const appA = createScopedApp('atproto:account-a') try { new SecretStorage().setSecret('external-token', 'A') expect(appA.secretStorage.getSecret('external-token')).toBe('A') const appB = createScopedApp('atproto:account-b') try { expect(new SecretStorage().getSecret('external-token')).toBeNull() expect(localStorage.getItem('reams:secret:external-token')).toBeNull() } finally { appB.destroy() } } finally { appA.destroy() } })
it('keeps Classic App local storage under the legacy vault key', () => { const dm = createMockDocumentManager() const vault = new Vault('ClassicVault', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const app = new App(vault, new Workspace(document.createElement('div'))) try { app.saveLocalStorage('plugin-settings', 'classic-value')
expect(localStorage.getItem('reams:vault:ClassicVault:plugin-settings')).toBe('"classic-value"') expect(app.loadLocalStorage('plugin-settings')).toBe('classic-value') } finally { app.destroy() } })
it('keeps Classic SecretStorage under the legacy secret key', () => { const dm = createMockDocumentManager() const vault = new Vault('ClassicVault', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const app = new App(vault, new Workspace(document.createElement('div'))) try { app.secretStorage.setSecret('plugin-token', 'classic-token')
expect(localStorage.getItem('reams:secret:plugin-token')).toBe('classic-token') expect(app.secretStorage.getSecret('plugin-token')).toBe('classic-token') } finally { app.destroy() } })})
describe('App.plugins event subscription (C.8)', () => { let app: App let loader: any
beforeEach(() => { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) app = new App(vault, workspace)
// Create a mock PluginLoader with event methods const listeners = new Map<string, Array<(...args: any[]) => void>>() loader = { getPlugin: vi.fn(), getPluginIds: vi.fn().mockReturnValue([]), getPluginState: vi.fn(), enablePlugin: vi.fn(), disablePlugin: vi.fn(), on: vi.fn((name: string, cb: (...args: any[]) => void) => { const callbacks = listeners.get(name) ?? [] callbacks.push(cb) listeners.set(name, callbacks) return { e: loader, n: name, fn: cb } }), off: vi.fn((name: string, cb: (...args: any[]) => void) => { const callbacks = listeners.get(name) if (callbacks) { const idx = callbacks.indexOf(cb) if (idx !== -1) callbacks.splice(idx, 1) } }), offref: vi.fn((ref: { e: any; n: string; fn: (...args: any[]) => void }) => { loader.off(ref.n, ref.fn) }), trigger: (name: string, ...args: any[]) => { const callbacks = listeners.get(name) ?? [] for (const cb of callbacks) cb(...args) }, _listeners: listeners, } app.setPluginLoader(loader) })
afterEach(() => { app.destroy() })
it('on() subscribes to plugin-state-change event and returns EventRef', () => { const callback = vi.fn()
const ref = app.plugins.on('plugin-state-change', callback)
expect(ref).toBeDefined() // Cast to internal type to verify implementation details const impl = ref as EventRefImpl expect(impl.e).toBeDefined() expect(impl.n).toBe('plugin-state-change') expect(impl.fn).toBeDefined() expect(loader.on).toHaveBeenCalledWith('plugin-state-change', expect.any(Function)) })
it('callback is invoked when plugin state changes', () => { const callback = vi.fn() app.plugins.on('plugin-state-change', callback)
loader.trigger('plugin-state-change', 'test-plugin', 'active', 'disabled')
expect(callback).toHaveBeenCalledWith('test-plugin', 'active', 'disabled') })
it('offref() unsubscribes from events', () => { const callback = vi.fn() const ref = app.plugins.on('plugin-state-change', callback)
app.plugins.offref(ref)
loader.trigger('plugin-state-change', 'test-plugin', 'active', 'disabled') expect(callback).not.toHaveBeenCalled() })
it('on() returns no-op EventRef when no loader is set', () => { const noLoaderApp = (() => { const dm = createMockDocumentManager() const vault = new Vault('NoLoader', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) return new App(vault, workspace) })()
const callback = vi.fn() const ref = noLoaderApp.plugins.on('plugin-state-change', callback)
expect(ref).toBeDefined() // offref should not throw expect(() => noLoaderApp.plugins.offref(ref)).not.toThrow()
noLoaderApp.destroy() })})