import fs from 'fs' import path from 'path' import { test, expect, type Page } from '@playwright/test' import { createFixtureVaultCopy, openFixtureVaultDesktopHarness, removeFixtureVaultCopy, } from '../helpers/fixtureVault' import { executeCommand, openCommandPalette } from './helpers' import { triggerMenuCommand } from './testBridge' let tempVaultDir: string type SaveBeforeSwitchProbeWindow = Window & typeof globalThis & { __mockHandlers?: Record) => unknown> __saveBeforeSwitchProbe?: { calls: Array<{ path: string; content: string }> release: () => void } __saveBeforeSwitchProbeGate?: Promise } function isReactUpdateLoop(message: string): boolean { return ( message.includes('Maximum update depth') || message.includes('React error #185') || message.includes('#185') ) } function collectReactUpdateLoopErrors(page: Page): string[] { const errors: string[] = [] page.on('pageerror', (error) => { if (isReactUpdateLoop(error.message)) errors.push(error.message) }) page.on('console', (message) => { if (message.type() === 'error' && isReactUpdateLoop(message.text())) { errors.push(message.text()) } }) return errors } async function openNote(page: Page, title: string) { const noteList = page.locator('[data-testid="note-list-container"]') await noteList.getByText(title, { exact: true }).click() } async function openRawMode(page: Page) { await openCommandPalette(page) await executeCommand(page, 'Toggle Raw') await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 }) } async function openPropertiesPanel(page: Page) { const openPanelButton = page.getByRole('button', { name: 'Open the properties panel' }) if (await openPanelButton.count()) { await openPanelButton.click() } } async function getRawEditorContent(page: Page): Promise { return page.evaluate(() => { const el = document.querySelector('.cm-content') if (!el) return '' // eslint-disable-next-line @typescript-eslint/no-explicit-any const view = (el as any).cmTile?.view if (view) return view.state.doc.toString() as string return el.textContent ?? '' }) } async function setRawEditorContent(page: Page, content: string) { await page.evaluate((nextContent) => { const el = document.querySelector('.cm-content') if (!el) { throw new Error('CodeMirror content element is missing') } // eslint-disable-next-line @typescript-eslint/no-explicit-any const view = (el as any).cmTile?.view if (!view) { throw new Error('CodeMirror view is missing') } view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: nextContent }, }) }, content) } async function placeCaretAtEndOfBlock(page: Page, blockIndex: number): Promise { const block = page.locator('.bn-block-content').nth(blockIndex) await expect(block).toBeVisible({ timeout: 5_000 }) const placed = await block.evaluate((element) => { const editable = element.closest('[contenteditable="true"]') if (editable instanceof HTMLElement) editable.focus() const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) let lastTextNode: Text | null = null while (walker.nextNode()) { if (walker.currentNode.textContent) lastTextNode = walker.currentNode as Text } if (!lastTextNode) return false const range = document.createRange() range.setStart(lastTextNode, lastTextNode.textContent?.length ?? 0) range.collapse(true) const selection = window.getSelection() selection?.removeAllRanges() selection?.addRange(range) document.dispatchEvent(new Event('selectionchange')) return true }) expect(placed).toBe(true) } function initializeDelayedSaveProbeState(): void { const probeWindow = window as SaveBeforeSwitchProbeWindow let releaseSave: (() => void) | null = null probeWindow.__saveBeforeSwitchProbeGate = new Promise((resolve) => { releaseSave = resolve }) probeWindow.__saveBeforeSwitchProbe = { calls: [], release: () => { releaseSave?.() }, } } function installDelayedSaveProbeInterceptor(): void { const probeWindow = window as SaveBeforeSwitchProbeWindow const readStringArg = (args: Record | undefined, key: string) => { const value = args?.[key] return typeof value === 'string' ? value : '' } const patchHandlers = (handlers?: Record) => unknown> | null) => { if (!handlers) return null if (Reflect.get(handlers, '__saveBeforeSwitchProbePatched') === true) return handlers const originalSave = handlers.save_note_content if (!originalSave) throw new Error('save_note_content handler is unavailable') handlers.save_note_content = async (args?: Record) => { probeWindow.__saveBeforeSwitchProbe?.calls.push({ path: readStringArg(args, 'path'), content: readStringArg(args, 'content'), }) await probeWindow.__saveBeforeSwitchProbeGate return originalSave(args) } Object.defineProperty(handlers, '__saveBeforeSwitchProbePatched', { configurable: true, enumerable: false, value: true, }) return handlers } let ref = patchHandlers(probeWindow.__mockHandlers) Object.defineProperty(probeWindow, '__mockHandlers', { configurable: true, get() { return patchHandlers(ref) ?? ref }, set(value) { ref = patchHandlers(value) }, }) } async function installDelayedSaveProbe(page: Page): Promise { await page.evaluate(initializeDelayedSaveProbeState) await page.evaluate(installDelayedSaveProbeInterceptor) } async function releaseDelayedSave(page: Page): Promise { await page.evaluate(() => { const probeWindow = window as SaveBeforeSwitchProbeWindow probeWindow.__saveBeforeSwitchProbe?.release() }) } async function delayedSaveCount(page: Page): Promise { return page.evaluate(() => { const probeWindow = window as SaveBeforeSwitchProbeWindow return probeWindow.__saveBeforeSwitchProbe?.calls.length ?? 0 }) } test.beforeEach(async ({ page }, testInfo) => { testInfo.setTimeout(60_000) await page.goto('/') await page.waitForLoadState('networkidle') tempVaultDir = createFixtureVaultCopy() await openFixtureVaultDesktopHarness(page, tempVaultDir) }) test.afterEach(() => { removeFixtureVaultCopy(tempVaultDir) }) test('@smoke switching notes persists unsaved raw edits without waiting for the debounce window', async ({ page }) => { const noteBPath = path.join(tempVaultDir, 'note', 'note-b.md') const appendedText = `Flushed before note switch ${Date.now()}` await openNote(page, 'Note B') await openRawMode(page) const rawContent = await getRawEditorContent(page) await setRawEditorContent(page, `${rawContent}\n\n${appendedText}`) await page.waitForTimeout(100) await openNote(page, 'Alpha Project') await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('alpha-project', { timeout: 5_000 }) await expect.poll(async () => getRawEditorContent(page)).toContain('# Alpha Project') await expect.poll( () => fs.readFileSync(noteBPath, 'utf8'), { timeout: 450, intervals: [50, 100, 100, 100, 100] }, ).toContain(appendedText) }) test('@smoke switching notes during a slow rich-editor save writes once and opens the latest note', async ({ page }) => { const errors = collectReactUpdateLoopErrors(page) const noteBPath = path.join(tempVaultDir, 'note', 'note-b.md') const appendedText = `Slow rich save before switch ${Date.now()}` const noteList = page.locator('[data-testid="note-list-container"]') await installDelayedSaveProbe(page) await openNote(page, 'Note B') await expect(page.locator('.bn-editor h1').first()).toHaveText('Note B', { timeout: 5_000 }) await placeCaretAtEndOfBlock(page, 1) await page.keyboard.type(` ${appendedText}`, { delay: 10 }) await expect(page.locator('.bn-block-content').filter({ hasText: appendedText })).toBeVisible({ timeout: 5_000 }) await page.waitForTimeout(100) await triggerMenuCommand(page, 'file-save') await expect.poll(() => delayedSaveCount(page), { timeout: 5_000 }).toBe(1) await noteList.getByText('Alpha Project', { exact: true }).click() await noteList.getByText('Note C', { exact: true }).click() await expect.poll(() => delayedSaveCount(page), { timeout: 5_000 }).toBe(1) await releaseDelayedSave(page) await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('note-c', { timeout: 10_000 }) await expect.poll( () => fs.readFileSync(noteBPath, 'utf8'), { timeout: 10_000 }, ).toContain(appendedText) expect(errors).toEqual([]) }) test('@smoke deleting a property after switching notes keeps the current note editable', async ({ page }) => { const alphaPath = path.join(tempVaultDir, 'project', 'alpha-project.md') await openNote(page, 'Note B') await openPropertiesPanel(page) await expect(page.getByTestId('editable-property').filter({ hasText: 'Status' })).toBeVisible() await openNote(page, 'Alpha Project') await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('alpha-project', { timeout: 5_000 }) const ownerRow = page.getByTestId('editable-property').filter({ hasText: 'Owner' }) await expect(ownerRow).toBeVisible() await ownerRow.hover() await ownerRow.getByTitle('Delete property').click({ force: true }) await expect(ownerRow).toHaveCount(0) await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('alpha-project') await expect.poll( () => fs.readFileSync(alphaPath, 'utf8'), { timeout: 1_000, intervals: [50, 100, 200, 300, 350] }, ).not.toContain('Owner:') })