hololake-platform/tests/smoke/h1-untitled-auto-rename.spec.ts

499 lines
18 KiB
TypeScript

import fs from 'fs'
import { test, expect, type Page } from '@playwright/test'
import { APP_COMMAND_IDS } from '../../src/hooks/appCommandCatalog'
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { triggerMenuCommand, triggerShortcutCommand } from './testBridge'
function markdownFiles(vaultPath: string): string[] {
return fs.readdirSync(vaultPath).filter((name) => name.endsWith('.md')).sort()
}
function slugifyTitle(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
}
interface FileExpectation {
vaultPath: string
filename: string
}
interface FileContentExpectation extends FileExpectation {
text: string
}
interface EmptyTitleHeadingState {
contentType: string | null
placeholder: string | null
}
const REACT_UPDATE_LOOP_FRAGMENTS = [
'Maximum update depth',
'React error #185',
'#185',
]
function collectReactUpdateLoopErrors(page: Page): string[] {
const errors: string[] = []
const collect = (message: string) => {
if (REACT_UPDATE_LOOP_FRAGMENTS.some((fragment) => message.includes(fragment))) {
errors.push(message)
}
}
page.on('pageerror', (error) => collect(error.message))
page.on('console', (message) => {
if (message.type() === 'error') collect(message.text())
})
return errors
}
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,
})
await expectStableEmptyTitleHeading(page)
}
async function writeNewHeading(page: Page, title: string): Promise<void> {
await page.keyboard.type(title)
await page.keyboard.press('Enter')
}
async function writeNewHeadingAndBody(page: Page, title: string, body: string): Promise<void> {
await page.keyboard.type(title, { delay: 20 })
await page.keyboard.press('Enter')
await page.keyboard.type(body, { delay: 20 })
}
async function expectRenamedFile({ vaultPath, filename }: FileExpectation): Promise<void> {
await expect(async () => {
expect(markdownFiles(vaultPath)).toContain(filename)
}).toPass({ timeout: 10_000 })
}
async function expectFileMissing({ vaultPath, filename }: FileExpectation): Promise<void> {
await expect(async () => {
expect(markdownFiles(vaultPath)).not.toContain(filename)
}).toPass({ timeout: 10_000 })
}
async function expectFileContentContains({ vaultPath, filename, text }: FileContentExpectation): Promise<void> {
await expect(async () => {
const content = fs.readFileSync(`${vaultPath}/${filename}`, 'utf-8')
expect(content).toContain(text)
}).toPass({ timeout: 10_000 })
}
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 readEmptyTitleHeadingState(page: Page): Promise<EmptyTitleHeadingState> {
return page.evaluate(() => {
const firstBlock = document.querySelector('.bn-block-content') as HTMLElement | null
const inlineHeading = firstBlock?.querySelector('.bn-inline-content') as HTMLElement | null
return {
contentType: firstBlock?.getAttribute('data-content-type') ?? null,
placeholder: inlineHeading ? getComputedStyle(inlineHeading, '::before').content : null,
}
})
}
async function selectionInsideEmptyTitleHeading(page: Page): Promise<boolean> {
return page.evaluate(() => {
const firstBlock = document.querySelector('.bn-block-content') as HTMLElement | null
const selection = window.getSelection()
const anchorNode = selection?.anchorNode ?? null
const anchorElement = anchorNode instanceof Element ? anchorNode : anchorNode?.parentElement ?? null
return Boolean(selection?.rangeCount && anchorElement && firstBlock?.contains(anchorElement))
})
}
async function expectReadyEmptyTitleHeading(page: Page): Promise<void> {
await expectEditorFocused(page)
await expect.poll(() => readEmptyTitleHeadingState(page), {
timeout: 5_000,
}).toEqual({
contentType: 'heading',
placeholder: '"Title"',
})
await expect.poll(() => selectionInsideEmptyTitleHeading(page), { timeout: 5_000 }).toBe(true)
}
async function expectStableEmptyTitleHeading(page: Page): Promise<void> {
await expectReadyEmptyTitleHeading(page)
await page.waitForTimeout(300)
await expectReadyEmptyTitleHeading(page)
}
async function activeSelectionBlockType(page: Page): Promise<string | null> {
return page.evaluate(() => {
const anchorNode = window.getSelection()?.anchorNode
if (!anchorNode) return null
const anchorElement = anchorNode instanceof Element ? anchorNode : anchorNode.parentElement
if (!anchorElement) return null
return anchorElement.closest('.bn-block-content')?.getAttribute('data-content-type') ?? null
})
}
async function getRawEditorContent(page: Page): Promise<string> {
return page.evaluate(() => {
const host = document.querySelector('[data-testid="raw-editor-codemirror"]') as (Element & {
__cmView?: { state: { doc: { toString: () => string } } }
}) | null
const view = host?.__cmView
if (!view) throw new Error('CodeMirror view is missing')
return view.state.doc.toString()
})
}
async function setRawEditorContent(page: Page, content: string): Promise<void> {
await page.evaluate((nextContent) => {
const host = document.querySelector('[data-testid="raw-editor-codemirror"]') as (Element & {
__cmView?: {
state: { doc: { toString: () => string } }
dispatch: (spec: { changes: { from: number; to: number; insert: string } }) => void
}
}) | null
const view = host?.__cmView
if (!view) throw new Error('CodeMirror view is missing')
view.dispatch({
changes: { from: 0, to: view.state.doc.toString().length, insert: nextContent },
})
}, content)
}
async function openRawEditor(page: Page): Promise<void> {
await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor)
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
}
async function openRichEditor(page: Page): Promise<void> {
await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor)
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function expectTitleHeadingText(page: Page, title: string): Promise<void> {
await expect(page.locator('.bn-editor [data-content-type="heading"]').first()).toContainText(title, {
timeout: 5_000,
})
}
async function dispatchRichHeadingPaste(page: Page, text: string): Promise<void> {
await page.evaluate((textFragment) => {
const target = document.querySelector<HTMLElement>(
[
'.bn-editor [data-content-type="heading"][data-level="1"] .bn-inline-content',
'.bn-editor [data-content-type="heading"]:not([data-level]) .bn-inline-content',
'.bn-editor h1',
].join(', '),
)
if (!target) throw new Error('Title heading paste target was not found')
const clipboardData = new DataTransfer()
clipboardData.setData('text/html', '<h1>Rich <em>Paste</em> Payload</h1><table><tr><td>structured</td></tr></table>')
clipboardData.setData('text/plain', textFragment)
const event = new Event('paste', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'clipboardData', { value: clipboardData })
if (target.dispatchEvent(event)) {
throw new Error('Rich title-heading paste was not intercepted')
}
}, text)
}
async function clickEditorChromeBelowLastBlock(page: Page): Promise<void> {
const point = await page.evaluate(() => {
const container = document.querySelector('.editor__blocknote-container')?.getBoundingClientRect()
const blocks = Array.from(document.querySelectorAll('.bn-block-outer'))
const lastBlock = blocks.at(-1)?.getBoundingClientRect()
if (!container || !lastBlock) return null
return {
x: Math.min(container.right - 24, Math.max(container.left + 24, lastBlock.left + 40)),
y: Math.min(container.bottom - 24, lastBlock.bottom + 18),
}
})
expect(point).not.toBeNull()
await page.mouse.click(point!.x, point!.y)
}
let tempVaultDir: string
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(90_000)
tempVaultDir = createFixtureVaultCopy()
await openFixtureVaultTauri(page, tempVaultDir)
})
test.afterEach(async () => {
removeFixtureVaultCopy(tempVaultDir)
})
test('new-note H1 auto-rename keeps the editor usable and leaves no untitled duplicates', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => {
errors.push(err.message)
})
const titles = [
'Fresh Focus Title',
'Rapid Rename 2',
'Rapid Rename 3',
'Rapid Rename 4',
'Rapid Rename 5',
]
for (const [index, title] of titles.entries()) {
await createUntitledNote(page)
if (index === 0) {
await writeNewHeadingAndBody(page, title, 'Body continues while rename is pending.')
} else {
await writeNewHeading(page, title)
}
await expectActiveFilename(page, slugifyTitle(title))
await expectRenamedFile({ vaultPath: tempVaultDir, filename: `${slugifyTitle(title)}.md` })
await expectEditorFocused(page)
await expectFileContentContains({
vaultPath: tempVaultDir,
filename: `${slugifyTitle(title)}.md`,
text: `# ${title}`,
})
if (index === 0) {
await expectFileContentContains({
vaultPath: tempVaultDir,
filename: 'fresh-focus-title.md',
text: 'Body continues while rename is pending.',
})
await page.keyboard.type(' focus-probe')
await expectFileContentContains({
vaultPath: tempVaultDir,
filename: 'fresh-focus-title.md',
text: 'focus-probe',
})
}
}
const files = markdownFiles(tempVaultDir)
expect(files).toContain('fresh-focus-title.md')
expect(files.filter((name) => name.startsWith('untitled-note-'))).toEqual([])
expect(files.filter((name) => /^rapid-rename-\d+\.md$/.test(name))).toHaveLength(4)
expect(errors).toEqual([])
})
test('new-note short title typing and rich heading paste stay in the H1 until Enter', async ({ page }) => {
const errors = collectReactUpdateLoopErrors(page)
const titleStart = 'Obsi'
const title = 'Obsidian'
const richPasteSuffix = ' Rich Paste Payload'
const pastedTitle = `${title}${richPasteSuffix}`
const bodyText = 'Body starts only after intentional Enter.'
await createUntitledNote(page)
await page.keyboard.type(titleStart, { delay: 80 })
await expectTitleHeadingText(page, titleStart)
await expectEditorFocused(page)
await expect.poll(() => activeSelectionBlockType(page), { timeout: 5_000 }).toBe('heading')
await page.waitForTimeout(1_000)
await expectTitleHeadingText(page, titleStart)
await expect.poll(() => activeSelectionBlockType(page), { timeout: 5_000 }).toBe('heading')
await page.keyboard.type('dian', { delay: 80 })
await expectTitleHeadingText(page, title)
await page.keyboard.press('End')
await dispatchRichHeadingPaste(page, richPasteSuffix)
await expectTitleHeadingText(page, pastedTitle)
await expect.poll(() => activeSelectionBlockType(page), { timeout: 5_000 }).toBe('heading')
await page.keyboard.press('Enter')
await page.keyboard.type(bodyText, { delay: 35 })
await expectActiveFilename(page, slugifyTitle(pastedTitle))
await expectFileContentContains({
vaultPath: tempVaultDir,
filename: `${slugifyTitle(pastedTitle)}.md`,
text: `# ${pastedTitle}`,
})
await expectFileContentContains({
vaultPath: tempVaultDir,
filename: `${slugifyTitle(pastedTitle)}.md`,
text: bodyText,
})
expect(errors).toEqual([])
})
test('@smoke new-note typing stays focused through initial save settlement', async ({ page }) => {
const title = 'Creation Focus Guard'
const bodyText = 'Body keeps accepting text while creation writes and saves settle.'
await createUntitledNote(page)
await page.keyboard.type(title, { delay: 35 })
await page.keyboard.press('Enter')
await page.keyboard.type(bodyText, { delay: 35 })
await page.waitForTimeout(1_000)
await expectEditorFocused(page)
await page.keyboard.type(' Still focused.')
await expectActiveFilename(page, slugifyTitle(title))
await expectFileContentContains({
vaultPath: tempVaultDir,
filename: `${slugifyTitle(title)}.md`,
text: 'Still focused.',
})
})
test('new-note editor mode roundtrip stays editable after auto-rename', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => {
errors.push(err.message)
})
const title = 'Mode Switch Crash Guard'
const filename = `${slugifyTitle(title)}.md`
const rawLine = 'Raw mode edit after auto-rename.'
const richLine = 'Rich editor still accepts typing after the raw-mode return.'
await createUntitledNote(page)
await writeNewHeadingAndBody(page, title, 'Initial body before the raw-mode round trip.')
await expectActiveFilename(page, slugifyTitle(title))
await expectRenamedFile({ vaultPath: tempVaultDir, filename })
await openRawEditor(page)
const rawContent = await getRawEditorContent(page)
await setRawEditorContent(page, `${rawContent}\n\n${rawLine}`)
await page.waitForTimeout(650)
await openRichEditor(page)
await expect(page.locator('.bn-editor')).toContainText(rawLine, { timeout: 5_000 })
await page.locator('.bn-editor').click()
await page.keyboard.type(richLine)
await expectFileContentContains({
vaultPath: tempVaultDir,
filename,
text: rawLine,
})
await expectFileContentContains({
vaultPath: tempVaultDir,
filename,
text: richLine,
})
expect(errors).toEqual([])
})
test('new-note H1 auto-rename preserves body typing and cursor while rename lands', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => {
errors.push(err.message)
})
await createUntitledNote(page)
await page.keyboard.type('Cursor Stable Rename', { delay: 30 })
await page.keyboard.press('Enter')
// Let the initial untitled save settle so the rename timer can fire mid-body typing.
await page.waitForTimeout(700)
const bodyText = 'Body keeps flowing through the rename without losing the caret or freezing.'
await page.keyboard.type(bodyText, { delay: 70 })
await expectActiveFilename(page, 'cursor-stable-rename')
await expectRenamedFile({ vaultPath: tempVaultDir, filename: 'cursor-stable-rename.md' })
await expectFileContentContains({
vaultPath: tempVaultDir,
filename: 'cursor-stable-rename.md',
text: bodyText,
})
await expectEditorFocused(page)
await expect.poll(() => activeSelectionBlockType(page), { timeout: 5_000 }).toBe('paragraph')
await page.keyboard.type(' Still typing after rename.')
await expectFileContentContains({
vaultPath: tempVaultDir,
filename: 'cursor-stable-rename.md',
text: 'Still typing after rename.',
})
await expectEditorFocused(page)
await expect(errors).toEqual([])
})
test('fresh-note Enter stays stable after autosave and editor chrome clicks', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => {
errors.push(err.message)
})
const title = 'Enter Selection Guard'
const filename = 'enter-selection-guard.md'
const firstLine = 'First paragraph before chrome clicks.'
const secondLine = 'Second paragraph after editor chrome click.'
const thirdLine = 'Third paragraph after another chrome click.'
await createUntitledNote(page)
await page.keyboard.type(title, { delay: 35 })
await page.keyboard.press('Enter')
await page.keyboard.type(firstLine, { delay: 35 })
await expectActiveFilename(page, 'enter-selection-guard')
await expectRenamedFile({ vaultPath: tempVaultDir, filename })
await page.waitForTimeout(900)
await clickEditorChromeBelowLastBlock(page)
await page.keyboard.press('Enter')
await page.keyboard.type(secondLine, { delay: 35 })
await clickEditorChromeBelowLastBlock(page)
await page.keyboard.press('Enter')
await page.keyboard.type(thirdLine, { delay: 35 })
await expect(page.locator('.error-boundary')).toHaveCount(0)
await expectEditorFocused(page)
expect(errors).toEqual([])
await expectFileContentContains({ vaultPath: tempVaultDir, filename, text: firstLine })
await expectFileContentContains({ vaultPath: tempVaultDir, filename, text: secondLine })
await expectFileContentContains({ vaultPath: tempVaultDir, filename, text: thirdLine })
})
test('@smoke new-note H1 auto-rename does not recreate the untitled file when a buffered save lands after rename', async ({ page }) => {
const title = 'Late Save Guard'
const lateBody = 'Body typed right before rename'
await createUntitledNote(page)
const untitledStem = (await page.getByTestId('breadcrumb-filename-trigger').textContent())?.trim()
expect(untitledStem).toMatch(/^untitled-note-\d+(?:-\d+)?$/i)
await writeNewHeading(page, title)
await page.waitForTimeout(2_600)
await page.keyboard.type(lateBody)
await expectActiveFilename(page, 'late-save-guard')
await expectRenamedFile({ vaultPath: tempVaultDir, filename: 'late-save-guard.md' })
await expectFileContentContains({
vaultPath: tempVaultDir,
filename: 'late-save-guard.md',
text: lateBody,
})
await page.waitForTimeout(800)
await expectFileMissing({
vaultPath: tempVaultDir,
filename: `${untitledStem}.md`,
})
})