// 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',
)
})
})
})