diff --git a/tests/e2e/helpers/markdown-ordered-list-exit.ts b/tests/e2e/helpers/markdown-ordered-list-exit.ts new file mode 100644 index 000000000..ff4e7d29b --- /dev/null +++ b/tests/e2e/helpers/markdown-ordered-list-exit.ts @@ -0,0 +1,282 @@ +import { randomUUID } from 'crypto' +import { mkdir, rm, writeFile } from 'fs/promises' +import path from 'path' +import type { Locator, Page } from '@stablyai/playwright-test' +import { expect } from '@stablyai/playwright-test' + +const MARKDOWN_HYDRATION_TIMEOUT_MS = 25_000 +const DRAFT_SERIALIZATION_TIMEOUT_MS = 10_000 + +export type ActiveWorktreeContext = { + worktreeId: string + rootPath: string +} + +export type MatrixRow = { + name: string + slug: string + sentinel: string + initialMarkdown: string + run: (page: Page, sentinel: string) => Promise +} + +type ActiveEditorFile = { + filePath: string +} + +export async function getActiveWorktreeContext(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const state = store.getState() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + throw new Error('No active worktree is selected') + } + + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((entry) => entry.id === worktreeId) + if (!worktree) { + throw new Error(`Active worktree was not found in store: ${worktreeId}`) + } + + return { worktreeId, rootPath: worktree.path } + }) +} + +export async function createMarkdownFixture( + context: ActiveWorktreeContext, + slug: string, + workerIndex: number, + initialMarkdown: string +): Promise { + const directory = path.join(context.rootPath, '.orca-e2e-markdown-ordered-list') + await mkdir(directory, { recursive: true }) + + const filePath = path.join(directory, `${slug}-${workerIndex}-${Date.now()}-${randomUUID()}.md`) + await writeFile(filePath, initialMarkdown, 'utf8') + + return filePath +} + +export async function cleanupMarkdownFixture(filePath: string | null): Promise { + if (!filePath) { + return + } + + try { + await rm(filePath, { force: true }) + } catch { + // Best-effort cleanup must not hide the editor regression assertion. + } +} + +export async function openMarkdownFixture( + page: Page, + context: ActiveWorktreeContext, + filePath: string +): Promise { + const relativePath = path.relative(context.rootPath, filePath) + + await page.evaluate( + ({ filePath, relativePath, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + store.getState().openFile({ + filePath, + relativePath, + worktreeId, + language: 'markdown', + mode: 'edit' + }) + }, + { filePath, relativePath, worktreeId: context.worktreeId } + ) + + let activeFile: ActiveEditorFile | null = null + await expect + .poll( + async () => { + activeFile = await page.evaluate(() => { + const store = window.__store + if (!store) { + return null + } + + const state = store.getState() + const file = state.openFiles.find((entry) => entry.id === state.activeFileId) + return file ? { filePath: file.filePath } : null + }) + return activeFile?.filePath ?? null + }, + { + timeout: 5_000, + message: `Active editor file did not become ${filePath}` + } + ) + .toBe(filePath) + + if (!activeFile) { + throw new Error(`Active editor file was not available after opening ${filePath}`) + } + + return activeFile +} + +export async function waitForRichMarkdownEditor(page: Page): Promise { + const editor = page.locator('.rich-markdown-editor') + await expect(editor).toBeVisible({ timeout: MARKDOWN_HYDRATION_TIMEOUT_MS }) + return editor +} + +export async function expectSentinelParagraphOutsideOrderedList( + page: Page, + sentinel: string +): Promise { + await expect + .poll( + async () => + page.evaluate((sentinel) => { + const editor = document.querySelector('.rich-markdown-editor') + if (!editor) { + return false + } + + return Array.from(editor.querySelectorAll('p')).some((paragraph) => { + return ( + paragraph.textContent?.trim() === sentinel && + !paragraph.closest('ol') && + !paragraph.closest('li') + ) + }) + }, sentinel), + { + timeout: 5_000, + message: `${sentinel} did not render in a paragraph outside an ordered list` + } + ) + .toBe(true) +} + +export async function expectSerializedDraftOutsideOrderedList( + page: Page, + draftKey: string, + sentinel: string +): Promise { + await expect + .poll( + async () => + page.evaluate( + ({ draftKey, sentinel }) => { + const draft = window.__store?.getState().editorDrafts[draftKey] + if (typeof draft !== 'string') { + return false + } + + const sentinelLines = draft + .split(/\r\n|\r|\n/) + .filter((line) => line.includes(sentinel)) + return { + hasPlainLine: sentinelLines.some((line) => line.trim() === sentinel), + appearsOnNumberedLine: sentinelLines.some((line) => /^\s*\d+\.\s+/.test(line)) + } + }, + { draftKey, sentinel } + ), + { + timeout: DRAFT_SERIALIZATION_TIMEOUT_MS, + message: `${sentinel} did not serialize as a plain paragraph in editorDrafts[${draftKey}]` + } + ) + .toEqual({ hasPlainLine: true, appearsOnNumberedLine: false }) +} + +export async function assertLoadedThirdEmptyOrderedListItem(page: Page): Promise { + await expect + .poll( + async () => + page.evaluate(() => { + const editor = document.querySelector('.rich-markdown-editor') + const listItems = Array.from(editor?.querySelectorAll('ol > li') ?? []) + const thirdItem = listItems[2] + const paragraph = thirdItem?.querySelector('p') ?? null + const rect = paragraph?.getBoundingClientRect() + return Boolean( + thirdItem && + paragraph && + thirdItem.textContent?.trim() === '' && + rect && + rect.width > 0 && + rect.height > 0 + ) + }), + { + timeout: 5_000, + message: 'Loaded markdown did not expose an editable empty third ordered-list item' + } + ) + .toBe(true) +} + +async function selectionIsInsideThirdEmptyOrderedListItem(page: Page): Promise { + return page.evaluate(() => { + const editor = document.querySelector('.rich-markdown-editor') + const thirdItem = editor?.querySelectorAll('ol > li')[2] + const selection = window.getSelection() + const anchorNode = selection?.anchorNode ?? null + if (!thirdItem || !anchorNode || !selection?.isCollapsed) { + return false + } + + const prosemirrorNodeName = ( + thirdItem as Element & { pmViewDesc?: { node?: { type?: { name?: string } } } } + ).pmViewDesc?.node?.type?.name + const anchorElement = + anchorNode.nodeType === Node.ELEMENT_NODE ? anchorNode : anchorNode.parentElement + return ( + prosemirrorNodeName === 'listItem' && + Boolean(anchorElement && thirdItem.contains(anchorElement)) + ) + }) +} + +export async function placeCaretInLoadedThirdEmptyItem(page: Page): Promise { + const thirdItemParagraph = page.locator('.rich-markdown-editor ol > li').nth(2).locator('p') + await thirdItemParagraph.click() + + if (!(await selectionIsInsideThirdEmptyOrderedListItem(page))) { + await page.evaluate(() => { + const editor = document.querySelector('.rich-markdown-editor') + const thirdItem = editor?.querySelectorAll('ol > li')[2] + const paragraph = thirdItem?.querySelector('p') + if (!editor || !paragraph) { + throw new Error('Cannot place caret in the loaded empty ordered-list item') + } + + // Why: headless Electron can click an empty paragraph without producing a + // stable caret; force the same collapsed DOM selection before pressing Enter. + const range = document.createRange() + range.setStart(paragraph, 0) + range.collapse(true) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + editor.focus() + document.dispatchEvent(new Event('selectionchange')) + }) + } + + await expect + .poll(async () => selectionIsInsideThirdEmptyOrderedListItem(page), { + timeout: 3_000, + message: 'Selection was not inside the loaded empty third ordered-list item' + }) + .toBe(true) +} diff --git a/tests/e2e/markdown-ordered-list-exit.spec.ts b/tests/e2e/markdown-ordered-list-exit.spec.ts new file mode 100644 index 000000000..db249e272 --- /dev/null +++ b/tests/e2e/markdown-ordered-list-exit.spec.ts @@ -0,0 +1,92 @@ +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + assertLoadedThirdEmptyOrderedListItem, + cleanupMarkdownFixture, + createMarkdownFixture, + expectSentinelParagraphOutsideOrderedList, + expectSerializedDraftOutsideOrderedList, + getActiveWorktreeContext, + openMarkdownFixture, + placeCaretInLoadedThirdEmptyItem, + type MatrixRow, + waitForRichMarkdownEditor +} from './helpers/markdown-ordered-list-exit' + +const rows: MatrixRow[] = [ + { + name: 'typed ordered-list marker exits to a paragraph', + slug: 'typed-marker', + sentinel: 'afterTypedMarkerExit', + initialMarkdown: '', + run: async (page, sentinel) => { + const editor = await waitForRichMarkdownEditor(page) + await editor.click() + await page.keyboard.type('1. first') + await page.keyboard.press('Enter') + await page.keyboard.press('Enter') + await page.keyboard.type(sentinel) + } + }, + { + name: 'toolbar-created ordered list exits to a paragraph', + slug: 'toolbar-list', + sentinel: 'afterToolbarListExit', + initialMarkdown: '', + run: async (page, sentinel) => { + const editor = await waitForRichMarkdownEditor(page) + await editor.click() + await page.getByRole('button', { name: 'Numbered list' }).click() + await expect(editor.locator('ol')).toHaveCount(1, { timeout: 5_000 }) + await page.keyboard.type('first') + await page.keyboard.press('Enter') + await page.keyboard.press('Enter') + await page.keyboard.type(sentinel) + } + }, + { + name: 'loaded existing-note ordered-list continuation exits to a paragraph', + slug: 'loaded-continuation', + sentinel: 'afterLoadedContinuationExit', + initialMarkdown: '1. Item 1\n2. Item 2\n3. \n\n## Next section\n', + run: async (page, sentinel) => { + await waitForRichMarkdownEditor(page) + await assertLoadedThirdEmptyOrderedListItem(page) + await placeCaretInLoadedThirdEmptyItem(page) + await page.keyboard.press('Enter') + await page.keyboard.type(sentinel) + } + } +] + +test.describe('Markdown ordered-list exit regression', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + for (const row of rows) { + test(row.name, async ({ orcaPage }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + let filePath: string | null = null + + try { + filePath = await createMarkdownFixture( + context, + row.slug, + testInfo.workerIndex, + row.initialMarkdown + ) + const activeFile = await openMarkdownFixture(orcaPage, context, filePath) + const draftKey = activeFile.filePath + + await row.run(orcaPage, row.sentinel) + + await expectSentinelParagraphOutsideOrderedList(orcaPage, row.sentinel) + await expectSerializedDraftOutsideOrderedList(orcaPage, draftKey, row.sentinel) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) + } +})