fix: stabilize release e2e failures

This commit is contained in:
Neil
2026-05-26 13:25:54 -07:00
committed by GitHub
parent 66a946d663
commit c64777d1bc
8 changed files with 292 additions and 34 deletions
@@ -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<KeyboardEvent> = {}
@@ -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())
@@ -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,
@@ -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',
@@ -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) {
+9 -2
View File
@@ -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
]
}
+72 -24
View File
@@ -228,21 +228,59 @@ export async function assertLoadedThirdEmptyOrderedListItem(page: Page): Promise
async function selectionIsInsideThirdEmptyOrderedListItem(page: Page): Promise<boolean> {
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<void
if (!(await selectionIsInsideThirdEmptyOrderedListItem(page))) {
await page.evaluate(() => {
const editor = document.querySelector<HTMLElement>('.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?.()
})
}
+18 -2
View File
@@ -118,6 +118,14 @@ function onboardingNotificationSoundSelect(page: Page) {
return page.getByRole('combobox').first()
}
async function expectOnboardingNotificationSoundMenuClosed(page: Page): Promise<void> {
await expect(page.getByRole('option', { name: /Choose Custom File/i })).toHaveCount(0)
}
async function expectOnboardingSkipConfirmationClosed(page: Page): Promise<void> {
await expect(page.getByRole('dialog', { name: /Skip onboarding\?/i })).toHaveCount(0)
}
async function expectOnboardingNotificationSound(page: Page, name: RegExp): Promise<void> {
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<void> {
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<void> {
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<void> {
+4 -5
View File
@@ -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',