Files
orca/tests/e2e/rich-markdown-inline-image.spec.ts
fb9ba4b681 fix(editor): make markdown images inline so a paragraph stays schema-valid (#19746)
* fix(editor): make markdown images inline so a paragraph stays schema-valid

Image was registered as a block node while paragraph is content:'inline*',
but the markdown pipeline nests an inline image as a paragraph child.
Schema.nodeFromJSON does not validate content, so the editor built a
schema-invalid document that rendered fine and threw on the first step
that reassembled the paragraph - i.e. on the user's next keystroke.

Report 0e46c048 (1.4.198, macOS): RangeError "Invalid content for node
paragraph" from checkContent via Node.replace, tearing down the
editor.rich-markdown boundary.

Register Image as inline and override paragraph's parseMarkdown so a lone
image is not hoisted out of its paragraph. Also fixes the same crash class
reachable through details/summary. Markdown output is byte-identical.

* fix(editor): keep a fenced code block intact when an image is inserted into it

Making the image node inline meant it could no longer be fitted into
codeBlock (content:'text*', marks:''), so inserting one with the cursor
inside a fence made ProseMirror close the block at the insertion point:
the remaining code escaped as plain prose and the language attribute was
lost, and autosave wrote that markdown to the user's file. The pre-fix
block image split the fence into two intact blocks instead.

Resolve the insert content against the target position: when an inline
image cannot be fitted where the caret sits, wrap it in a paragraph so
ProseMirror splits the block and both halves keep their ``` fencing and
language. Prose insertion is unchanged. Every production insert path now
shares that resolution - the toolbar picker, the slash command and the
clipboard-screenshot paste through insertRichMarkdownImageFromPath, plus
the GitHub/GitLab composer's image-URL insert - each with a regression
test.

Also guard the unchecked cast of Paragraph.config.parseMarkdown: a Tiptap
upgrade that drops the field would otherwise turn every paragraph parse
into a TypeError and take the whole editor down, instead of degrading to
parseInline.

Four of the new round-trip cases asserted only on getMarkdown(), which
walks the document without running NodeType.checkContent and so emits
byte-identical output from a schema-invalid document - they passed on the
pre-fix code. roundTripMarkdown now runs doc.check(), the list-item and
table-cell case performs a real edit, and the standalone-image case types
beside the image. All twelve cases now fail on the merge-base.

Adds an Electron e2e spec driving the real renderer: a paragraph image and
a toggle-summary image each survive a keystroke, and Bold over a selection
spanning the image keeps it.

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <neil@stably.ai>
2026-09-10 17:42:50 -07:00

136 lines
5.1 KiB
TypeScript

import { test, expect } from './helpers/orca-app'
import {
cleanupMarkdownFixture,
createMarkdownFixture,
getActiveWorktreeContext,
openMarkdownFixture,
waitForRichMarkdownEditor
} from './helpers/markdown-editor-fixture'
import {
collectRichMarkdownPageErrors,
expectNoRichMarkdownSchemaCrash,
INLINE_IMAGE_DETAILS_MARKDOWN,
INLINE_IMAGE_FIXTURE_DIRECTORY,
INLINE_IMAGE_PARAGRAPH_MARKDOWN,
writeInlineImageAsset
} from './helpers/markdown-inline-image'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
// A markdown image nested in a paragraph or a toggle summary used to parse into a
// schema-invalid document that only threw on the first edit reassembling it.
test.describe('Rich markdown inline image regression', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
})
test('an inline image inside a paragraph survives a keystroke', async ({
orcaPage
}, testInfo) => {
const context = await getActiveWorktreeContext(orcaPage)
const pageErrors = collectRichMarkdownPageErrors(orcaPage)
let filePath: string | null = null
try {
writeInlineImageAsset(context.rootPath)
filePath = await createMarkdownFixture(
context,
INLINE_IMAGE_FIXTURE_DIRECTORY,
'paragraph-inline-image',
testInfo.workerIndex,
INLINE_IMAGE_PARAGRAPH_MARKDOWN
)
await openMarkdownFixture(orcaPage, context, filePath)
const editor = await waitForRichMarkdownEditor(orcaPage)
const paragraph = editor.locator('p').filter({ hasText: 'more text' }).first()
await expect(paragraph).toBeVisible({ timeout: 15_000 })
await expect(editor.locator('img')).toHaveCount(1, { timeout: 15_000 })
// Typing at the paragraph start reassembles the whole paragraph, which is
// the frame the reported RangeError bottomed out in.
await paragraph.click({ position: { x: 12, y: 8 } })
await orcaPage.keyboard.press('Home')
await orcaPage.keyboard.type('X')
await expect(editor.locator('p').filter({ hasText: 'XSome text' })).toHaveCount(1)
await expect(editor.locator('img')).toHaveCount(1)
await expectNoRichMarkdownSchemaCrash(orcaPage, pageErrors)
} finally {
await cleanupMarkdownFixture(filePath)
}
})
test('an inline image inside a toggle summary survives a keystroke', async ({
orcaPage
}, testInfo) => {
const context = await getActiveWorktreeContext(orcaPage)
const pageErrors = collectRichMarkdownPageErrors(orcaPage)
let filePath: string | null = null
try {
writeInlineImageAsset(context.rootPath)
filePath = await createMarkdownFixture(
context,
INLINE_IMAGE_FIXTURE_DIRECTORY,
'details-inline-image',
testInfo.workerIndex,
INLINE_IMAGE_DETAILS_MARKDOWN
)
await openMarkdownFixture(orcaPage, context, filePath)
const editor = await waitForRichMarkdownEditor(orcaPage)
const summary = editor.locator('summary').first()
await expect(summary).toBeVisible({ timeout: 15_000 })
await expect(editor.locator('summary img')).toHaveCount(1, { timeout: 15_000 })
await summary.click()
await orcaPage.keyboard.press('End')
await orcaPage.keyboard.type('X')
await expect(editor.locator('summary').filter({ hasText: 'labelX' })).toHaveCount(1)
await expect(editor.locator('summary img')).toHaveCount(1)
await expectNoRichMarkdownSchemaCrash(orcaPage, pageErrors)
} finally {
await cleanupMarkdownFixture(filePath)
}
})
test('a formatting command over an inline image keeps the image', async ({
orcaPage
}, testInfo) => {
const context = await getActiveWorktreeContext(orcaPage)
const pageErrors = collectRichMarkdownPageErrors(orcaPage)
let filePath: string | null = null
try {
writeInlineImageAsset(context.rootPath)
filePath = await createMarkdownFixture(
context,
INLINE_IMAGE_FIXTURE_DIRECTORY,
'paragraph-inline-image-bold',
testInfo.workerIndex,
INLINE_IMAGE_PARAGRAPH_MARKDOWN
)
await openMarkdownFixture(orcaPage, context, filePath)
const editor = await waitForRichMarkdownEditor(orcaPage)
const paragraph = editor.locator('p').filter({ hasText: 'more text' }).first()
await expect(paragraph).toBeVisible({ timeout: 15_000 })
await expect(editor.locator('img')).toHaveCount(1, { timeout: 15_000 })
// toggleBold runs tr.addMark across the selection, which reassembles every
// paragraph it spans — and silently dropped the image before the fix.
await paragraph.click({ position: { x: 12, y: 8 } })
await orcaPage.keyboard.press('ControlOrMeta+a')
await orcaPage.getByRole('button', { name: 'Bold', exact: true }).first().click()
await expect(editor.locator('strong').first()).toBeVisible()
await expect(editor.locator('img')).toHaveCount(1)
await expectNoRichMarkdownSchemaCrash(orcaPage, pageErrors)
} finally {
await cleanupMarkdownFixture(filePath)
}
})
})