mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
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.
This commit is contained in:
@@ -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 = '<details class="orca-details" open>\n<summary>leaf</summary>\n\nBody\n\n</details>'
|
||||
for (let level = depth - 1; level > 0; level -= 1) {
|
||||
html = `<details class="orca-details" open>\n<summary>level ${level}</summary>\n\n${html}\n\n</details>`
|
||||
}
|
||||
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: `<summary>${'Heading line\n'.repeat(1_000)}</summary><p>Body</p>`,
|
||||
hasNestedDetails: false
|
||||
inner: `<summary>${'Heading line\n'.repeat(1_000)}</summary><p>Body</p>`
|
||||
}
|
||||
|
||||
expect(isEditableDetailsHtmlBlock(block)).toBe(true)
|
||||
@@ -61,17 +74,51 @@ describe('details markdown html', () => {
|
||||
const editableHeading5: DetailsHtmlBlock = {
|
||||
raw: '',
|
||||
openingAttributes: ' data-orca-toggle="heading-5"',
|
||||
inner: '<summary>Toggle</summary><p>Body</p>',
|
||||
hasNestedDetails: false
|
||||
inner: '<summary>Toggle</summary><p>Body</p>'
|
||||
}
|
||||
const unsupportedHeading6: DetailsHtmlBlock = {
|
||||
raw: '',
|
||||
openingAttributes: ' data-orca-toggle="heading-6"',
|
||||
inner: '<summary>Toggle</summary><p>Body</p>',
|
||||
hasNestedDetails: false
|
||||
inner: '<summary>Toggle</summary><p>Body</p>'
|
||||
}
|
||||
|
||||
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: '<summary>Outer</summary><details id="x"><summary>Inner</summary><p>Body</p></details>'
|
||||
}
|
||||
|
||||
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: '<summary>Outer</summary><details><summary>Inner</summary>'
|
||||
}
|
||||
const lookalike: DetailsHtmlBlock = {
|
||||
raw: '',
|
||||
openingAttributes: '',
|
||||
inner: '<summary>Outer</summary><detailsish>Body</detailsish>'
|
||||
}
|
||||
|
||||
expect(isEditableDetailsHtmlBlock(unbalanced)).toBe(false)
|
||||
expect(isEditableDetailsHtmlBlock(lookalike)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(/^<details\b/i, '').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, '<details', index)
|
||||
if (nestedStart === -1) {
|
||||
return result + bodyHtml.slice(index)
|
||||
}
|
||||
|
||||
if (nestingLevel >= 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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
'<details><summary>Outer</summary><details><summary>Inner</summary><p>Body</p></details></details>\n'
|
||||
)
|
||||
).toBe(
|
||||
[
|
||||
'<details class="orca-details">',
|
||||
'<summary>Outer</summary>',
|
||||
'',
|
||||
'<details class="orca-details">',
|
||||
'<summary>Inner</summary>',
|
||||
'',
|
||||
'Body',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'</details>'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('round-trips an orca-authored nested toggle unchanged', () => {
|
||||
const input = [
|
||||
'<details class="orca-details" data-orca-toggle="heading-3" open>',
|
||||
'<summary>08/26/2026</summary>',
|
||||
'',
|
||||
'<details class="orca-details" open>',
|
||||
'<summary>goals</summary>',
|
||||
'',
|
||||
'- Read X post',
|
||||
' - Collab',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'- after inner',
|
||||
'',
|
||||
'</details>',
|
||||
''
|
||||
].join('\n')
|
||||
expect(roundTripMarkdown(input)).toBe(input.trimEnd())
|
||||
})
|
||||
|
||||
it('keeps nested toggle bodies editable rather than inert raw html', () => {
|
||||
const markdown = markdownAfterTextReplace(
|
||||
[
|
||||
'<details class="orca-details" open>',
|
||||
'<summary>Outer</summary>',
|
||||
'',
|
||||
'<details class="orca-details" open>',
|
||||
'<summary>Inner</summary>',
|
||||
'',
|
||||
'Body',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'</details>',
|
||||
''
|
||||
].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 =
|
||||
'<details><summary>Outer</summary><details><summary>Inner</summary><p>Body</p></details></details>\n'
|
||||
'<details><summary>Outer</summary><details id="x"><summary>Inner</summary><p>Body</p></details></details>\n'
|
||||
expect(roundTripMarkdown(input)).toBe(input.trimEnd())
|
||||
})
|
||||
|
||||
|
||||
@@ -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<ActiveWorktreeContext> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<ActiveEditorFile> {
|
||||
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<Locator> {
|
||||
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<void> {
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
'',
|
||||
'<details class="orca-details" data-orca-toggle="heading-3" open>',
|
||||
'<summary>08/26/2026</summary>',
|
||||
'',
|
||||
'<details class="orca-details" open>',
|
||||
'<summary>goals</summary>',
|
||||
'',
|
||||
'- Get back to ppl',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'- after inner toggle',
|
||||
'',
|
||||
'</details>',
|
||||
''
|
||||
].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 = [
|
||||
'<details class="orca-details" open>',
|
||||
'<summary>outer</summary>',
|
||||
'',
|
||||
'<details id="not-a-toggle">',
|
||||
'<summary>inner</summary>',
|
||||
'',
|
||||
'Body',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'</details>',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
export type RenderedToggles = {
|
||||
toggleCount: number
|
||||
nestedToggleCount: number
|
||||
summaries: string[]
|
||||
variants: (string | null)[]
|
||||
passthroughBlockCount: number
|
||||
}
|
||||
|
||||
export async function readRenderedToggles(page: Page): Promise<RenderedToggles> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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('<details id="not-a-toggle">')
|
||||
}
|
||||
|
||||
async function selectionIsInsideNestedToggleBody(page: Page): Promise<boolean> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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('<details class="orca-details" data-orca-toggle=')
|
||||
const innerOpen = fileContents.indexOf('<details class="orca-details" open>')
|
||||
const firstClose = fileContents.indexOf('</details>')
|
||||
const sentinelAt = fileContents.indexOf(sentinel)
|
||||
|
||||
expect({
|
||||
openTagCount: fileContents.split('<details').length - 1,
|
||||
closeTagCount: fileContents.split('</details>').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
|
||||
})
|
||||
}
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
type ActiveEditorFile = {
|
||||
filePath: string
|
||||
}
|
||||
|
||||
export async function getActiveWorktreeContext(page: Page): Promise<ActiveWorktreeContext> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<ActiveEditorFile> {
|
||||
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<Locator> {
|
||||
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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user