mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
fix: stabilize release e2e failures
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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?.()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user