From e1a7b0b02269bfc1a716f7ff5f3391f39befbe94 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Thu, 27 Aug 2026 13:50:39 -0700
Subject: [PATCH] Support nested toggles as editable blocks with recursion
guards
Previously nested details blocks were preserved as inert passthrough HTML.
Now, nested details that themselves meet editability criteria are opened as
editable toggle nodes. Recursive validation includes a 16-level nesting limit
to prevent stack exhaustion on pathological input. Refactors common markdown
editor test helpers into a reusable fixture module.
---
.../editor/details-markdown-html.test.ts | 59 ++++++-
.../editor/details-markdown-html.ts | 48 ++++--
.../editor/markdown-round-trip.test.ts | 69 +++++++-
tests/e2e/helpers/markdown-editor-fixture.ts | 139 +++++++++++++++
tests/e2e/helpers/markdown-nested-toggle.ts | 163 ++++++++++++++++++
.../e2e/helpers/markdown-ordered-list-exit.ts | 131 ++------------
tests/e2e/markdown-nested-toggle.spec.ts | 113 ++++++++++++
7 files changed, 585 insertions(+), 137 deletions(-)
create mode 100644 tests/e2e/helpers/markdown-editor-fixture.ts
create mode 100644 tests/e2e/helpers/markdown-nested-toggle.ts
create mode 100644 tests/e2e/markdown-nested-toggle.spec.ts
diff --git a/src/renderer/src/components/editor/details-markdown-html.test.ts b/src/renderer/src/components/editor/details-markdown-html.test.ts
index c4e1493a198..47e404318b8 100644
--- a/src/renderer/src/components/editor/details-markdown-html.test.ts
+++ b/src/renderer/src/components/editor/details-markdown-html.test.ts
@@ -2,11 +2,25 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
extractDetailsSummaryHtml,
isEditableDetailsHtmlBlock,
+ matchDetailsHtmlBlock,
parseDetailsAttributes,
parseToggleHeadingVariant,
type DetailsHtmlBlock
} from './details-markdown-html'
+function nestedToggles(depth: number): string {
+ let html = '\nleaf
\n\nBody\n\n '
+ for (let level = depth - 1; level > 0; level -= 1) {
+ html = `\nlevel ${level}
\n\n${html}\n\n `
+ }
+ return html
+}
+
+function isEditableHtml(html: string): boolean {
+ const block = matchDetailsHtmlBlock(html, 0)
+ return block !== null && isEditableDetailsHtmlBlock(block)
+}
+
afterEach(() => {
vi.restoreAllMocks()
})
@@ -34,8 +48,7 @@ describe('details markdown html', () => {
const block: DetailsHtmlBlock = {
raw: '',
openingAttributes: '',
- inner: `${'Heading line\n'.repeat(1_000)}
Body
`,
- hasNestedDetails: false
+ inner: `${'Heading line\n'.repeat(1_000)}Body
`
}
expect(isEditableDetailsHtmlBlock(block)).toBe(true)
@@ -61,17 +74,51 @@ describe('details markdown html', () => {
const editableHeading5: DetailsHtmlBlock = {
raw: '',
openingAttributes: ' data-orca-toggle="heading-5"',
- inner: 'ToggleBody
',
- hasNestedDetails: false
+ inner: 'ToggleBody
'
}
const unsupportedHeading6: DetailsHtmlBlock = {
raw: '',
openingAttributes: ' data-orca-toggle="heading-6"',
- inner: 'ToggleBody
',
- hasNestedDetails: false
+ inner: 'ToggleBody
'
}
expect(isEditableDetailsHtmlBlock(editableHeading5)).toBe(true)
expect(isEditableDetailsHtmlBlock(unsupportedHeading6)).toBe(false)
})
+
+ it('accepts nested toggles whose descendants are all editable', () => {
+ expect(isEditableHtml(nestedToggles(2))).toBe(true)
+ expect(isEditableHtml(nestedToggles(16))).toBe(true)
+ })
+
+ it('rejects a toggle whose nested toggle is not itself editable', () => {
+ const block: DetailsHtmlBlock = {
+ raw: '',
+ openingAttributes: ' class="orca-details"',
+ inner: 'OuterInner
Body
'
+ }
+
+ expect(isEditableDetailsHtmlBlock(block)).toBe(false)
+ })
+
+ it('rejects nesting past the recursion guard instead of recursing without bound', () => {
+ expect(isEditableHtml(nestedToggles(17))).toBe(false)
+ expect(isEditableHtml(nestedToggles(400))).toBe(false)
+ })
+
+ it('rejects unbalanced and non-toggle tags that start with details', () => {
+ const unbalanced: DetailsHtmlBlock = {
+ raw: '',
+ openingAttributes: '',
+ inner: 'OuterInner
'
+ }
+ const lookalike: DetailsHtmlBlock = {
+ raw: '',
+ openingAttributes: '',
+ inner: 'Outer
Body'
+ }
+
+ expect(isEditableDetailsHtmlBlock(unbalanced)).toBe(false)
+ expect(isEditableDetailsHtmlBlock(lookalike)).toBe(false)
+ })
})
diff --git a/src/renderer/src/components/editor/details-markdown-html.ts b/src/renderer/src/components/editor/details-markdown-html.ts
index a2f4238b658..248e2303637 100644
--- a/src/renderer/src/components/editor/details-markdown-html.ts
+++ b/src/renderer/src/components/editor/details-markdown-html.ts
@@ -34,7 +34,6 @@ export type DetailsHtmlBlock = {
raw: string
openingAttributes: string
inner: string
- hasNestedDetails: boolean
}
export type DetailsSummaryHtml = {
@@ -145,7 +144,6 @@ export function matchDetailsHtmlBlock(content: string, start: number): DetailsHt
const fenceRanges = markdownFenceRanges(content)
let depth = 0
- let hasNestedDetails = false
for (;;) {
const tagMatch = detailsTagPattern.exec(content)
@@ -167,14 +165,10 @@ export function matchDetailsHtmlBlock(content: string, start: number): DetailsHt
return {
raw: content.slice(start, closingEnd),
openingAttributes: openingMatch[0].replace(/^ $/u, ''),
- inner: content.slice(start + openingMatch[0].length, tagMatch.index),
- hasNestedDetails
+ inner: content.slice(start + openingMatch[0].length, tagMatch.index)
}
}
} else {
- if (depth > 0) {
- hasNestedDetails = true
- }
depth += 1
}
}
@@ -267,11 +261,37 @@ function isHtmlTagNamePart(code: number): boolean {
)
}
-export function isEditableDetailsHtmlBlock(block: DetailsHtmlBlock): boolean {
- if (block.hasNestedDetails) {
- return false
- }
+// Why: nested toggles validate recursively and each level rescans its own body,
+// so a pathological file would otherwise blow the stack or go quadratic.
+const MAX_DETAILS_NESTING_LEVELS = 16
+// Removes nested toggles that are themselves editable, so the caller's raw-tag
+// scan sees only the body's own markup. Null when a nested toggle can't be one.
+function stripEditableNestedDetails(bodyHtml: string, nestingLevel: number): string | null {
+ let result = ''
+ let index = 0
+
+ for (;;) {
+ const nestedStart = indexOfAsciiIgnoreCase(bodyHtml, '= MAX_DETAILS_NESTING_LEVELS) {
+ return null
+ }
+
+ const nested = matchDetailsHtmlBlock(bodyHtml, nestedStart)
+ if (!nested || !isEditableDetailsHtmlBlock(nested, nestingLevel + 1)) {
+ return null
+ }
+
+ result += bodyHtml.slice(index, nestedStart)
+ index = nestedStart + nested.raw.length
+ }
+}
+
+export function isEditableDetailsHtmlBlock(block: DetailsHtmlBlock, nestingLevel = 1): boolean {
if (!hasOnlySupportedDetailsAttributes(block.openingAttributes)) {
return false
}
@@ -289,7 +309,11 @@ export function isEditableDetailsHtmlBlock(block: DetailsHtmlBlock): boolean {
return false
}
- const bodyHtml = block.inner.slice(summary.rawLength)
+ const bodyHtml = stripEditableNestedDetails(block.inner.slice(summary.rawLength), nestingLevel)
+ if (bodyHtml === null) {
+ return false
+ }
+
if (!hasOnlyPlainParagraphAndBreakTags(bodyHtml)) {
return false
}
diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts
index fabdc6b4d43..3f896f80e38 100644
--- a/src/renderer/src/components/editor/markdown-round-trip.test.ts
+++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts
@@ -186,9 +186,74 @@ describe('rich markdown round trip', () => {
expect(roundTripMarkdown(input)).toBe(input.trimEnd())
})
- it('preserves nested details blocks as passthrough html', () => {
+ it('reopens nested toggles as editable details blocks', () => {
+ expect(
+ roundTripMarkdown(
+ 'Outer
Inner
Body
\n'
+ )
+ ).toBe(
+ [
+ '',
+ 'Outer
',
+ '',
+ '',
+ 'Inner
',
+ '',
+ 'Body',
+ '',
+ ' ',
+ '',
+ ' '
+ ].join('\n')
+ )
+ })
+
+ it('round-trips an orca-authored nested toggle unchanged', () => {
+ const input = [
+ '',
+ '08/26/2026
',
+ '',
+ '',
+ 'goals
',
+ '',
+ '- Read X post',
+ ' - Collab',
+ '',
+ ' ',
+ '',
+ '- after inner',
+ '',
+ ' ',
+ ''
+ ].join('\n')
+ expect(roundTripMarkdown(input)).toBe(input.trimEnd())
+ })
+
+ it('keeps nested toggle bodies editable rather than inert raw html', () => {
+ const markdown = markdownAfterTextReplace(
+ [
+ '',
+ 'Outer
',
+ '',
+ '',
+ 'Inner
',
+ '',
+ 'Body',
+ '',
+ ' ',
+ '',
+ ' ',
+ ''
+ ].join('\n'),
+ 'Body',
+ 'Edited body'
+ )
+ expect(markdown).toContain('Edited body')
+ })
+
+ it('preserves a nested toggle that is not itself editable as passthrough html', () => {
const input =
- 'Outer
Inner
Body
\n'
+ 'Outer
Inner
Body
\n'
expect(roundTripMarkdown(input)).toBe(input.trimEnd())
})
diff --git a/tests/e2e/helpers/markdown-editor-fixture.ts b/tests/e2e/helpers/markdown-editor-fixture.ts
new file mode 100644
index 00000000000..b9b71a3bb81
--- /dev/null
+++ b/tests/e2e/helpers/markdown-editor-fixture.ts
@@ -0,0 +1,139 @@
+import { randomUUID } from 'node:crypto'
+import { mkdir, rm, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import type { Locator, Page } from '@stablyai/playwright-test'
+import { expect } from '@stablyai/playwright-test'
+
+const MARKDOWN_HYDRATION_TIMEOUT_MS = 25_000
+
+export type ActiveWorktreeContext = {
+ worktreeId: string
+ rootPath: string
+}
+
+export 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,
+ directoryName: string,
+ slug: string,
+ workerIndex: number,
+ initialMarkdown: string
+): Promise {
+ const directory = path.join(context.rootPath, directoryName)
+ 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 closeActiveEditorTab(page: Page, filePath: string): Promise {
+ const fileName = path.basename(filePath)
+ const tab = page.locator('[data-tab-id]').filter({ hasText: fileName }).last()
+ await tab.getByRole('button', { name: 'Close tab' }).click()
+
+ await expect(page.locator('.editor-header-path').filter({ hasText: fileName })).toHaveCount(0, {
+ timeout: 10_000
+ })
+}
diff --git a/tests/e2e/helpers/markdown-nested-toggle.ts b/tests/e2e/helpers/markdown-nested-toggle.ts
new file mode 100644
index 00000000000..6ea15a8c68b
--- /dev/null
+++ b/tests/e2e/helpers/markdown-nested-toggle.ts
@@ -0,0 +1,163 @@
+import type { Page } from '@stablyai/playwright-test'
+import { expect } from '@stablyai/playwright-test'
+
+const TOGGLE_RENDER_TIMEOUT_MS = 10_000
+
+export const NESTED_TOGGLE_FIXTURE_DIRECTORY = '.orca-e2e-markdown-nested-toggle'
+
+export const NESTED_TOGGLE_BODY_TEXT = 'Get back to ppl'
+
+export const NESTED_TOGGLE_MARKDOWN = [
+ '# 08/27/2026',
+ '',
+ '',
+ '08/26/2026
',
+ '',
+ '',
+ 'goals
',
+ '',
+ '- Get back to ppl',
+ '',
+ ' ',
+ '',
+ '- after inner toggle',
+ '',
+ ' ',
+ ''
+].join('\n')
+
+// An inner toggle carrying unsupported attributes cannot become an editable
+// node, so the whole block must still fall back to byte-preserving passthrough.
+export const UNSUPPORTED_NESTED_TOGGLE_MARKDOWN = [
+ '',
+ 'outer
',
+ '',
+ '',
+ 'inner
',
+ '',
+ 'Body',
+ '',
+ ' ',
+ '',
+ ' ',
+ ''
+].join('\n')
+
+export type RenderedToggles = {
+ toggleCount: number
+ nestedToggleCount: number
+ summaries: string[]
+ variants: (string | null)[]
+ passthroughBlockCount: number
+}
+
+export async function readRenderedToggles(page: Page): Promise {
+ return page.evaluate(() => {
+ const editor = document.querySelector('.rich-markdown-editor')
+ const toggles = Array.from(editor?.querySelectorAll('[data-type="details"]') ?? [])
+
+ return {
+ toggleCount: toggles.length,
+ nestedToggleCount:
+ editor?.querySelectorAll('[data-type="details"] [data-type="details"]').length ?? 0,
+ summaries: toggles.map(
+ (toggle) => toggle.querySelector('summary')?.textContent?.trim() ?? ''
+ ),
+ variants: toggles.map((toggle) => toggle.getAttribute('data-orca-toggle')),
+ passthroughBlockCount: editor?.querySelectorAll('[data-raw-markdown-html-block]').length ?? 0
+ }
+ })
+}
+
+export async function expectEditableNestedToggles(page: Page): Promise {
+ await expect
+ .poll(async () => readRenderedToggles(page), {
+ timeout: TOGGLE_RENDER_TIMEOUT_MS,
+ message: 'Nested toggles did not render as editable toggle nodes'
+ })
+ .toEqual({
+ toggleCount: 2,
+ nestedToggleCount: 1,
+ summaries: ['08/26/2026', 'goals'],
+ variants: ['heading-3', null],
+ passthroughBlockCount: 0
+ })
+}
+
+export async function expectPassthroughFallback(page: Page): Promise {
+ const passthrough = page.locator('.rich-markdown-editor [data-raw-markdown-html-block]')
+ await expect(passthrough).toHaveCount(1, { timeout: TOGGLE_RENDER_TIMEOUT_MS })
+ await expect(passthrough).toContainText('')
+}
+
+async function selectionIsInsideNestedToggleBody(page: Page): Promise {
+ return page.evaluate(() => {
+ const editor = document.querySelector('.rich-markdown-editor') as
+ | (Element & { editor?: { state?: { selection?: { from?: number; empty?: boolean } } } })
+ | null
+ const paragraph = editor?.querySelector(
+ '[data-type="details"] [data-type="details"] [data-type="detailsContent"] p'
+ ) as (Element & { pmViewDesc?: { posAtStart?: number; posAtEnd?: number } }) | null
+ const selection = editor?.editor?.state?.selection
+ const from = selection?.from
+ const start = paragraph?.pmViewDesc?.posAtStart
+ const end = paragraph?.pmViewDesc?.posAtEnd
+
+ if (
+ !selection?.empty ||
+ typeof from !== 'number' ||
+ typeof start !== 'number' ||
+ typeof end !== 'number'
+ ) {
+ return false
+ }
+
+ return from >= start && from <= end
+ })
+}
+
+export async function placeCaretInNestedToggleBody(page: Page): Promise {
+ // Real input only: click the body text itself, then extend to end of line.
+ await page
+ .locator('.rich-markdown-editor')
+ .getByText(NESTED_TOGGLE_BODY_TEXT, { exact: true })
+ .click()
+ await page.keyboard.press('End')
+
+ await expect
+ .poll(async () => selectionIsInsideNestedToggleBody(page), {
+ timeout: 3_000,
+ message: 'Clicking the nested toggle body text did not place the caret inside it'
+ })
+ .toBe(true)
+}
+
+export async function expectSentinelInsideNestedToggle(
+ page: Page,
+ sentinel: string
+): Promise {
+ const nestedBody = page.locator(
+ '.rich-markdown-editor [data-type="details"] [data-type="details"] [data-type="detailsContent"]'
+ )
+ await expect(nestedBody).toContainText(sentinel, { timeout: TOGGLE_RENDER_TIMEOUT_MS })
+}
+
+export function expectFileKeepsNesting(fileContents: string, sentinel: string): void {
+ const outerOpen = fileContents.indexOf('')
+ const firstClose = fileContents.indexOf(' ')
+ const sentinelAt = fileContents.indexOf(sentinel)
+
+ expect({
+ openTagCount: fileContents.split('').length - 1,
+ innerIsNestedInOuter: outerOpen !== -1 && innerOpen > outerOpen,
+ // The edited line has to stay inside the inner toggle, before its close.
+ sentinelInsideInner: sentinelAt !== -1 && sentinelAt > innerOpen && sentinelAt < firstClose
+ }).toEqual({
+ openTagCount: 2,
+ closeTagCount: 2,
+ innerIsNestedInOuter: true,
+ sentinelInsideInner: true
+ })
+}
diff --git a/tests/e2e/helpers/markdown-ordered-list-exit.ts b/tests/e2e/helpers/markdown-ordered-list-exit.ts
index 30e0258c4ea..5a026e78e53 100644
--- a/tests/e2e/helpers/markdown-ordered-list-exit.ts
+++ b/tests/e2e/helpers/markdown-ordered-list-exit.ts
@@ -1,16 +1,20 @@
-import { randomUUID } from 'node:crypto'
-import { mkdir, rm, writeFile } from 'node:fs/promises'
-import path from 'node:path'
-import type { Locator, Page } from '@stablyai/playwright-test'
+import type { Page } from '@stablyai/playwright-test'
import { expect } from '@stablyai/playwright-test'
+import {
+ createMarkdownFixture as createMarkdownFixtureIn,
+ type ActiveWorktreeContext
+} from './markdown-editor-fixture'
-const MARKDOWN_HYDRATION_TIMEOUT_MS = 25_000
const DRAFT_SERIALIZATION_TIMEOUT_MS = 10_000
+const FIXTURE_DIRECTORY = '.orca-e2e-markdown-ordered-list'
-export type ActiveWorktreeContext = {
- worktreeId: string
- rootPath: string
-}
+export {
+ cleanupMarkdownFixture,
+ getActiveWorktreeContext,
+ openMarkdownFixture,
+ waitForRichMarkdownEditor,
+ type ActiveWorktreeContext
+} from './markdown-editor-fixture'
export type MatrixRow = {
name: string
@@ -20,120 +24,13 @@ export type MatrixRow = {
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
+ return createMarkdownFixtureIn(context, FIXTURE_DIRECTORY, slug, workerIndex, initialMarkdown)
}
export async function expectSentinelParagraphOutsideOrderedList(
diff --git a/tests/e2e/markdown-nested-toggle.spec.ts b/tests/e2e/markdown-nested-toggle.spec.ts
new file mode 100644
index 00000000000..537e6b65775
--- /dev/null
+++ b/tests/e2e/markdown-nested-toggle.spec.ts
@@ -0,0 +1,113 @@
+import { readFileSync } from 'node:fs'
+import { test, expect } from './helpers/orca-app'
+import {
+ cleanupMarkdownFixture,
+ closeActiveEditorTab,
+ createMarkdownFixture,
+ getActiveWorktreeContext,
+ openMarkdownFixture,
+ waitForRichMarkdownEditor
+} from './helpers/markdown-editor-fixture'
+import {
+ expectEditableNestedToggles,
+ expectFileKeepsNesting,
+ expectPassthroughFallback,
+ expectSentinelInsideNestedToggle,
+ NESTED_TOGGLE_FIXTURE_DIRECTORY,
+ NESTED_TOGGLE_MARKDOWN,
+ placeCaretInNestedToggleBody,
+ UNSUPPORTED_NESTED_TOGGLE_MARKDOWN
+} from './helpers/markdown-nested-toggle'
+import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
+
+test.describe('Markdown nested toggle regression', () => {
+ test.beforeEach(async ({ orcaPage }) => {
+ await waitForSessionReady(orcaPage)
+ await waitForActiveWorktree(orcaPage)
+ })
+
+ test('a nested toggle on disk reopens as editable toggles', async ({ orcaPage }, testInfo) => {
+ const context = await getActiveWorktreeContext(orcaPage)
+ let filePath: string | null = null
+
+ try {
+ filePath = await createMarkdownFixture(
+ context,
+ NESTED_TOGGLE_FIXTURE_DIRECTORY,
+ 'nested-toggle-reopen',
+ testInfo.workerIndex,
+ NESTED_TOGGLE_MARKDOWN
+ )
+ await openMarkdownFixture(orcaPage, context, filePath)
+ await waitForRichMarkdownEditor(orcaPage)
+
+ await expectEditableNestedToggles(orcaPage)
+ } finally {
+ await cleanupMarkdownFixture(filePath)
+ }
+ })
+
+ test('editing a nested toggle survives save and reopen', async ({ orcaPage }, testInfo) => {
+ const context = await getActiveWorktreeContext(orcaPage)
+ const sentinel = `editedInsideNestedToggle${Date.now()}`
+ let filePath: string | null = null
+
+ try {
+ filePath = await createMarkdownFixture(
+ context,
+ NESTED_TOGGLE_FIXTURE_DIRECTORY,
+ 'nested-toggle-edit',
+ testInfo.workerIndex,
+ NESTED_TOGGLE_MARKDOWN
+ )
+ await openMarkdownFixture(orcaPage, context, filePath)
+ await waitForRichMarkdownEditor(orcaPage)
+ await expectEditableNestedToggles(orcaPage)
+
+ await placeCaretInNestedToggleBody(orcaPage)
+ await orcaPage.keyboard.type(` ${sentinel}`)
+ await expectSentinelInsideNestedToggle(orcaPage, sentinel)
+
+ // Save through the real shortcut and assert the bytes that landed on disk.
+ await orcaPage.keyboard.press('ControlOrMeta+S')
+ const savedPath = filePath
+ await expect
+ .poll(() => readFileSync(savedPath, 'utf8'), { timeout: 10_000 })
+ .toContain(sentinel)
+ expectFileKeepsNesting(readFileSync(savedPath, 'utf8'), sentinel)
+
+ // The reported bug only appeared on reopen, so close the tab and parse
+ // the saved file again from scratch.
+ await closeActiveEditorTab(orcaPage, savedPath)
+ await openMarkdownFixture(orcaPage, context, savedPath)
+ await waitForRichMarkdownEditor(orcaPage)
+ await expectEditableNestedToggles(orcaPage)
+ await expectSentinelInsideNestedToggle(orcaPage, sentinel)
+ } finally {
+ await cleanupMarkdownFixture(filePath)
+ }
+ })
+
+ test('a nested toggle that cannot be represented stays raw passthrough', async ({
+ orcaPage
+ }, testInfo) => {
+ const context = await getActiveWorktreeContext(orcaPage)
+ let filePath: string | null = null
+
+ try {
+ filePath = await createMarkdownFixture(
+ context,
+ NESTED_TOGGLE_FIXTURE_DIRECTORY,
+ 'nested-toggle-unsupported',
+ testInfo.workerIndex,
+ UNSUPPORTED_NESTED_TOGGLE_MARKDOWN
+ )
+ await openMarkdownFixture(orcaPage, context, filePath)
+ await waitForRichMarkdownEditor(orcaPage)
+
+ await expectPassthroughFallback(orcaPage)
+ } finally {
+ await cleanupMarkdownFixture(filePath)
+ }
+ })
+})