Files
orca/tests/e2e/markdown-find-editing.spec.ts
Brennan Benson 9eb930173e Keep rich Markdown in place when focus returns from Find (#36781)
* Preserve rich Markdown viewport through bare focus returns

* Retire viewport ownership on editor remount

* Complete no-op navigation and body Tab coverage

* Keep patch metadata keys separate from dependency snapshots

* Retire the viewport before root detach callbacks

* Give the engine writer fixture its native selection

* Keep native focus navigation current in Markdown

* fix: leave smooth Markdown scrolling to native focus cancellation

* Cover focus wrapper removal and routed Markdown writers

* Type the viewport scroll spy and mark the focus-recovery wait as deliberate

* Scope Markdown viewport wheel and touch guards to the editor scroller
2026-11-01 11:46:13 -08:00

305 lines
12 KiB
TypeScript

import type { Locator, Page } from '@stablyai/playwright-test'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { pressShortcut } from './helpers/shortcuts'
import {
cleanupMarkdownFixture,
createMarkdownFixture,
getActiveWorktreeContext,
openMarkdownFixture,
waitForRichMarkdownEditor
} from './helpers/markdown-editor-fixture'
const TARGET_TEXT = 'Editing target paragraph keeps the pointer position and viewport.'
const MARKDOWN = [
'# Find and pointer editing',
'Needle remains at the beginning.',
...Array.from({ length: 100 }, (_, index) =>
index === 70 ? TARGET_TEXT : `Paragraph ${index} provides room to scroll through the document.`
),
'Needle remains at the end.'
].join('\n\n')
const TABLE_MARKDOWN = MARKDOWN.replace(
TARGET_TEXT,
`| Name | Value |\n| --- | --- |\n| Target | ${TARGET_TEXT} |`
)
async function textPoint(paragraph: Locator, offset: number) {
return paragraph.evaluate((element, offset) => {
const text = element.firstChild
if (!(text instanceof Text)) {
throw new Error('Expected a plain-text paragraph')
}
const range = document.createRange()
range.setStart(text, offset)
range.collapse(true)
const bounds = range.getBoundingClientRect()
return { x: bounds.left, y: bounds.top + bounds.height / 2 }
}, offset)
}
async function readParagraphSelection(paragraph: Locator) {
return paragraph.evaluate((element) => {
const selection = window.getSelection()
if (!selection?.anchorNode || !selection.focusNode) {
return null
}
if (!element.contains(selection.anchorNode) || !element.contains(selection.focusNode)) {
return null
}
const start = document.createRange()
start.selectNodeContents(element)
start.setEnd(selection.anchorNode, selection.anchorOffset)
const end = document.createRange()
end.selectNodeContents(element)
end.setEnd(selection.focusNode, selection.focusOffset)
return {
from: Math.min(start.toString().length, end.toString().length),
to: Math.max(start.toString().length, end.toString().length),
text: selection.toString()
}
})
}
async function centerParagraph(paragraph: Locator): Promise<void> {
await paragraph.evaluate((element) => element.scrollIntoView({ block: 'center' }))
}
async function pointAtParagraph(page: Page, paragraph: Locator, drag: boolean) {
const start = await textPoint(paragraph, 8)
await page.mouse.move(start.x, start.y)
await page.mouse.down()
if (drag) {
const end = await textPoint(paragraph, 24)
await page.mouse.move(end.x, end.y, { steps: 12 })
}
// Tiptap focus commands can schedule a stale-selection scroll for the next frame.
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
})
)
await page.mouse.up()
const selection = await readParagraphSelection(paragraph)
expect(selection).not.toBeNull()
if (!selection) {
throw new Error('Pointer selection left the intended paragraph')
}
if (drag) {
expect(selection.text).toBe('target paragraph')
} else {
expect(selection.from).toBe(selection.to)
}
return selection
}
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
})
for (const interaction of ['click', 'drag'] as const) {
test(`keeps Find open while a document ${interaction} edits at the pointer position`, async ({
orcaPage,
registerPostElectronShutdownCleanup
}, testInfo) => {
const context = await getActiveWorktreeContext(orcaPage)
const filePath = await createMarkdownFixture(
context,
'.orca-e2e-markdown-find-editing',
`find-${interaction}`,
testInfo.workerIndex,
MARKDOWN
)
registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
await openMarkdownFixture(orcaPage, context, filePath)
const editor = await waitForRichMarkdownEditor(orcaPage)
const paragraph = editor.locator('p').filter({ hasText: 'Editing' })
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
await editor.locator('p').first().click()
await pressShortcut(orcaPage, 'f')
const search = orcaPage.getByRole('textbox', { name: 'Find in rich markdown editor' })
await expect(search).toBeFocused()
await search.fill('Needle')
await orcaPage.getByRole('button', { name: 'Match case', exact: true }).click()
await orcaPage.getByRole('button', { name: 'Match whole word', exact: true }).click()
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
await centerParagraph(paragraph)
const originalScroll = await viewport.evaluate((element) => element.scrollTop)
const selection = await pointAtParagraph(orcaPage, paragraph, interaction === 'drag')
// Separate key events allow document updates to expose a caret reset between characters.
await orcaPage.keyboard.type('xy', { delay: 100 })
await orcaPage.screenshot({ path: testInfo.outputPath('find-document-edit.png') })
await expect(paragraph).toHaveText(
`${TARGET_TEXT.slice(0, selection.from)}xy${TARGET_TEXT.slice(selection.to)}`
)
await expect(editor.locator('p').first()).toHaveText('Needle remains at the beginning.')
await expect
.poll(() => readParagraphSelection(paragraph))
.toEqual({
from: selection.from + 2,
to: selection.from + 2,
text: ''
})
await expect
.poll(() => viewport.evaluate((element) => element.scrollTop))
.toBeCloseTo(originalScroll, 0)
await expect(search).toBeVisible()
await expect(search).toHaveValue('Needle')
await expect(orcaPage.getByRole('button', { name: 'Match case', exact: true })).toHaveAttribute(
'aria-pressed',
'true'
)
await expect(
orcaPage.getByRole('button', { name: 'Match whole word', exact: true })
).toHaveAttribute('aria-pressed', 'true')
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
})
test(`keeps the scrolled table and ${interaction} selection after switching Source to Rich`, async ({
orcaPage,
registerPostElectronShutdownCleanup
}, testInfo) => {
const context = await getActiveWorktreeContext(orcaPage)
const filePath = await createMarkdownFixture(
context,
'.orca-e2e-markdown-find-editing',
`source-${interaction}`,
testInfo.workerIndex,
TABLE_MARKDOWN
)
registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
await openMarkdownFixture(orcaPage, context, filePath)
await waitForRichMarkdownEditor(orcaPage)
await orcaPage.getByRole('radio', { name: 'Source', exact: true }).click()
await expect(orcaPage.locator('.monaco-editor')).toBeVisible()
await orcaPage.getByRole('radio', { name: 'Rich Editor', exact: true }).click()
const editor = await waitForRichMarkdownEditor(orcaPage)
await expect(editor).not.toBeFocused()
const paragraph = editor.locator('td p').filter({ hasText: 'Editing' })
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
await centerParagraph(paragraph)
const originalScroll = await viewport.evaluate((element) => element.scrollTop)
const selection = await pointAtParagraph(orcaPage, paragraph, interaction === 'drag')
await expect(editor).toBeFocused()
await expect(paragraph).toHaveText(TARGET_TEXT)
await expect
.poll(() => viewport.evaluate((element) => element.scrollTop))
.toBeCloseTo(originalScroll, 0)
const tab = orcaPage
.locator('[data-tab-id]')
.filter({ hasText: path.basename(filePath) })
.last()
await expect(tab.locator('span.rounded-full')).toHaveCount(0)
await expect(tab.getByRole('button', { name: 'Close tab' })).toBeVisible()
await orcaPage.screenshot({ path: testInfo.outputPath('source-rich-table-selection.png') })
await orcaPage.keyboard.type('xy', { delay: 100 })
await orcaPage.screenshot({ path: testInfo.outputPath('source-rich-pointer-edit.png') })
await expect(paragraph).toHaveText(
`${TARGET_TEXT.slice(0, selection.from)}xy${TARGET_TEXT.slice(selection.to)}`
)
await expect
.poll(() => readParagraphSelection(paragraph))
.toEqual({
from: selection.from + 2,
to: selection.from + 2,
text: ''
})
await expect
.poll(() => viewport.evaluate((element) => element.scrollTop))
.toBeCloseTo(originalScroll, 0)
})
}
for (const returnPath of ['bare', 'removed-replace', 'native', 'escape'] as const) {
test(`preserves the live viewport on ${returnPath} return from Find`, async ({
orcaPage,
registerPostElectronShutdownCleanup
}, testInfo) => {
const context = await getActiveWorktreeContext(orcaPage)
const filePath = await createMarkdownFixture(
context,
'.orca-e2e-markdown-find-editing',
`viewport-${returnPath}`,
testInfo.workerIndex,
MARKDOWN
)
registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
await openMarkdownFixture(orcaPage, context, filePath)
const editor = await waitForRichMarkdownEditor(orcaPage)
const originalText = await editor.textContent()
await editor.locator('p').first().click()
await pressShortcut(orcaPage, 'f')
const search = orcaPage.getByRole('textbox', { name: 'Find in rich markdown editor' })
await search.fill('Needle')
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
if (returnPath === 'removed-replace' || returnPath === 'native') {
await orcaPage.getByRole('button', { name: 'Toggle replace', exact: true }).click()
await orcaPage.getByRole('textbox', { name: 'Replace in rich markdown editor' }).focus()
}
await centerParagraph(editor.locator('p').filter({ hasText: 'Editing' }))
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
const before = await viewport.evaluate((element) => element.scrollTop)
expect(before).toBeGreaterThan(1000)
if (returnPath === 'removed-replace' || returnPath === 'native') {
await orcaPage.getByRole('button', { name: 'Toggle replace', exact: true }).click()
await expect(
orcaPage.getByRole('textbox', { name: 'Replace in rich markdown editor' })
).toHaveCount(0)
}
if (returnPath === 'native') {
await orcaPage.keyboard.press('Shift+Tab')
await expect(
orcaPage.getByRole('button', { name: 'Toggle replace', exact: true })
).toBeFocused()
await orcaPage.keyboard.press('Shift+Tab')
} else if (returnPath === 'escape') {
await search.press('Escape')
} else {
await editor.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error('Missing rich editor')
}
element.focus()
})
}
await expect(editor).toBeFocused()
// Allow the engine's focus recovery to settle, rather than checking only the focus listener.
await expect
.poll(() =>
viewport.evaluate(async (element) => {
// ast-grep-ignore: test-no-real-time-wait -- proves the viewport stays put through the engine's 200 ms post-focus selection recovery window, which has no completion signal
await new Promise((resolve) => setTimeout(resolve, 250))
return element.scrollTop
})
)
.toBeCloseTo(before, 0)
await expect(editor).toHaveText(originalText ?? '')
if (returnPath === 'bare') {
await search.focus()
await orcaPage.getByRole('button', { name: 'Next match', exact: true }).click()
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('2/2')
const zero = await editor.evaluate((element) => {
if (
!(element instanceof HTMLElement) ||
!(element.parentElement?.parentElement instanceof HTMLElement)
) {
throw new Error('Missing rich viewport')
}
const viewport = element.closest('.overflow-auto')
if (!(viewport instanceof HTMLElement)) {
throw new Error('Missing rich viewport')
}
viewport.scrollTo({ top: 0, behavior: 'instant' })
element.focus()
return viewport.scrollTop
})
expect(zero).toBe(0)
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(0)
}
})
}