diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts index f44044279ac..dc2c275aa20 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts @@ -14,6 +14,24 @@ function createEditor(content: object): Editor { }) } +function firstEmptyParagraphPosition(editor: Editor): number { + let position: number | null = null + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && node.content.size === 0) { + position = pos + 1 + return false + } + + return true + }) + + if (position === null) { + throw new Error('Expected an empty paragraph in the test document') + } + + return position +} + function keyEvent( key: string, overrides: Partial = {} @@ -104,6 +122,52 @@ describe('rich markdown key handler', () => { } }) + it('exits loaded trailing empty ordered-list items on Enter', () => { + const editor = createEditor({ + type: 'doc', + content: [ + { + type: 'orderedList', + attrs: { start: 1, type: null }, + content: [ + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 1' }] }] + }, + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 2' }] }] + }, + { type: 'listItem', content: [{ type: 'paragraph' }] } + ] + }, + { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Next section' }] } + ] + }) + + try { + editor.commands.setTextSelection(firstEmptyParagraphPosition(editor)) + const event = keyEvent('Enter') + + expect(createRichMarkdownKeyHandler(createContext(editor, false))(null, event)).toBe(true) + expect(event.preventDefault).toHaveBeenCalled() + expect(editor.state.selection.$from.parent.type.name).toBe('paragraph') + expect(editor.state.selection.$from.depth).toBe(1) + expect(editor.state.doc.toJSON()).toMatchObject({ + content: [ + { + type: 'orderedList', + content: [{ type: 'listItem' }, { type: 'listItem' }] + }, + { type: 'paragraph' }, + { type: 'heading' } + ] + }) + } finally { + editor.destroy() + } + }) + it('does not rewrite empty ordered-list input during IME composition', () => { const editor = createEditor(emptyTopLevelOrderedList()) diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index aac18c5c1c2..a4d12f224f2 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -16,7 +16,8 @@ import { import { collapseEmptyListContinuationParagraph, commitEmptyOrderedListMarkerAsText, - convertEmptyNestedOrderedItemToContinuation + convertEmptyNestedOrderedItemToContinuation, + exitTrailingEmptyOrderedListItem } from './rich-markdown-list-continuation' export type KeyHandlerContext = { @@ -141,6 +142,10 @@ export function createRichMarkdownKeyHandler( event.preventDefault() return true } + if (ed && !isComposingMarkdownInput(event, ed) && exitTrailingEmptyOrderedListItem(ed)) { + event.preventDefault() + return true + } } // Tab/Shift-Tab: indent/outdent lists, insert spaces in code blocks, diff --git a/src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts b/src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts index 0a51b596b95..a96fb6718bd 100644 --- a/src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts @@ -6,6 +6,7 @@ import { collapseEmptyListContinuationParagraph, commitEmptyOrderedListMarkerAsText, convertEmptyNestedOrderedItemToContinuation, + exitTrailingEmptyOrderedListItem, isSingleEmptyTopLevelOrderedList } from './rich-markdown-list-continuation' @@ -19,6 +20,24 @@ function createEditor(content: object): Editor { }) } +function firstEmptyParagraphPosition(editor: Editor): number { + let position: number | null = null + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && node.content.size === 0) { + position = pos + 1 + return false + } + + return true + }) + + if (position === null) { + throw new Error('Expected an empty paragraph in the test document') + } + + return position +} + describe('rich markdown list continuation', () => { it('preserves a typed empty ordered-list marker when Enter is pressed', () => { const editor = createEditor({ @@ -144,6 +163,65 @@ describe('rich markdown list continuation', () => { } }) + it('exits a loaded trailing empty ordered-list item into a body paragraph', () => { + const editor = createEditor({ + type: 'doc', + content: [ + { + type: 'orderedList', + attrs: { start: 1, type: null }, + content: [ + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 1' }] }] + }, + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 2' }] }] + }, + { type: 'listItem', content: [{ type: 'paragraph' }] } + ] + }, + { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Next section' }] } + ] + }) + + try { + editor.commands.setTextSelection(firstEmptyParagraphPosition(editor)) + + expect(exitTrailingEmptyOrderedListItem(editor)).toBe(true) + expect(editor.state.selection.$from.parent.type.name).toBe('paragraph') + expect(editor.state.selection.$from.depth).toBe(1) + expect(editor.state.doc.toJSON()).toEqual({ + type: 'doc', + content: [ + { + type: 'orderedList', + attrs: { start: 1, type: null }, + content: [ + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 1' }] }] + }, + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 2' }] }] + } + ] + }, + { type: 'paragraph' }, + { + type: 'heading', + attrs: { level: 2 }, + content: [{ type: 'text', text: 'Next section' }] + } + ] + }) + } finally { + editor.destroy() + } + }) + it('collapses an empty continuation paragraph back to the parent list item text', () => { const editor = createEditor({ type: 'doc', diff --git a/src/renderer/src/components/editor/rich-markdown-list-continuation.ts b/src/renderer/src/components/editor/rich-markdown-list-continuation.ts index b8a70e8cc1e..f34e500f01a 100644 --- a/src/renderer/src/components/editor/rich-markdown-list-continuation.ts +++ b/src/renderer/src/components/editor/rich-markdown-list-continuation.ts @@ -96,6 +96,47 @@ export function isSingleEmptyTopLevelOrderedList(editor: Editor): boolean { ) } +export function exitTrailingEmptyOrderedListItem(editor: Editor): boolean { + const context = getEmptyListItemContext(editor) + if (!context) { + return false + } + + const { state, view } = editor + const { schema } = state + const { $from } = state.selection + const list = $from.node(context.listDepth) + const listItem = $from.node(context.listItemDepth) + const parentDepth = context.listDepth - 1 + const childIndex = $from.index(context.listDepth) + + if ( + list.type.name !== 'orderedList' || + list.childCount <= 1 || + childIndex !== list.childCount - 1 || + listItem.childCount !== 1 || + (parentDepth >= 0 && $from.node(parentDepth).type.name === 'listItem') + ) { + return false + } + + const paragraphType = schema.nodes.paragraph + if (!paragraphType) { + return false + } + + const remainingList = list.copy(list.content.cut(0, list.content.size - listItem.nodeSize)) + const continuationParagraph = paragraphType.create() + const from = $from.before(context.listDepth) + const to = $from.after(context.listDepth) + const tr = state.tr.replaceWith(from, to, [remainingList, continuationParagraph]) + // Why: loaded markdown can contain a trailing empty numbered item. Enter on + // that caret target should continue as body text, not keep extending the list. + tr.setSelection(TextSelection.create(tr.doc, from + remainingList.nodeSize + 1)) + view.dispatch(tr.scrollIntoView()) + return true +} + export function collapseEmptyListContinuationParagraph(editor: Editor): boolean { const context = getEmptyListItemContext(editor) if (!context) { diff --git a/tests/e2e/helpers/electron-launch-args.ts b/tests/e2e/helpers/electron-launch-args.ts index e732258ec1f..f2c07086543 100644 --- a/tests/e2e/helpers/electron-launch-args.ts +++ b/tests/e2e/helpers/electron-launch-args.ts @@ -4,6 +4,13 @@ export function getOrcaElectronLaunchArgs(mainPath: string, headful: boolean): s } // Why: Ubuntu CI can fail headless Electron when Chromium's GPU subprocess - // cannot initialize; these switches keep rendering on a software-safe path. - return ['--disable-gpu', '--disable-dev-shm-usage', mainPath] + // cannot initialize; keep E2E on a low-process software path under Xvfb. + return [ + '--disable-gpu', + '--disable-gpu-compositing', + '--disable-gpu-sandbox', + '--disable-dev-shm-usage', + '--in-process-gpu', + mainPath + ] } diff --git a/tests/e2e/helpers/markdown-ordered-list-exit.ts b/tests/e2e/helpers/markdown-ordered-list-exit.ts index ff4e7d29b90..07b76e060e0 100644 --- a/tests/e2e/helpers/markdown-ordered-list-exit.ts +++ b/tests/e2e/helpers/markdown-ordered-list-exit.ts @@ -228,21 +228,59 @@ export async function assertLoadedThirdEmptyOrderedListItem(page: Page): Promise 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) { + const thirdItem = editor?.querySelectorAll('ol > li')[2] as + | (Element & { + pmViewDesc?: { + node?: { type?: { name?: string } } + } + }) + | undefined + const paragraph = thirdItem?.querySelector('p') as + | (Element & { + pmViewDesc?: { + posAtStart?: number + posAtEnd?: number + } + }) + | null + const tiptapEditor = ( + editor as + | (Element & { + editor?: { + state?: { + selection?: { + empty?: boolean + from?: number + to?: number + } + } + } + }) + | null + )?.editor + const selection = tiptapEditor?.state?.selection + const selectionFrom = selection?.from + const selectionTo = selection?.to + const paragraphStart = paragraph?.pmViewDesc?.posAtStart + const paragraphEnd = paragraph?.pmViewDesc?.posAtEnd + + if ( + !thirdItem || + !paragraph || + !selection?.empty || + typeof selectionFrom !== 'number' || + typeof selectionTo !== 'number' || + typeof paragraphStart !== 'number' || + typeof paragraphEnd !== 'number' + ) { 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)) + thirdItem.pmViewDesc?.node?.type?.name === 'listItem' && + thirdItem.textContent?.trim() === '' && + selectionFrom >= paragraphStart && + selectionTo <= paragraphEnd ) }) } @@ -253,23 +291,33 @@ export async function placeCaretInLoadedThirdEmptyItem(page: Page): Promise { - const editor = document.querySelector('.rich-markdown-editor') + const editor = document.querySelector('.rich-markdown-editor') as + | (Element & { + editor?: { + commands?: { + focus?: () => boolean + setTextSelection?: (position: number) => boolean + } + } + }) + | null const thirdItem = editor?.querySelectorAll('ol > li')[2] - const paragraph = thirdItem?.querySelector('p') - if (!editor || !paragraph) { + const paragraph = thirdItem?.querySelector('p') as + | (Element & { + pmViewDesc?: { + posAtStart?: number + } + }) + | null + const selectionPosition = paragraph?.pmViewDesc?.posAtStart + if (!editor?.editor?.commands || typeof selectionPosition !== 'number') { 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')) + // Why: headless Electron can click an empty paragraph without committing + // ProseMirror's state selection before Enter; set the same caret explicitly. + editor.editor.commands.setTextSelection?.(selectionPosition) + editor.editor.commands.focus?.() }) } diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index ec45c1c29dd..865d7b351ed 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -118,6 +118,14 @@ function onboardingNotificationSoundSelect(page: Page) { return page.getByRole('combobox').first() } +async function expectOnboardingNotificationSoundMenuClosed(page: Page): Promise { + await expect(page.getByRole('option', { name: /Choose Custom File/i })).toHaveCount(0) +} + +async function expectOnboardingSkipConfirmationClosed(page: Page): Promise { + await expect(page.getByRole('dialog', { name: /Skip onboarding\?/i })).toHaveCount(0) +} + async function expectOnboardingNotificationSound(page: Page, name: RegExp): Promise { await expect(onboardingNotificationSoundSelect(page)).toContainText(name) } @@ -125,15 +133,23 @@ async function expectOnboardingNotificationSound(page: Page, name: RegExp): Prom async function chooseOnboardingNotificationSound(page: Page, name: RegExp): Promise { const soundSelect = onboardingNotificationSoundSelect(page) await soundSelect.click() - await page.getByRole('option', { name }).click() + const option = page.getByRole('option', { name }) + await expect(option).toBeVisible() + // Why: the select menu extends over the onboarding footer on small CI + // viewports; keyboard selection avoids pointer fall-through to Skip. + await option.press('Enter') await expect(soundSelect).toContainText(name) + await expectOnboardingNotificationSoundMenuClosed(page) + await expectOnboardingSkipConfirmationClosed(page) } async function expectOnboardingCustomSoundOption(page: Page): Promise { const soundSelect = onboardingNotificationSoundSelect(page) await soundSelect.click() await expect(page.getByRole('option', { name: /Choose Custom File/i })).toBeVisible() - await page.keyboard.press('Escape') + await page.getByRole('option', { selected: true }).press('Enter') + await expectOnboardingNotificationSoundMenuClosed(page) + await expectOnboardingSkipConfirmationClosed(page) } async function continueOnboarding(page: Page): Promise { diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts index e5c4dfb3e52..23daa5ac4d8 100644 --- a/tests/playwright.config.ts +++ b/tests/playwright.config.ts @@ -26,11 +26,10 @@ export default defineConfig({ // substantially. The few visible-window tests that still rely on real // pointer interaction are marked serial in their spec file instead. fullyParallel: true, - // Why: Playwright defaults to workers=1 on CI, which would serialize all - // specs on the ubuntu-latest runner (4 vCPUs) and waste headroom. Each test - // launches an isolated Electron instance with its own userData dir, so they - // don't share state — we can safely fan out to match the runner's vCPU count. - workers: process.env.CI ? 4 : undefined, + // Why: each CI worker launches real Electron/Chromium process trees. Ubuntu + // runners have 4 vCPUs, but 4 parallel apps can exhaust Chromium GPU/zygote + // subprocess startup under Xvfb, so keep headless E2E below that ceiling. + workers: process.env.CI ? 2 : undefined, forbidOnly: !!process.env.CI, retries: 0, reporter: 'list',