Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206// src/layout/__tests__/AppShell.test.tsximport { 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 instancesconst 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 <div data-testid="main-area-stub" data-reams-view="markdown" /> },}))
// Mock the SearchContext so AppShell doesn't require a SearchProvidervi.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 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 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: Applet 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<typeof createTFile> if (existing) { file = existing as ReturnType<typeof createTFile> } 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 tabsfunction 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( <LayoutProvider> <AppShell documentManager={mockDocumentManager} app={mockApp} enabledStateStore={enabledStateStore} /> </LayoutProvider> )}
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<void>(resolve => { release = resolve }) const originalEnable = PluginLoader.prototype.enablePlugin let delayed = false vi.spyOn(PluginLoader.prototype, 'enablePlugin').mockImplementation(async function (this: PluginLoader, ...args: Parameters<PluginLoader['enablePlugin']>) { if (!delayed) { delayed = true; await deferred } return originalEnable.apply(this, args) }) const onReady = vi.fn() const onStage = vi.fn() render(<StrictMode><LayoutProvider><AppShell documentManager={mockDocumentManager} app={mockApp} onReady={onReady} onStage={onStage} /></LayoutProvider></StrictMode>) 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<void>(resolve => { release = resolve }) const originalEnable = PluginLoader.prototype.enablePlugin vi.spyOn(PluginLoader.prototype, 'enablePlugin').mockImplementation(async function (this: PluginLoader, ...args: Parameters<PluginLoader['enablePlugin']>) { if (args[0] === 'reams-word-highlighter') await deferred return originalEnable.apply(this, args) }) const onReady = vi.fn() render(<LayoutProvider><AppShell documentManager={mockDocumentManager} app={mockApp} onReady={onReady} /></LayoutProvider>) 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<void>(resolve => { release = resolve }) const originalEnable = PluginLoader.prototype.enablePlugin let delayed = false vi.spyOn(PluginLoader.prototype, 'enablePlugin').mockImplementation(async function (this: PluginLoader, ...args: Parameters<PluginLoader['enablePlugin']>) { if (!delayed) { delayed = true; await deferred } return originalEnable.apply(this, args) }) const onReady = vi.fn() render(<LayoutProvider><AppShell documentManager={mockDocumentManager} app={mockApp} onReady={onReady} /></LayoutProvider>) 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( <LayoutProvider> <AppShell documentManager={mockDocumentManager} app={mockApp} /> </LayoutProvider> ) // 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( <LayoutProvider> <AppShell documentManager={mockDocumentManager} app={mockApp} /> </LayoutProvider> ) 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(<LayoutProvider><AppShell documentManager={mockDocumentManager} app={mockApp} /></LayoutProvider>)
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(<LayoutProvider><AppShell documentManager={mockDocumentManager} app={mockApp} /></LayoutProvider>) 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( <LayoutProvider> <AppShell documentManager={mockDocumentManager} app={mockApp} /> </LayoutProvider> ) 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( <LayoutProvider> <AppShell documentManager={mockDocumentManager} app={mockApp} /> </LayoutProvider> ) 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( <LayoutProvider> <AppShell documentManager={mockDocumentManager} app={mockApp} /> </LayoutProvider> ) 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( <LayoutProvider> <AppShell documentManager={mockDocumentManager} app={mockApp} /> </LayoutProvider> ) // 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( <LayoutProvider> <AppShell documentManager={mockDocumentManager} app={mockApp} /> </LayoutProvider> ) 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( <LayoutProvider> <AppShell documentManager={mockDocumentManager} app={mockApp} /> </LayoutProvider> ) 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( <LayoutProvider> <AppShell documentManager={mockDocumentManager} app={mockApp} /> </LayoutProvider> )
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( <LayoutProvider> <AppShell documentManager={mockDocumentManager} app={mockApp} /> </LayoutProvider> )
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<Element> }
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', ) }) })})