// src/layout/__tests__/AppShell.test.tsx import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { render, screen, fireEvent, cleanup, waitFor, within, act } from '@testing-library/react' import { setViewportWidth, restoreViewport } from '../../test-utils/viewport' import userEvent from '@testing-library/user-event' import { LayoutProvider } from '../LayoutContext' import { AppShell } from '../AppShell' import { createTestVaultStateData } from '../../services/__tests__/vault-state-test-helpers' import { EnabledStateStore } from '../../plugin/loader/enabled-state-store' import { LAYOUT_STORAGE_KEY } from '../layout-persistence' 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 { attachMarkdownViewEditor, MarkdownView } from '../../plugin/views/markdown-view' import type { Editor } from '../../plugin/editor' import type { FormattingAction } from '../../plugin/ui/formatting-action-types' import { SEPARATOR_TOKEN } from '../../plugin/ui/formatting-action-types' import { CM6EditorAdapter } from '../../plugin/cm6-editor' import { EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { StrictMode, useContext } from 'react' import { MainAreaContext } from '../MainAreaContext' import { createPromptlessNote } from '../promptless-note-creation' import { PluginLoader } from '../../plugin/loader/plugin-loader' import type { WorkspaceLeaf } from '../../plugin/workspace' import { newBaseYaml } from '../../bases/default-base' vi.mock('../promptless-note-creation', () => ({ createPromptlessNote: vi.fn() })) let reportBootViewReady: ((leaf: WorkspaceLeaf) => void) | undefined const searchDocuments = vi.hoisted(() => vi.fn().mockReturnValue([])) import type { AutomergeUrl } from '@automerge/automerge-repo' // Default file stat for creating TFile instances const defaultStat = { ctime: 0, mtime: 0, size: 0 } // Mock MainAreaViewContainer so AppShell tests don't need to mount a real // editor (the old `children` render-prop is gone; the dispatcher mounts the // markdown binding which requires a real document-manager). AppShell tests // assert layout/tab behavior — they never inspect the editor surface, so a // lightweight stub is safe. // // The stub emits `data-reams-view="markdown"` on its outer element so test // plan §2.7 ("AppShell still mounts the editor for a markdown leaf") can // assert the expected selector without mounting the real dispatcher. The // component-level dispatcher behavior is covered in // MainAreaViewContainer.test.tsx. vi.mock('../MainAreaViewContainer', () => ({ MainAreaViewContainer: () => { reportBootViewReady = useContext(MainAreaContext)?.onInitialViewReady return
}, })) // Mock the SearchContext so AppShell doesn't require a SearchProvider vi.mock('../../services/SearchContext', () => ({ useSearch: () => ({ searchService: { search: searchDocuments, 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 to mirror the same shape that built-in plugins now read via // this.app.metadataCache and this.app.search (#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 // 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 // Reuse an existing registered file if the URL is already in the index; // otherwise create and register a fresh one. Kept in lockstep with the // helper in tab-characterization.integration.test.tsx so both tests // tolerate the dedup pattern (same docUrl passed twice) without tripping // FileIndex's dev-mode guard against URL re-registration with a different // file object. const fileIndex = (vault as unknown as { fileIndex: { getAbstractFileByUrl(u: string): unknown; registerFile(u: string, p: string, f: unknown): void } }).fileIndex const existing = fileIndex.getAbstractFileByUrl(docUrl) let file: ReturnType if (existing) { file = existing as ReturnType } else { const root = vault.getRoot() file = createTFile(title, root, defaultStat) 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 with provider and pre-open tabs function renderApp(initialTabs: Array<{ docUrl: string; title: string }> = []) { // Pre-create tabs via workspace before rendering initialTabs.forEach(tab => { createWorkspaceTab(mockApp, tab.docUrl, tab.title) }) return render( ) } describe('AppShell', () => { beforeEach(() => { localStorage.clear() searchDocuments.mockReset().mockReturnValue([]) mockApp = createMockApp() enabledStateStore = new EnabledStateStore() vi.mocked(createPromptlessNote).mockReset().mockResolvedValue({ status: 'fallback' }) }) afterEach(() => { mockApp.destroy() cleanup() vi.useRealTimers() vi.restoreAllMocks() reportBootViewReady = undefined }) it('waits for plugin activation and the current editor across an A → B → A switch', async () => { createWorkspaceTab(mockApp, 'automerge:first', 'First.md') const first = mockApp.workspace.activeLeaf! let release!: () => void const deferred = new Promise(resolve => { release = resolve }) const originalEnable = PluginLoader.prototype.enablePlugin let delayed = false vi.spyOn(PluginLoader.prototype, 'enablePlugin').mockImplementation(async function (this: PluginLoader, ...args: Parameters) { if (!delayed) { delayed = true; await deferred } return originalEnable.apply(this, args) }) const onReady = vi.fn() const onStage = vi.fn() render() expect(onStage).toHaveBeenCalledWith('activating-plugins') expect(reportBootViewReady).toBeTypeOf('function') act(() => { reportBootViewReady!(first) }) expect(onReady).not.toHaveBeenCalled() act(() => { createWorkspaceTab(mockApp, 'automerge:second', 'Second.md') }) const second = mockApp.workspace.activeLeaf! act(() => { reportBootViewReady!(first) }) act(() => { reportBootViewReady!(second) }) act(() => { mockApp.workspace.setActiveLeaf(first) }) await act(async () => { release(); await deferred }) expect(onReady).not.toHaveBeenCalled() expect(onStage).toHaveBeenCalledWith('opening-note') act(() => { reportBootViewReady!(first) }) await waitFor(() => expect(onReady).toHaveBeenCalled()) }) it('shows the ready editor while later nonessential plugins continue activating', async () => { createWorkspaceTab(mockApp, 'automerge:editable', 'Editable.md') const leaf = mockApp.workspace.activeLeaf! let release!: () => void const deferred = new Promise(resolve => { release = resolve }) const originalEnable = PluginLoader.prototype.enablePlugin vi.spyOn(PluginLoader.prototype, 'enablePlugin').mockImplementation(async function (this: PluginLoader, ...args: Parameters) { if (args[0] === 'reams-word-highlighter') await deferred return originalEnable.apply(this, args) }) const onReady = vi.fn() render() try { act(() => { reportBootViewReady!(leaf) }) await waitFor(() => expect(onReady).toHaveBeenCalled()) } finally { await act(async () => { release(); await deferred }) } }) it('preserves a mounted editor readiness through a same-leaf republish', async () => { createWorkspaceTab(mockApp, 'automerge:republished', 'Same.md') const leaf = mockApp.workspace.activeLeaf! let release!: () => void const deferred = new Promise(resolve => { release = resolve }) const originalEnable = PluginLoader.prototype.enablePlugin let delayed = false vi.spyOn(PluginLoader.prototype, 'enablePlugin').mockImplementation(async function (this: PluginLoader, ...args: Parameters) { if (!delayed) { delayed = true; await deferred } return originalEnable.apply(this, args) }) const onReady = vi.fn() render() act(() => { reportBootViewReady!(leaf) }) const replacement = createTFile('Same.md', mockApp.vault.getRoot(), defaultStat) const originalUrl = mockApp.vault.getUrlForFile.bind(mockApp.vault) const url = originalUrl(leaf.file!) vi.spyOn(mockApp.vault, 'getUrlForFile').mockImplementation(file => file === replacement ? url : originalUrl(file)) act(() => { leaf.file = replacement mockApp.workspace.trigger('active-leaf-change', leaf) }) expect(onReady).not.toHaveBeenCalled() await act(async () => { release(); await deferred }) await waitFor(() => expect(onReady).toHaveBeenCalled()) }) it('detaches deleted-file leaves using file-object identity', async () => { renderApp([{ docUrl: 'automerge:deleted-doc', title: 'Deleted.md' }]) const openFile = mockApp.workspace.getActiveFile()! const deletedFile = createTFile('Deleted-event.md', mockApp.vault.getRoot(), defaultStat) const originalGetUrlForFile = mockApp.vault.getUrlForFile.bind(mockApp.vault) const originalGetUrlForPath = mockApp.vault.getUrlForPath.bind(mockApp.vault) const getUrlForFileSpy = vi .spyOn(mockApp.vault, 'getUrlForFile') .mockImplementation(file => { if (file === deletedFile) return 'automerge:deleted-doc' as AutomergeUrl return originalGetUrlForFile(file) }) const getUrlForPathSpy = vi .spyOn(mockApp.vault, 'getUrlForPath') .mockImplementation(path => { if (path === deletedFile.path) return 'automerge:wrong-path-doc' as AutomergeUrl return originalGetUrlForPath(path) }) getUrlForFileSpy.mockClear() getUrlForPathSpy.mockClear() act(() => { mockApp.vault.trigger('delete', deletedFile) }) const remainingLeaves: unknown[] = [] mockApp.workspace.iterateRootLeaves(leaf => { remainingLeaves.push(leaf) }) expect(getUrlForFileSpy).toHaveBeenCalledWith(deletedFile) expect(getUrlForFileSpy).toHaveBeenCalledWith(openFile) expect(getUrlForPathSpy).not.toHaveBeenCalled() expect(remainingLeaves).toHaveLength(0) }) it('keeps a replacement-leased tab during graph reconciliation and restores normal delete handling on release', () => { renderApp([{ docUrl: 'automerge:replacement-doc', title: 'Replacing.md' }]) const leaf = mockApp.workspace.activeLeaf! const file = leaf.file! const release = leaf.preserveFileReplacement(file) act(() => { mockApp.vault.trigger('delete', file) }) expect(mockApp.workspace.activeLeaf).toBe(leaf) release() act(() => { mockApp.vault.trigger('delete', file) }) const leaves: WorkspaceLeaf[] = [] mockApp.workspace.iterateRootLeaves(current => leaves.push(current)) expect(leaves).not.toContain(leaf) }) describe('sidebar toggle', () => { it('cycles sidebar mode when clicking menu button', async () => { const user = userEvent.setup() renderApp([{ docUrl: 'automerge:1', title: 'Test.md' }]) // Default is 'full' mode - button says "Hide sidebar" const toggleBtn = screen.getByRole('button', { name: /hide sidebar/i }) expect(toggleBtn).toHaveAttribute('aria-expanded', 'true') // Click to hide (full -> hidden) await user.click(toggleBtn) expect(screen.getByRole('button', { name: /show ribbon/i })).toHaveAttribute('aria-expanded', 'false') // Click to show ribbon (hidden -> ribbon) await user.click(screen.getByRole('button', { name: /show ribbon/i })) expect(screen.getByRole('button', { name: /show sidebar/i })).toHaveAttribute('aria-expanded', 'false') // Click to show full (ribbon -> full) await user.click(screen.getByRole('button', { name: /show sidebar/i })) expect(screen.getByRole('button', { name: /hide sidebar/i })).toHaveAttribute('aria-expanded', 'true') }) it('persists sidebar state to localStorage', async () => { const user = userEvent.setup() renderApp([{ docUrl: 'automerge:1', title: 'Test.md' }]) await user.click(screen.getByRole('button', { name: /hide sidebar/i })) const stored = JSON.parse(localStorage.getItem(LAYOUT_STORAGE_KEY)!) expect(stored.sidebarMode).toBe('hidden') }) }) describe('sidebar mode rendering', () => { it('hides ribbon when mode is hidden', async () => { const user = userEvent.setup() renderApp([{ docUrl: 'automerge:1', title: 'Test.md' }]) // Initially full - ribbon should be visible (data-hidden="false") const ribbon = document.querySelector('.ribbon') expect(ribbon).toBeInTheDocument() expect(ribbon).toHaveAttribute('data-hidden', 'false') // Toggle to hidden await user.click(screen.getByRole('button', { name: /hide sidebar/i })) // Ribbon DOM element stays mounted but is hidden (data-hidden="true") expect(ribbon).toBeInTheDocument() expect(ribbon).toHaveAttribute('data-hidden', 'true') }) it('resets --ribbon-width CSS variable when ribbon is hidden', async () => { const user = userEvent.setup() renderApp([{ docUrl: 'automerge:1', title: 'Test.md' }]) const shell = document.querySelector('.app-shell') as HTMLElement expect(shell).not.toBeNull() expect(shell.style.getPropertyValue('--ribbon-width')).toBe('var(--icon-column-width)') await user.click(screen.getByRole('button', { name: /hide sidebar/i })) expect(shell.style.getPropertyValue('--ribbon-width')).toBe('0px') }) it('shows ribbon when mode is ribbon', async () => { const user = userEvent.setup() renderApp([{ docUrl: 'automerge:1', title: 'Test.md' }]) // Toggle full -> hidden -> ribbon await user.click(screen.getByRole('button', { name: /hide sidebar/i })) await user.click(screen.getByRole('button', { name: /show ribbon/i })) // Ribbon should be visible (data-hidden="false") const ribbon = document.querySelector('.ribbon') expect(ribbon).toBeInTheDocument() expect(ribbon).toHaveAttribute('data-hidden', 'false') }) it('shows ribbon when mode is full', () => { renderApp([{ docUrl: 'automerge:1', title: 'Test.md' }]) // Default is full - ribbon should be visible (data-hidden="false") const ribbon = document.querySelector('.ribbon') expect(ribbon).toBeInTheDocument() expect(ribbon).toHaveAttribute('data-hidden', 'false') }) }) describe('right panel toggle', () => { it('toggles right panel visibility', async () => { const user = userEvent.setup() renderApp([{ docUrl: 'automerge:1', title: 'Test.md' }]) // Right panel starts collapsed const toggleBtn = screen.getByRole('button', { name: /show right panel/i }) expect(toggleBtn).toHaveAttribute('aria-expanded', 'false') await user.click(toggleBtn) expect(screen.getByRole('button', { name: /hide right panel/i })).toHaveAttribute('aria-expanded', 'true') }) it('toggle-backlinks command toggles right panel visibility', async () => { renderApp([{ docUrl: 'automerge:1', title: 'Test.md' }]) await waitFor(() => { expect(mockApp.commands.getCommand('reams-wikilink:toggle-backlinks')).toBeDefined() }) expect(screen.getByRole('button', { name: /show right panel/i })).toHaveAttribute('aria-expanded', 'false') expect(mockApp.commands.executeCommandById('reams-wikilink:toggle-backlinks')).toBe(true) await waitFor(() => { expect(screen.getByRole('button', { name: /hide right panel/i })).toHaveAttribute('aria-expanded', 'true') }) expect(mockApp.commands.executeCommandById('reams-wikilink:toggle-backlinks')).toBe(true) await waitFor(() => { expect(screen.getByRole('button', { name: /show right panel/i })).toHaveAttribute('aria-expanded', 'false') }) }) }) describe('tab switching', () => { it('switches active tab when clicking a tab', async () => { const user = userEvent.setup() // Pre-create tabs createWorkspaceTab(mockApp, 'automerge:1', 'First.md') createWorkspaceTab(mockApp, 'automerge:2', 'Second.md') render( ) // Second tab should be active (last opened). With the editor surface // mocked (#406), assert tab aria-selected directly — that's the new // single source of truth for "which doc is active". await waitFor(() => { expect(screen.getByRole('tab', { name: /Second\.md/i })) .toHaveAttribute('aria-selected', 'true') }) // Click first tab await user.click(screen.getByRole('tab', { name: /First\.md/i })) expect(screen.getByRole('tab', { name: /First\.md/i })) .toHaveAttribute('aria-selected', 'true') }) it('shows active tab with aria-selected', async () => { // Pre-create tabs createWorkspaceTab(mockApp, 'automerge:1', 'First.md') createWorkspaceTab(mockApp, 'automerge:2', 'Second.md') render( ) await waitFor(() => { expect(screen.getByRole('tab', { name: /First\.md/i })).toBeInTheDocument() }) const firstTab = screen.getByRole('tab', { name: /First\.md/i }) const secondTab = screen.getByRole('tab', { name: /Second\.md/i }) expect(firstTab).toHaveAttribute('aria-selected', 'false') expect(secondTab).toHaveAttribute('aria-selected', 'true') }) }) describe('workspace tab lifecycle persistence', () => { it.each(['visibilitychange', 'pagehide'] as const)('flushes the mounted workspace tab layout on %s before debounce', lifecycle => { createWorkspaceTab(mockApp, 'automerge:lifecycle-first', 'First.md') const first = mockApp.workspace.activeLeaf! createWorkspaceTab(mockApp, 'automerge:lifecycle-second', 'Second.md') render() act(() => { mockApp.workspace.setActiveLeaf(first) }) expect(localStorage.getItem('reams:workspace-tabs')).toBeNull() if (lifecycle === 'visibilitychange') { Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' }) document.dispatchEvent(new Event('visibilitychange')) } else { window.dispatchEvent(new Event('pagehide')) } const saved = JSON.parse(localStorage.getItem('reams:workspace-tabs')!) expect(saved.tabs).toHaveLength(2) expect(saved.activeIndex).toBe(0) }) it('does not persist a detached empty layout during AppShell teardown', async () => { vi.useFakeTimers() createWorkspaceTab(mockApp, 'automerge:teardown', 'Attached.md') const view = render() act(() => { mockApp.workspace.trigger('layout-change') }) view.unmount() act(() => { mockApp.workspace.activeLeaf?.detach(); vi.advanceTimersByTime(600) }) expect(localStorage.getItem('reams:workspace-tabs')).toBeNull() }) }) describe('tab closing', () => { it('closes tab when clicking close button', async () => { const user = userEvent.setup() // Pre-create tab createWorkspaceTab(mockApp, 'automerge:1', 'ToClose.md') render( ) await waitFor(() => { expect(screen.getByRole('tab', { name: /ToClose\.md/i })).toBeInTheDocument() }) await user.click(screen.getByRole('button', { name: /close ToClose\.md/i })) await waitFor(() => { expect(screen.queryByRole('tab', { name: /ToClose\.md/i })).not.toBeInTheDocument() }) }) it('closes tab on middle-click', async () => { // Pre-create tab createWorkspaceTab(mockApp, 'automerge:1', 'ToClose.md') render( ) await waitFor(() => { expect(screen.getByRole('tab', { name: /ToClose\.md/i })).toBeInTheDocument() }) const tab = screen.getByRole('tab', { name: /ToClose\.md/i }) // Simulate middle-click (button 1) fireEvent.mouseDown(tab, { button: 1 }) await waitFor(() => { expect(screen.queryByRole('tab', { name: /ToClose\.md/i })).not.toBeInTheDocument() }) }) }) describe('header branding', () => { it('shows Reams branding text', () => { render( ) const header = screen.getByRole('banner') expect(header).toHaveTextContent('Reams') }) }) describe('empty state', () => { it('renders the main-area dispatcher (which owns the empty state) when no tabs are open', () => { render( ) // The empty state ("Open a document to start editing") moved into // MainAreaViewContainer (#406). AppShell mocks MainAreaViewContainer // in this suite to avoid the editor heavy-mount; assert the dispatcher // is mounted instead. expect(screen.getByTestId('main-area-stub')).toBeInTheDocument() }) }) // Test plan §2.7 — AppShell's no-children call shape mounts the dispatcher, // and the mounted dispatcher exposes `data-reams-view` so the rest of the // host-internal infrastructure can rely on it. The dispatcher itself is // mocked here; the real assertion against a live MarkdownView lives in // MainAreaViewContainer.test.tsx (where the leaf is wired through the // workspace registry and the real dispatcher is exercised). describe('no-children signature (#406 §2.7)', () => { it('mounts the dispatcher with a data-reams-view marker', () => { createWorkspaceTab(mockApp, 'automerge:1', 'note.md') render( ) const stub = screen.getByTestId('main-area-stub') expect(stub).toHaveAttribute('data-reams-view', 'markdown') }) }) describe('complete toggle cycle scenario', () => { it('cycles through all 3 states with correct button labels and ribbon visibility', async () => { const user = userEvent.setup() renderApp([{ docUrl: 'automerge:1', title: 'Test.md' }]) const ribbon = document.querySelector('.ribbon') // Step 1: Verify initial full state const initialBtn = screen.getByRole('button', { name: /hide sidebar/i }) expect(initialBtn).toHaveAttribute('aria-expanded', 'true') expect(ribbon).toHaveAttribute('data-hidden', 'false') // Step 2: Click to hidden await user.click(initialBtn) // Step 3: Verify hidden state const hiddenBtn = screen.getByRole('button', { name: /show ribbon/i }) expect(hiddenBtn).toHaveAttribute('aria-expanded', 'false') expect(ribbon).toHaveAttribute('data-hidden', 'true') // Step 4: Click to ribbon await user.click(hiddenBtn) // Step 5: Verify ribbon state const ribbonBtn = screen.getByRole('button', { name: /show sidebar/i }) expect(ribbonBtn).toHaveAttribute('aria-expanded', 'false') expect(ribbon).toHaveAttribute('data-hidden', 'false') // Step 6: Click to full await user.click(ribbonBtn) // Step 7: Verify back to full state const fullBtn = screen.getByRole('button', { name: /hide sidebar/i }) expect(fullBtn).toHaveAttribute('aria-expanded', 'true') expect(ribbon).toHaveAttribute('data-hidden', 'false') }) }) describe('persistence scenario', () => { it('sidebar mode preference persists across sessions', async () => { const user = userEvent.setup() // Step 1: Render and toggle to ribbon mode (full -> hidden -> ribbon) const { unmount } = renderApp([{ docUrl: 'automerge:1', title: 'Test.md' }]) await user.click(screen.getByRole('button', { name: /hide sidebar/i })) await user.click(screen.getByRole('button', { name: /show ribbon/i })) // Step 2: Verify localStorage has sidebarMode: 'ribbon' const stored = JSON.parse(localStorage.getItem(LAYOUT_STORAGE_KEY)!) expect(stored.sidebarMode).toBe('ribbon') // Step 3: Unmount unmount() // Step 4: Re-render fresh renderApp([{ docUrl: 'automerge:1', title: 'Test.md' }]) // Step 5: Verify ribbon mode is restored (button says "Show sidebar") expect(screen.getByRole('button', { name: /show sidebar/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /show sidebar/i })).toHaveAttribute('aria-expanded', 'false') }) }) describe('ribbon icons persistence', () => { it('ribbon icons persist after cycling through hidden state', async () => { const user = userEvent.setup() renderApp([{ docUrl: 'automerge:1', title: 'Test.md' }]) // Add a test icon to the ribbon via the RibbonActionManager API // Use 'star' icon which is not used by any built-in plugin const iconCallback = vi.fn() mockApp.reamsRibbon.addIcon('test-plugin', 'star', 'Test Star Icon', iconCallback) // Verify icon is in the DOM initially (wait for React re-render from useSyncExternalStore) const ribbon = document.querySelector('.ribbon') await vi.waitFor(() => { const found = ribbon?.querySelector('[data-icon="star"]') expect(found).toBeInTheDocument() }) const icon = ribbon?.querySelector('[data-icon="star"]') expect(icon).toHaveAttribute('aria-label', 'Test Star Icon') // Cycle: full -> hidden -> ribbon await user.click(screen.getByRole('button', { name: /hide sidebar/i })) expect(ribbon).toHaveAttribute('data-hidden', 'true') await user.click(screen.getByRole('button', { name: /show ribbon/i })) expect(ribbon).toHaveAttribute('data-hidden', 'false') // CRITICAL: The icon should still be in the DOM and visible // This was the bug: icons disappeared after cycling through hidden state const iconAfterCycle = ribbon?.querySelector('[data-icon="star"]') expect(iconAfterCycle).toBeInTheDocument() expect(iconAfterCycle).toHaveAttribute('aria-label', 'Test Star Icon') // Verify icon is clickable await user.click(iconAfterCycle!) expect(iconCallback).toHaveBeenCalledTimes(1) }) }) describe('keyboard shortcuts', () => { it('closes active tab with Cmd+W', async () => { const user = userEvent.setup() // Pre-create tab createWorkspaceTab(mockApp, 'automerge:1', 'ToClose.md') render( ) await waitFor(() => { expect(screen.getByRole('tab', { name: /ToClose\.md/i })).toBeInTheDocument() }) await user.keyboard('{Meta>}w{/Meta}') await waitFor(() => { expect(screen.queryByRole('tab', { name: /ToClose\.md/i })).not.toBeInTheDocument() }) }) }) describe('mobile auto-close sidebars', () => { afterEach(() => { restoreViewport() }) it('closes sidebar panel to ribbon on editor interaction on mobile', async () => { setViewportWidth(600) // Mobile viewport renderApp([{ docUrl: 'automerge:1', title: 'Test.md' }]) // Mobile starts ribbon-only; expand it before exercising auto-close. fireEvent.click(screen.getByRole('button', { name: /show sidebar/i })) expect(screen.getByRole('button', { name: /hide sidebar/i })).toBeInTheDocument() // Find and interact with editor area const editorArea = document.querySelector('.app-shell-editor') expect(editorArea).toBeInTheDocument() fireEvent.pointerDown(editorArea!) // Sidebar panel closes to ribbon mode (ribbon stays, panel goes away) // In ribbon mode, the burger button says "Show sidebar" expect(screen.getByRole('button', { name: /show sidebar/i })).toBeInTheDocument() }) it('does not close sidebar when interacting with editor on desktop', async () => { setViewportWidth(1024) // Desktop viewport renderApp([{ docUrl: 'automerge:1', title: 'Test.md' }]) // Verify sidebar starts visible expect(screen.getByRole('button', { name: /hide sidebar/i })).toBeInTheDocument() // Find and interact with editor area const editorArea = document.querySelector('.app-shell-editor') expect(editorArea).toBeInTheDocument() fireEvent.pointerDown(editorArea!) // Sidebar should still be visible expect(screen.getByRole('button', { name: /hide sidebar/i })).toBeInTheDocument() }) }) describe('search create flow', () => { it('treats Templates as absent for search-create and ribbon create flows when the plugin is disabled', async () => { const user = userEvent.setup() enabledStateStore.setEnabled('reams-templates', false) await mockApp.reamsStateData!.save('plugin-data', 'reams-templates', { filenamePattern: 'daily-{{title}}', defaultTemplate: 'templates/default.md', }) renderApp([{ docUrl: 'automerge:1', title: 'Test.md' }]) await waitFor(() => { expect(document.querySelector('.ribbon [data-icon="search"]')).not.toBeNull() expect(document.querySelector('.ribbon [data-icon="file-plus"]')).not.toBeNull() }) const searchButton = document.querySelector('.ribbon [data-icon="search"]') as HTMLElement await user.click(searchButton) await user.type(screen.getByRole('combobox'), 'note') await new Promise(resolve => setTimeout(resolve, 200)) expect(screen.getByText(/Create note\.md/)).toBeInTheDocument() expect(screen.queryByText(/Create daily-note\.md/)).not.toBeInTheDocument() const newFileButton = document.querySelector('.ribbon [data-icon="file-plus"]') as HTMLElement await user.click(newFileButton) expect(screen.getAllByRole('dialog').length).toBeGreaterThan(1) expect(screen.queryByRole('combobox', { name: /template/i })).not.toBeInTheDocument() }) // TODO(#241): AppShell regression test for Templates-disabled-mid-modal race. // // Adversary review of commits e1bdcd6 / 7bd471f (chainlink #233 close) // flagged that the canary test "falls back to blank ribbon creation // when Templates is disabled after the modal opens" was deleted in // 87f82c4 during the search-create refactor. That test was the canary // for an infinite render loop caused by the Mod+Shift+E hotkey // collision (#232) when Templates is toggled while a modal is open. // // The trio of FileTree.test.tsx tests cover FileTree in isolation, and // the test directly above ("treats Templates as absent...") covers // Templates-disabled-BEFORE-modal-opens. The canary covered Templates- // disabled-MID-modal -- a distinct race that was the original // reproducer for the loop. Both the AppShell-cascade path and the // FileTree-cascade path can re-trigger the bug independently. // // Acceptance criteria for the regression test: // 1. Open the search overlay with Templates ENABLED. // 2. Programmatically disable Templates while the overlay is open // (simulates "user toggles plugin in another tab"). // 3. Assert no infinite re-render via either: // a. A render-count probe (Profiler or a probe hook). // b. A timeout-based assertion (vi.advanceTimersByTime(1000) + // assert the test did not hang). // 4. Verify the overlay updates (preview switches from templated // path to plain-md path). // // Filed as low priority because: // - Root cause (#232 hotkey collision) is filed and tracked. // - FileTree-level analogous tests cover an adjacent surface. // - The cascade through AppShell is a specific shape, but #232 is // still present in production code. // // When #232 closes, revisit: the cascade race may already be impossible; // verify and then either delete this TODO or upgrade to a real test. // Adversary review MAJOR-3: the previous integration coverage of the // happy-path search-create flow was deleted with the search-create.ts // refactor. The unit tests on search-create.ts validate the pure // function behavior, but ONLY this AppShell-level test verifies that // the overlay's onCreate prop is wired to handleSearchCreate, that // searchCreatePreview state propagates correctly, and that // app.vault.create gets called against the right path. If a future // change to AppShell prop wiring or useCallback deps breaks the // integration, the unit tests would still pass — this test is the // safety net. // Test name describes the wiring proven (overlay → handleSearchCreate → // app.vault.create), not a search-result match condition. The empty- // search behavior comes from mockDocumentManager.listFolder returning []. it('wires the search overlay create entry to app.vault.create', async () => { const user = userEvent.setup() // Templates plugin disabled → blank file path (simpler assertion surface) enabledStateStore.setEnabled('reams-templates', false) renderApp([{ docUrl: 'automerge:1', title: 'Test.md' }]) // Spy on the vault's create method. The handleSearchCreate callback // wires this into the SearchCreateEnvironment.createBlankFile, so // when the user clicks Create we expect this to be invoked with the // resolved finalPath ('new-note.md') and empty content. Use a real // TFile (not a partial object cast through any) so any future change // that reads additional fields on the returned file fails loudly // rather than silently producing a NPE inside production code. const createSpy = vi.spyOn(mockApp.vault, 'create').mockImplementation(async (path: string) => { return createTFile(path.split('/').pop() ?? path, null, { ctime: 0, mtime: 0, size: 0 }) }) // Open the search overlay (Cmd/Ctrl+K is bound by QuickSearchPlugin; // ribbon icon is also wired to it). await waitFor(() => { expect(document.querySelector('.ribbon [data-icon="search"]')).not.toBeNull() }) const searchButton = document.querySelector('.ribbon [data-icon="search"]') as HTMLElement await user.click(searchButton) // Type a query that matches no existing doc (mockDocumentManager has // no files, so the create entry will appear). const searchInput = screen.getByRole('combobox') await user.type(searchInput, 'new-note') // Wait past the 150ms search debounce so the create entry materializes. await new Promise(resolve => setTimeout(resolve, 200)) // The overlay should render a "Create new-note.md" entry. const createEntry = screen.getByText(/Create new-note\.md/) expect(createEntry).toBeInTheDocument() // Click the create entry → handleSearchCreate fires → executeSearchCreate // re-resolves → createBlankFile called. await user.click(createEntry) // The contract being verified: app.vault.create must be called with // the resolved finalPath and an empty content string. We deliberately // do NOT spy on getUrlForPath / openTab — those are post-create flow // details that the unit tests cover. This integration test's job is // to prove the overlay wiring fires the expected create call. await waitFor(() => { expect(createSpy).toHaveBeenCalledWith('new-note.md', '') }) }) it('opens the regular document search overlay for link selected text', async () => { const user = userEvent.setup() enabledStateStore.setEnabled('reams-templates', false) searchDocuments.mockReturnValue([{ url: 'automerge:destination', name: 'Destination.md', path: 'Destination.md', snippet: '', score: 1, }]) renderApp([ { docUrl: 'automerge:destination', title: 'Destination.md' }, { docUrl: 'automerge:source', title: 'Source.md' }, ]) const sourceView = mockApp.workspace.activeEditor as MarkdownView const editorView = new EditorView({ state: EditorState.create({ doc: 'project plan', selection: { anchor: 0, head: 12 }, }), }) const adapter = new CM6EditorAdapter() adapter.setView(editorView) attachMarkdownViewEditor(sourceView, adapter) const sourceEditor: Editor = sourceView.editor expect(mockApp.vault.getFileByPath('Destination.md')).not.toBeNull() let action: FormattingAction | undefined await waitFor(() => { action = mockApp.reamsFormattingActions.getSnapshot().find( (entry): entry is FormattingAction => entry !== SEPARATOR_TOKEN && entry.id === 'builtin:link-selection', ) expect(action).toBeDefined() }) act(() => action!.callback(sourceEditor)) expect(await screen.findByRole('combobox')).toHaveAttribute('placeholder', 'Search files...') expect(screen.getByRole('combobox')).toHaveValue('project plan') expect(document.querySelector('.modal-container')).toBeNull() await user.keyboard('{Escape}{Escape}') await waitFor(() => expect(screen.queryByRole('combobox')).not.toBeInTheDocument()) expect(editorView.state.doc.toString()).toBe('project plan') act(() => action!.callback(sourceEditor)) expect(await screen.findByRole('combobox')).toHaveValue('project plan') await user.clear(screen.getByRole('combobox')) await user.type(screen.getByRole('combobox'), 'Destination') await new Promise(resolve => setTimeout(resolve, 200)) await user.click(await screen.findByRole('option', { name: /Destination\.md/ })) expect(editorView.state.doc.toString()).toBe('[[Destination|project plan]]') searchDocuments.mockReturnValue([]) const searchButton = document.querySelector('.ribbon [data-icon="search"]') as HTMLElement await user.click(searchButton) await user.type(screen.getByRole('combobox'), 'missing-note') await new Promise(resolve => setTimeout(resolve, 200)) expect(screen.getByText(/Create missing-note\.md/)).toBeInTheDocument() editorView.destroy() }) }) describe('invariant diagnostics registration', () => { it('publishes a coherent app-shell snapshot and clears it on unmount', async () => { const user = userEvent.setup() createWorkspaceTab(mockApp, 'automerge:1', 'Invariant.md') const view = render( ) await waitFor(() => { expect(window.__reamsInvariantDiagnostics__?.getAppShellSnapshot()).not.toBeNull() }) const initialSnapshot = window.__reamsInvariantDiagnostics__?.getAppShellSnapshot() expect(initialSnapshot?.sidebarMode).toBe('full') expect(initialSnapshot?.rightPanelCollapsed).toBe(true) expect(initialSnapshot?.overlayCount).toBe(0) expect(initialSnapshot?.activeDocUrl).toBe('automerge:1') expect(initialSnapshot?.activeTabCount).toBe(1) expect(initialSnapshot?.visibleEditorCount).toBe(1) await user.click(screen.getByRole('button', { name: /open settings/i })) await waitFor(() => { expect(window.__reamsInvariantDiagnostics__?.getAppShellSnapshot()?.overlayCount).toBe(1) }) await user.click(screen.getByRole('button', { name: /hide sidebar/i })) expect(window.__reamsInvariantDiagnostics__?.getAppShellSnapshot()?.sidebarMode).toBe('hidden') expect(document.querySelector('.ribbon')).toHaveAttribute('data-hidden', 'true') await user.click(screen.getByRole('button', { name: /show right panel/i })) expect(window.__reamsInvariantDiagnostics__?.getAppShellSnapshot()?.rightPanelCollapsed).toBe(false) expect(document.querySelector('.app-shell-right-panel')).toHaveAttribute('data-collapsed', 'false') await user.keyboard('{Escape}') await waitFor(() => { expect(window.__reamsInvariantDiagnostics__?.getAppShellSnapshot()?.overlayCount).toBe(0) }) view.unmount() expect(window.__reamsInvariantDiagnostics__).toBeDefined() expect(window.__reamsInvariantDiagnostics__?.getAppShellSnapshot()).toBeNull() }) it('publishes tab/editor counts from app state and includes image-viewer overlays', async () => { createWorkspaceTab(mockApp, 'automerge:1', 'Invariant.md') render( ) await waitFor(() => { expect(window.__reamsInvariantDiagnostics__?.getAppShellSnapshot()).not.toBeNull() }) const overlay = document.createElement('div') overlay.className = 'image-viewer-overlay' document.body.appendChild(overlay) const querySpy = vi.spyOn(document, 'querySelectorAll').mockImplementation((selector) => { if ( selector === '[role="dialog"]' || selector === '[role="tablist"][aria-label="Open documents"] [role="tab"][aria-selected="true"]' || selector === '.cm6-editor-container' ) { return [] as unknown as NodeListOf } return Document.prototype.querySelectorAll.call(document, selector) }) const snapshot = window.__reamsInvariantDiagnostics__?.getAppShellSnapshot() expect(snapshot?.overlayCount).toBe(1) expect(snapshot?.activeTabCount).toBe(1) expect(snapshot?.visibleEditorCount).toBe(1) querySpy.mockRestore() overlay.remove() }) }) describe('create-file command + modal (chrome-row "+" path)', () => { // The chrome-row "+" button on the file-tree panel dispatches // `reams-create-file:create-file`, which opens AppShell's // CreateItemModal (the root-level modal owned by AppShell since the // chrome-row alignment refactor). This describe block covers the // wiring from command → modal-open → modal-submit, with a focus on // the adopt-by-URL path: typing an `automerge:` URL into the modal // must call `vault.adoptFolderByUrl(rootUrl, [], url)` so external // Automerge folders can be added at the vault root. it('opens the AppShell modal when reams-create-file:create-file fires', async () => { renderApp([]) await waitFor(() => { expect(mockApp.commands.getCommand('reams-create-file:create-file')).toBeDefined() }) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(mockApp.commands.executeCommandById('reams-create-file:create-file')).toBe(true) await waitFor(() => { expect(screen.getByRole('dialog')).toBeInTheDocument() }) expect(createPromptlessNote).toHaveBeenCalledWith(mockApp, mockApp.vault.rootUrl) }) it('opens the exact helper-returned file on promptless root creation', async () => { const file = createTFile('returned.md', mockApp.vault.getRoot(), defaultStat) const getUrlForFile = vi.spyOn(mockApp.vault, 'getUrlForFile').mockImplementation(candidate => candidate === file ? 'automerge:returned' as AutomergeUrl : undefined) vi.spyOn(mockApp.vault, 'getFileByUrl').mockImplementation(url => url === 'automerge:returned' ? file : null) const openFile = vi.spyOn(mockApp.workspace, 'openFile') vi.mocked(createPromptlessNote).mockResolvedValue({ status: 'created', file }) renderApp([]) await waitFor(() => expect(mockApp.commands.getCommand('reams-create-file:create-file')).toBeDefined()) act(() => { mockApp.commands.executeCommandById('reams-create-file:create-file') }) await waitFor(() => expect(openFile).toHaveBeenCalledWith(file)) expect(getUrlForFile).toHaveBeenCalledWith(file) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) it('reports promptless root failures without opening the modal', async () => { vi.mocked(createPromptlessNote).mockRejectedValue(new Error('creation failed')) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) renderApp([]) await waitFor(() => expect(mockApp.commands.getCommand('reams-create-file:create-file')).toBeDefined()) act(() => { mockApp.commands.executeCommandById('reams-create-file:create-file') }) await waitFor(() => expect(errorSpy).toHaveBeenCalledWith('Failed to create note:', expect.any(Error))) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) it('creates root .base files with ordinary YAML via the chrome-row modal', async () => { const user = userEvent.setup() renderApp([]) const create = vi.spyOn(mockApp.vault, 'create').mockResolvedValue(createTFile('Tasks.base', mockApp.vault.getRoot(), defaultStat)) await waitFor(() => expect(mockApp.commands.getCommand('reams-create-file:create-file')).toBeDefined()) mockApp.commands.executeCommandById('reams-create-file:create-file') const dialog = await screen.findByRole('dialog') await user.type(within(dialog).getByRole('textbox'), 'Tasks.base') await user.click(within(dialog).getByRole('button', { name: /^create$/i })) expect(create).toHaveBeenCalledWith('Tasks.base', newBaseYaml()) }) it('adopts an external folder at the vault root when an automerge URL is submitted', async () => { const user = userEvent.setup() renderApp([]) // The Vault is a real instance — spy on the method so we can // assert the call without needing the underlying DocumentManager // adoption flow (which has its own dedicated tests in vault.test.ts). const adoptSpy = vi .spyOn(mockApp.vault, 'adoptFolderByUrl') .mockResolvedValue(undefined) await waitFor(() => { expect(mockApp.commands.getCommand('reams-create-file:create-file')).toBeDefined() }) mockApp.commands.executeCommandById('reams-create-file:create-file') const dialog = await screen.findByRole('dialog') await user.type(within(dialog).getByRole('textbox'), 'automerge:abc12345') await user.click(within(dialog).getByRole('button', { name: /^create$/i })) // The chrome-row "+" → AppShell modal path must anchor the // adoption at vault.rootUrl with no intermediate segments — // the FileTree's per-folder ctx-menu modal anchors elsewhere // (see FileTree.test.tsx's "Adopt folder by URL" block). expect(adoptSpy).toHaveBeenCalledWith( mockApp.vault.rootUrl, [], 'automerge:abc12345', ) }) }) })