hololake-platform/tests/smoke/rich-editor-reload-typing.spec.ts

477 lines
18 KiB
TypeScript

import fs from 'fs'
import path from 'path'
import { test, expect, type Page } from '@playwright/test'
import {
createFixtureVaultCopy,
openFixtureVaultDesktopHarness,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
import { triggerMenuCommand } from './testBridge'
const IMAGE_DATA_URL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAO+yK9sAAAAASUVORK5CYII='
const MEDIA_BLOCKQUOTE_NOTE_TITLE = 'Media Reload Blockquote'
const MEDIA_BLOCKQUOTE_TEXT = 'Quote before media reload'
let tempVaultDir: string
type TextBlockTarget = { text: string }
type NoteTitleTarget = { title: string }
type NotePathTarget = { notePath: string }
type MediaBlockquoteFile = { filePath: string }
type MockHandler = (args?: Record<string, unknown>) => unknown
type SaveProbe = Array<{ content: string; path: string }>
type SaveCountExpectation = { expectedCount: number; page: Page }
type RichEditorSaveProbeWindow = Window & typeof globalThis & {
__mockHandlers?: Record<string, MockHandler>
__richEditorTransformSaveProbe?: SaveProbe
}
function isEditorTypingCrash(message: string): boolean {
return (
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') ||
message.includes('TypeError')
)
}
function trackEditorTypingCrashes(page: Page): string[] {
const messages: string[] = []
page.on('pageerror', (error) => {
if (isEditorTypingCrash(error.message)) messages.push(error.message)
})
page.on('console', (message) => {
if (message.type() === 'error' && isEditorTypingCrash(message.text())) {
messages.push(message.text())
}
})
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<void> {
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<void> {
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<void> {
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(filenameStem, { timeout: 10_000 })
}
async function expectEditorFocused(page: Page): Promise<void> {
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<void> {
const block = page.locator('.bn-block-content').nth(blockIndex)
await expect(block).toBeVisible({ timeout: 5_000 })
await placeCaretAtEndOfBlockElement(block)
}
async function placeCaretAtEndOfBlockContaining(page: Page, target: TextBlockTarget): Promise<void> {
const block = page.locator('.bn-block-content').filter({ hasText: target.text }).first()
await expect(block).toBeVisible({ timeout: 5_000 })
await placeCaretAtEndOfBlockElement(block)
}
async function placeCaretAtEndOfBlockElement(block: ReturnType<Page['locator']>): Promise<void> {
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)
}
async function expectNoteFileToContain(filePath: string, marker: string): Promise<void> {
await expect.poll(() => fs.readFileSync(filePath, 'utf8'), { timeout: 10_000 }).toContain(marker)
}
async function installRichEditorSaveProbe(page: Page): Promise<void> {
await page.evaluate(() => {
const probeWindow = window as RichEditorSaveProbeWindow
const saves: SaveProbe = []
const patchHandlers = (handlers?: Record<string, MockHandler> | null) => {
if (!handlers || Reflect.get(handlers, '__richEditorTransformProbePatched') === true) {
return handlers ?? null
}
const originalSaveNoteContent = handlers.save_note_content
handlers.save_note_content = (args?: Record<string, unknown>) => {
saves.push({
content: typeof args?.content === 'string' ? args.content : '',
path: typeof args?.path === 'string' ? args.path : '',
})
return originalSaveNoteContent?.(args)
}
Object.defineProperty(handlers, '__richEditorTransformProbePatched', {
configurable: true,
enumerable: false,
value: true,
})
return handlers
}
let ref = patchHandlers(probeWindow.__mockHandlers) ?? null
probeWindow.__richEditorTransformSaveProbe = saves
Object.defineProperty(probeWindow, '__mockHandlers', {
configurable: true,
get() {
return patchHandlers(ref) ?? ref
},
set(value) {
ref = patchHandlers(value) ?? null
},
})
})
}
async function expectSaveCount({ expectedCount, page }: SaveCountExpectation): Promise<void> {
await expect.poll(() => page.evaluate(() => {
const probeWindow = window as RichEditorSaveProbeWindow
return probeWindow.__richEditorTransformSaveProbe?.length ?? 0
}), { timeout: 10_000 }).toBeGreaterThanOrEqual(expectedCount)
}
function writeChecklistNote(filePath: string, marker: string, checked = false): void {
fs.writeFileSync(filePath, `---
Is A: Note
Status: Active
---
# Note B
- [${checked ? 'x' : ' '}] Toggle me
- [ ] Keep me
${marker}
`, 'utf8')
}
function writePlainNoteB(filePath: string, marker: string): void {
fs.writeFileSync(filePath, `---
Is A: Note
Status: Active
---
# Note B
${marker}
`, 'utf8')
}
function writeMediaBlockquoteNote({ filePath }: MediaBlockquoteFile): void {
fs.writeFileSync(filePath, `---
Is A: Note
Status: Active
---
# ${MEDIA_BLOCKQUOTE_NOTE_TITLE}
Intro paragraph before the media flow.
> ${MEDIA_BLOCKQUOTE_TEXT}
![Reload image](${IMAGE_DATA_URL})
Paragraph after media reload.
`, 'utf8')
}
function checklistCheckbox(page: Page, index: number) {
return page.locator('.bn-block-content[data-content-type="checkListItem"] input[type="checkbox"]').nth(index)
}
async function retainCurrentChecklistCheckbox(page: Page): Promise<void> {
await page.evaluate(() => {
const testWindow = window as typeof window & { __staleChecklistCheckbox?: HTMLInputElement | null }
testWindow.__staleChecklistCheckbox = document.querySelector(
'.bn-block-content[data-content-type="checkListItem"] input[type="checkbox"]',
)
})
}
async function dispatchRetainedChecklistChange(page: Page): Promise<void> {
await page.evaluate(() => {
const testWindow = window as typeof window & { __staleChecklistCheckbox?: HTMLInputElement | null }
const checkbox = testWindow.__staleChecklistCheckbox
if (!checkbox) throw new Error('Expected retained checklist checkbox')
checkbox.checked = !checkbox.checked
checkbox.dispatchEvent(new Event('change', { bubbles: true }))
})
}
async function reloadVault(page: Page): Promise<void> {
await triggerMenuCommand(page, 'vault-reload')
await expect(page.getByText(/Vault reloaded \(\d+ entries\)/).last()).toBeVisible({
timeout: 5_000,
})
}
async function stubUpdatedPull(page: Page, updatedFile: string): Promise<void> {
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<void> {
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<string> {
const note = page
.getByTestId('note-list-container')
.locator('[data-note-path]')
.filter({ hasText: target.title })
.first()
await expect(note).toBeVisible({ timeout: 5_000 })
const notePath = await note.getAttribute('data-note-path')
if (!notePath) throw new Error(`Missing data-note-path for ${target.title}`)
return notePath
}
async function touchDragHandleForBlock(page: Page, target: TextBlockTarget): Promise<void> {
const block = page.locator('.bn-block-content').filter({ hasText: target.text }).first()
await expect(block).toBeVisible({ timeout: 5_000 })
await block.hover()
const dragHandle = page.getByRole('button', { name: 'Open block menu' }).first()
await expect(dragHandle).toBeVisible({ timeout: 5_000 })
await dragHandle.hover()
}
async function runMediaFrontmatterCycle(page: Page, target: NotePathTarget): Promise<void> {
await page.evaluate(async ({ imageData, path }) => {
const saveImage = window.__mockHandlers?.save_image
const updateFrontmatter = window.__mockHandlers?.update_frontmatter
if (typeof saveImage !== 'function') throw new Error('Fixture vault is missing save_image')
if (typeof updateFrontmatter !== 'function') throw new Error('Fixture vault is missing update_frontmatter')
await saveImage({ filename: 'reload-crash.png', data: imageData })
await updateFrontmatter({ path, key: 'Status', value: 'Reviewed' })
}, { imageData: IMAGE_DATA_URL, path: target.notePath })
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await page.setViewportSize({ width: 1400, height: 860 })
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('@smoke typing after a rich-editor reload and note switch stays usable', async ({ page }) => {
const crashes = trackEditorTypingCrashes(page)
const noteBPath = path.join(tempVaultDir, 'note', 'note-b.md')
const draftMarker = `draft before reload ${Date.now()}`
const afterReloadMarker = `typing after reload ${Date.now()}`
await openNote(page, 'Note B')
await placeCaretAtEndOfBlock(page, 1)
await page.keyboard.type(` ${draftMarker}`, { delay: 10 })
await expectNoteFileToContain(noteBPath, draftMarker)
await placeCaretAtEndOfBlock(page, 1)
await page.keyboard.type('/')
await expect(page.locator('.bn-suggestion-menu')).toBeVisible({ timeout: 5_000 })
await reloadVault(page)
await page.keyboard.press('Escape')
await expect(page.locator('.bn-suggestion-menu')).not.toBeVisible({ timeout: 5_000 })
await openNote(page, 'Alpha Project')
await openNote(page, 'Note B')
await placeCaretAtEndOfBlock(page, 1)
await page.keyboard.type(` -> ${afterReloadMarker}`, { delay: 10 })
await expectNoteFileToContain(noteBPath, afterReloadMarker)
await page.waitForTimeout(500)
expect(crashes).toEqual([])
})
test('@smoke rich-editor typing stays usable through repeated saves and transforms', async ({ page }) => {
const crashes = trackEditorTypingCrashes(page)
const noteBPath = path.join(tempVaultDir, 'note', 'note-b.md')
const firstMarker = `repeated save transform first ${Date.now()}`
const secondMarker = `repeated save transform second ${Date.now()}`
const finalMarker = `repeated save transform final ${Date.now()}`
await installRichEditorSaveProbe(page)
await openNote(page, 'Note B')
await placeCaretAtEndOfBlock(page, 1)
await page.keyboard.type(` ${firstMarker} $x^2$`, { delay: 10 })
await triggerMenuCommand(page, 'file-save')
await expectSaveCount({ expectedCount: 1, page })
await page.keyboard.type(` ${secondMarker} ->`, { delay: 10 })
await triggerMenuCommand(page, 'file-save')
await expectSaveCount({ expectedCount: 2, page })
await page.keyboard.type(` ${finalMarker}`, { delay: 10 })
await triggerMenuCommand(page, 'file-save')
await expectSaveCount({ expectedCount: 3, page })
await expectNoteFileToContain(noteBPath, finalMarker)
expect(crashes).toEqual([])
})
test('typing after current-note filesystem refresh stays usable', async ({ page }) => {
const crashes = trackEditorTypingCrashes(page)
const noteBPath = path.join(tempVaultDir, 'note', 'note-b.md')
const reloadMarker = `filesystem refresh ${Date.now()}`
const afterRefreshMarker = `typing after filesystem refresh ${Date.now()}`
await openNote(page, 'Note B')
await placeCaretAtEndOfBlock(page, 1)
writePlainNoteB(noteBPath, reloadMarker)
await reloadVault(page)
await page.getByTestId('note-list-container').getByText('Note B', { exact: true }).click()
await expect(page.locator('.bn-editor')).toContainText(reloadMarker)
await placeCaretAtEndOfBlock(page, 1)
await page.keyboard.type(` -> ${afterRefreshMarker}`, { delay: 10 })
await expectNoteFileToContain(noteBPath, afterRefreshMarker)
await page.waitForTimeout(500)
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')
const initialMarker = `initial checklist body ${Date.now()}`
const reloadMarker = `reloaded checklist body ${Date.now()}`
writeChecklistNote(noteBPath, initialMarker)
await openNote(page, 'Note B')
await expect(checklistCheckbox(page, 0)).not.toBeChecked()
await retainCurrentChecklistCheckbox(page)
writeChecklistNote(noteBPath, reloadMarker)
await reloadVault(page)
await openNote(page, 'Alpha Project')
await openNote(page, 'Note B')
await expect(page.locator('.bn-editor')).toContainText(reloadMarker)
await dispatchRetainedChecklistChange(page)
const liveCheckbox = checklistCheckbox(page, 0)
await liveCheckbox.click()
await expect(liveCheckbox).toBeChecked()
await expectNoteFileToContain(noteBPath, '- [x] Toggle me')
await page.waitForTimeout(500)
expect(crashes).toEqual([])
})
test('clicking back into a blockquote after media and frontmatter reload stays usable', async ({ page }) => {
const crashes = trackEditorTypingCrashes(page)
const notePath = path.join(tempVaultDir, 'note', 'media-reload-blockquote.md')
const afterReloadMarker = `block quote click after media reload ${Date.now()}`
writeMediaBlockquoteNote({ filePath: notePath })
await reloadVault(page)
await openNote(page, MEDIA_BLOCKQUOTE_NOTE_TITLE)
await expect(page.locator('.bn-editor img.bn-visual-media')).toBeVisible({ timeout: 5_000 })
await touchDragHandleForBlock(page, { text: MEDIA_BLOCKQUOTE_TEXT })
const openedNotePath = await notePathForTitle(page, { title: MEDIA_BLOCKQUOTE_NOTE_TITLE })
await runMediaFrontmatterCycle(page, { notePath: openedNotePath })
await reloadVault(page)
await expect(page.locator('.bn-editor')).toContainText(MEDIA_BLOCKQUOTE_TEXT)
await touchDragHandleForBlock(page, { text: MEDIA_BLOCKQUOTE_TEXT })
await placeCaretAtEndOfBlockContaining(page, { text: MEDIA_BLOCKQUOTE_TEXT })
await page.keyboard.type(` ${afterReloadMarker}`, { delay: 10 })
await expectNoteFileToContain(notePath, afterReloadMarker)
await page.waitForTimeout(500)
expect(crashes).toEqual([])
})