diff --git a/tests/smoke/rich-editor-reload-typing.spec.ts b/tests/smoke/rich-editor-reload-typing.spec.ts index 721025b7..d6bfc439 100644 --- a/tests/smoke/rich-editor-reload-typing.spec.ts +++ b/tests/smoke/rich-editor-reload-typing.spec.ts @@ -32,6 +32,7 @@ function isEditorTypingCrash(message: string): boolean { message.includes('beforeinput') || message.includes('Block with ID') || message.includes('stale editor view') || + message.includes('Maximum update depth') || message.includes('Cannot read properties') || message.includes('undefined is not an object') || message.includes('RangeError') || @@ -52,12 +53,40 @@ function trackEditorTypingCrashes(page: Page): string[] { return messages } +function slugifyTitle(title: string): string { + return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') +} + async function openNote(page: Page, title: string): Promise { const noteList = page.getByTestId('note-list-container') await noteList.getByText(title, { exact: true }).click() await expect(page.locator('.bn-editor h1').first()).toHaveText(title, { timeout: 5_000 }) } +async function createUntitledNote(page: Page): Promise { + await page.locator('body').click() + await triggerMenuCommand(page, 'file-new-note') + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) + await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+(?:-\d+)?/i, { + timeout: 5_000, + }) + const titleBlock = page.locator('.bn-block-content[data-content-type="heading"]').first() + await expect(titleBlock).toBeVisible({ timeout: 5_000 }) + await titleBlock.click() + await expectEditorFocused(page) +} + +async function expectActiveFilename(page: Page, filenameStem: string): Promise { + await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(filenameStem, { timeout: 10_000 }) +} + +async function expectEditorFocused(page: Page): Promise { + await expect.poll(async () => page.evaluate(() => { + const active = document.activeElement as HTMLElement | null + return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]')) + }), { timeout: 5_000 }).toBe(true) +} + async function placeCaretAtEndOfBlock(page: Page, blockIndex: number): Promise { const block = page.locator('.bn-block-content').nth(blockIndex) await expect(block).toBeVisible({ timeout: 5_000 }) @@ -222,6 +251,22 @@ async function reloadVault(page: Page): Promise { }) } +async function stubUpdatedPull(page: Page, updatedFile: string): Promise { + await page.evaluate((filePath) => { + window.__mockHandlers!.git_pull = () => ({ + status: 'updated', + message: 'Pulled 1 update from remote', + updatedFiles: [filePath], + conflictFiles: [], + }) + }, updatedFile) +} + +async function pullFromRemote(page: Page): Promise { + await triggerMenuCommand(page, 'vault-pull') + await expect(page.getByText('Pulled 1 update(s) from remote')).toBeVisible({ timeout: 5_000 }) +} + async function notePathForTitle(page: Page, target: NoteTitleTarget): Promise { const note = page .getByTestId('note-list-container') @@ -345,6 +390,39 @@ test('typing after current-note filesystem refresh stays usable', async ({ page expect(crashes).toEqual([]) }) +test('editing after create-note pull reload and note switch avoids React update loops', async ({ page }) => { + const crashes = trackEditorTypingCrashes(page) + const title = `Reload Loop Guard ${Date.now()}` + const filenameStem = slugifyTitle(title) + const createdNotePath = path.join(tempVaultDir, `${filenameStem}.md`) + const noteCPath = path.join(tempVaultDir, 'note', 'note-c.md') + const createdBody = `Created before reload loop ${Date.now()}` + const pulledMarker = `Unrelated pulled reload loop change ${Date.now()}` + const afterSwitchMarker = `typing after create pull reload ${Date.now()}` + + await createUntitledNote(page) + await page.keyboard.type(title, { delay: 10 }) + await page.keyboard.press('Enter') + await page.keyboard.type(createdBody, { delay: 10 }) + await expectActiveFilename(page, filenameStem) + await expectNoteFileToContain(createdNotePath, createdBody) + + fs.appendFileSync(noteCPath, `\n\n${pulledMarker}\n`, 'utf8') + await stubUpdatedPull(page, noteCPath) + await pullFromRemote(page) + await reloadVault(page) + + await openNote(page, 'Alpha Project') + await openNote(page, title) + await placeCaretAtEndOfBlockContaining(page, { text: createdBody }) + await page.keyboard.type(` ${afterSwitchMarker}`, { delay: 10 }) + + await expectNoteFileToContain(createdNotePath, afterSwitchMarker) + await expect(page.locator('.error-boundary')).toHaveCount(0) + await page.waitForTimeout(500) + expect(crashes).toEqual([]) +}) + test('checklist toggles after a rich-editor reload ignore stale checkbox events', async ({ page }) => { const crashes = trackEditorTypingCrashes(page) const noteBPath = path.join(tempVaultDir, 'note', 'note-b.md')