// src/plugin/__tests__/app-managers.test.ts import { 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() 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() 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 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() }) })