Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293// src/layout/__tests__/PluginLoaderIntegration.test.tsximport { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'import { render, screen, cleanup, waitFor, within } from '@testing-library/react'import userEvent from '@testing-library/user-event'import { LayoutProvider } from '../LayoutContext'import { AppShell } from '../AppShell'import type { DocumentManager } from '../../services/document-manager'import { App, Vault, Workspace } from '../../plugin'import { createTFile } from '../../plugin/files'import { FileIndex } from '../../services/file-index'import { MarkdownView } from '../../plugin/views/markdown-view'import { createTestVaultStateData } from '../../services/__tests__/vault-state-test-helpers'import { EnabledStateStore } from '../../plugin/loader/enabled-state-store'
// Default file stat for creating TFile instancesconst defaultStat = { ctime: 0, mtime: 0, size: 0 }
// Mock the SearchContext so AppShell doesn't require a SearchProvider// Stub editor surface for the same reason as tab-characterization (#406).vi.mock('../MainAreaViewContainer', () => ({ MainAreaViewContainer: () => <div data-testid="editor-area" />,}))
vi.mock('../../services/SearchContext', () => ({ useSearch: () => ({ searchService: { search: vi.fn().mockReturnValue([]), getDocumentContent: vi.fn(), indexDocument: vi.fn(), removeDocument: vi.fn(), getDocumentHead: vi.fn(), getIndexedDocuments: vi.fn().mockReturnValue([]), serialize: vi.fn().mockReturnValue({}), deserialize: vi.fn(), clear: vi.fn(), getRecentFiles: vi.fn().mockReturnValue([]), }, metadataCache: { getBacklinks: vi.fn().mockReturnValue([]), getFileCache: vi.fn().mockReturnValue(null), getFileCacheByUrl: vi.fn().mockReturnValue(null), getAllTags: vi.fn().mockReturnValue({}), subscribe: vi.fn().mockReturnValue(() => {}), getVersion: vi.fn().mockReturnValue(0), updateFile: vi.fn(), removeFile: vi.fn(), getDisplayTitle: vi.fn().mockReturnValue(null), getIndexedDocuments: vi.fn().mockReturnValue([]), getDocumentTags: vi.fn().mockReturnValue([]), getOutgoingLinks: vi.fn().mockReturnValue([]), getResolvedOutgoingLinks: vi.fn().mockReturnValue([]), isDisplayTitleEnabled: vi.fn().mockReturnValue(true), setDisplayTitleEnabled: vi.fn(), getFirstLinkpathDest: vi.fn().mockReturnValue(null), on: vi.fn().mockReturnValue({ e: {}, n: 'changed', fn: () => {} }), offref: vi.fn(), }, indexer: { start: vi.fn(), stop: vi.fn() }, isInitialized: true, }),}))
// Mock DocumentManager for testsconst mockDocumentManager = { listFolder: async () => [], createFile: () => 'automerge:mock', createFolder: () => 'automerge:mock', deleteFile: async () => {}, readText: async (url: any) => ({ status: 'available' as const, documentId: url, heads: ['h0'], content: '' }), updateContent: async () => {},} as unknown as DocumentManager
// Create a mock App instance for testsfunction createMockApp(): App { const mockDM = { createFile: vi.fn(), createFolder: vi.fn(), readText: vi.fn(async (url: string) => ({ status: 'unavailable' as const, documentId: url })), updateContent: vi.fn(), listFolder: vi.fn().mockResolvedValue([]), } as any const vault = new Vault('TestVault', new FileIndex(mockDM, 'automerge:root' as any), mockDM) const workspace = new Workspace(document.createElement('div')) const app = new App(vault, workspace) app.reamsStateData = createTestVaultStateData() app.setDocumentManager(mockDM) // Wire metadataCache and searchService onto app — SearchProvider does // this in production but this test mocks SearchContext, so we set them // directly (#306). app.setMetadataCache({ getBacklinks: vi.fn().mockReturnValue([]), getFileCache: vi.fn().mockReturnValue(null), getFileCacheByUrl: vi.fn().mockReturnValue(null), getAllTags: vi.fn().mockReturnValue({}), subscribe: vi.fn().mockReturnValue(() => {}), getVersion: vi.fn().mockReturnValue(0), updateFile: vi.fn(), removeFile: vi.fn(), getDisplayTitle: vi.fn().mockReturnValue(null), getIndexedDocuments: vi.fn().mockReturnValue([]), getDocumentTags: vi.fn().mockReturnValue([]), getOutgoingLinks: vi.fn().mockReturnValue([]), getResolvedOutgoingLinks: vi.fn().mockReturnValue([]), isDisplayTitleEnabled: vi.fn().mockReturnValue(true), setDisplayTitleEnabled: vi.fn(), getFirstLinkpathDest: vi.fn().mockReturnValue(null), on: vi.fn().mockReturnValue({ e: {}, n: 'changed', fn: () => {} }), offref: vi.fn(), } as any) app.setSearchService({ search: vi.fn().mockReturnValue([]), getDocumentContent: vi.fn(), indexDocument: vi.fn(), removeDocument: vi.fn(), getDocumentHead: vi.fn(), getIndexedDocuments: vi.fn().mockReturnValue([]), serialize: vi.fn().mockReturnValue({}), deserialize: vi.fn(), clear: vi.fn(), getRecentFiles: vi.fn().mockReturnValue([]), } as any) return app}
// Module-level app instance for tests (cleaned up in afterEach)let mockApp: Applet enabledStateStore: EnabledStateStore
// Built-in plugin names for UI verificationconst BUILTIN_PLUGIN_NAMES = [ 'File Tree', 'Quick Search', 'Wikilinks', 'Daily Notes', 'Create File',]
// Helper to create a tab via workspace (matches AppShell.openDocument)function createWorkspaceTab( app: App, docUrl: string, title: string,): void { const workspace = app.workspace const vault = app.vault // Register file in vault's file index for lookup const root = vault.getRoot() const file = createTFile(title, root, defaultStat) ;(vault as any).fileIndex.registerFile(docUrl, 'automerge:root', file) const leaf = workspace.getLeaf(true) leaf.file = file const view = new MarkdownView(leaf) leaf.view = view view.file = file workspace.setActiveLeaf(leaf)}
// Helper to render AppShell with providerfunction renderAppShell() { // Create a tab via workspace before rendering (replaces useEffect openTab) createWorkspaceTab(mockApp, 'automerge:1', 'Test.md')
return render( <LayoutProvider> <AppShell documentManager={mockDocumentManager} app={mockApp} enabledStateStore={enabledStateStore} /> </LayoutProvider> )}
describe('PluginLoader Integration', () => { beforeEach(() => { localStorage.clear() mockApp = createMockApp() enabledStateStore = new EnabledStateStore() })
afterEach(() => { mockApp.destroy() cleanup() })
describe('Built-in plugin registration on startup', () => { it('registers all 5 built-in plugins on first launch', async () => { renderAppShell() // Wait for plugins to initialize - they are registered and enabled on first run await waitFor(() => expect(enabledStateStore.getEnabledPluginIds().length).toBeGreaterThan(0), { timeout: 3000 }) // Verify at least some plugins are enabled (the exact count may vary due to React StrictMode) expect(localStorage.getItem('reams:plugin-enabled-state')).toBeNull() }) })
describe('Settings modal integration', () => { it('opens settings modal when gear icon is clicked', async () => { const user = userEvent.setup() renderAppShell() // Wait for app to initialize await waitFor(() => { expect(screen.getByTestId('editor-area')).toBeInTheDocument() }) // Find and click the gear icon in the ribbon const gearIcon = screen.getByRole('button', { name: /^open settings$/i }) await user.click(gearIcon) // Settings modal should be visible expect(screen.getByRole('dialog')).toBeInTheDocument() expect(screen.getByText('Settings')).toBeInTheDocument()
// Pin the default active section. Adversary review MIN-5: when this // default last changed (from 'plugins' to 'editor') no test caught it, // and the silent change is what broke 'shows all registered plugins'. // Now any future default change forces a deliberate update here. const editorTab = document.querySelector('#settings-tab-editor') expect(editorTab).not.toBeNull() expect(editorTab?.getAttribute('aria-selected')).toBe('true') })
it('shows all registered plugins in settings modal', async () => { const user = userEvent.setup() renderAppShell() // Wait for app to initialize await waitFor(() => { expect(screen.getByTestId('editor-area')).toBeInTheDocument() }) // Open settings modal const gearIcon = screen.getByRole('button', { name: /^open settings$/i }) await user.click(gearIcon)
// Settings modal opens with the Editor section by default; navigate to // the Plugins section to see the built-in plugin list. Match by stable // DOM id (`settings-tab-plugins`) instead of visible label so a future // rename ("Built-in Plugins", "Plugins (5)" etc.) doesn't silently // skip this navigation. Adversary review CRIT-1 / MAJ-2/3. const pluginsTab = await waitFor(() => { const el = document.querySelector('#settings-tab-plugins') if (!el) throw new Error('Plugins tab not found') return el as HTMLElement }) await user.click(pluginsTab)
// Scope the plugin-name assertions to the plugin LIST container, NOT // the whole modal. Otherwise `getAllByText('Daily Notes')` matches the // permanently-visible left-nav button labelled "Daily Notes" and the // assertion passes even if PluginSection rendered no rows. Adversary // review CRIT-1. // // Adversary review MIN-D: `.settings-plugin-list` is rendered by both // PluginSection and ObsidianPluginSection. Today only one is mounted // at a time (the active section's tabpanel), but a future merge of // those panels would make a bare `.settings-plugin-list` selector // grab whichever element renders first. Scope through the active // tabpanel via aria-labelledby (SettingsModal.tsx:327 sets // aria-labelledby="settings-tab-<activeSection>" on the panel). // // Adversary review MIN-C: wrap the entire built-in-name probe in // waitFor so plugin registration becoming async (microtask/promise) // doesn't flake this test -- we currently rely on synchronous // registration but that is an implementation detail of PluginLoader. await waitFor(() => { const pluginList = document.querySelector( '[role="tabpanel"][aria-labelledby="settings-tab-plugins"] .settings-plugin-list' ) if (!pluginList) throw new Error('Plugin list container not found in active tabpanel') for (const name of BUILTIN_PLUGIN_NAMES) { expect(within(pluginList as HTMLElement).getByText(name)).toBeInTheDocument() } }) }) })
describe('Plugin enabled state persistence', () => { it('initializes enabled state without writing the obsolete local key', async () => { // First render - all plugins enabled by default renderAppShell() // Wait for initialization await waitFor(() => { expect(screen.getByTestId('editor-area')).toBeInTheDocument() }, { timeout: 3000 }) await waitFor(() => expect(enabledStateStore.getEnabledPluginIds().length).toBeGreaterThan(0), { timeout: 3000 }) expect(localStorage.getItem('reams:plugin-enabled-state')).toBeNull() }) })})