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: 'Toggle

Body

', - hasNestedDetails: false + inner: 'Toggle

Body

' } const unsupportedHeading6: DetailsHtmlBlock = { raw: '', openingAttributes: ' data-orca-toggle="heading-6"', - inner: 'Toggle

Body

', - hasNestedDetails: false + inner: 'Toggle

Body

' } 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: 'Outer
Inner

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: 'Outer
Inner' + } + const lookalike: DetailsHtmlBlock = { + raw: '', + openingAttributes: '', + inner: 'OuterBody' + } + + 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) + } + }) +})