// src/layout/__tests__/PluginLoaderIntegration.test.tsx import { 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 instances const 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: () =>
, })) 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 tests const 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 tests function 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: App let enabledStateStore: EnabledStateStore // Built-in plugin names for UI verification const 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 provider function renderAppShell() { // Create a tab via workspace before rendering (replaces useEffect openTab) createWorkspaceTab(mockApp, 'automerge:1', 'Test.md') return render( ) } 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-" 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() }) }) })