Эх сурвалжийг харах

test(editor): complete E2E Phase 2 — paste, DnD, focus, cursor boundaries, IME

- Add paste interception tests: single-line, multi-line uniform,
  multi-line mixed types (triggers block split)
- Add drag-and-drop reorder tests via reorderBlocks utility
- Add focus management tests: click-to-focus, type-in-correct-block,
  click-outside-blur
- Add cursor boundary tests: typing before/after hidden **, ~~ delimiters
- Add IME composition test: / during composition does not open menu
- Fix getBlockIds to read from blocks.value order (not Map insertion order)
- Add zoneIndex prop + data-zone-index attribute to EditorInsertionZone
- Add reorderBlocks to testUtils harness
Zander Hawke 23 цаг өмнө
parent
commit
03e82e906e

+ 61 - 0
packages/editor/e2e/cursor-boundary.spec.ts

@@ -0,0 +1,61 @@
+import { expect, test } from "@playwright/test"
+
+test.describe("Cursor boundary behavior", () => {
+  test.beforeEach(async ({ page }) => {
+    await page.goto("/editor?e2e=true")
+  })
+
+  test("typing after bold delimiter does not trap cursor", async ({ page }) => {
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    // Second block contains "**block**" in its content
+    const id = blockIds![1]
+    // Position cursor after "**block**" (14 chars into the paragraph text)
+    await page.evaluate(
+      ({ bid, from, to }: { bid: string; from: number; to: number }) =>
+        window.__testUtils__?.selectText(bid, from, to),
+      { bid: id, from: 14, to: 14 },
+    )
+
+    await page.keyboard.type("XX")
+
+    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(content).toContain("XX")
+  })
+
+  test("typing before bold delimiter flows continuously", async ({ page }) => {
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const id = blockIds![1]
+    // Position cursor right before "**block**" — "Each **" = 5 chars
+    await page.evaluate(
+      ({ bid, from, to }: { bid: string; from: number; to: number }) =>
+        window.__testUtils__?.selectText(bid, from, to),
+      { bid: id, from: 5, to: 5 },
+    )
+
+    await page.keyboard.type("YY")
+
+    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(content).toContain("YY")
+  })
+
+  test("typing after strikethrough delimiter does not trap cursor", async ({ page }) => {
+    // The initial content doesn't have ~~strikethrough~~, so use replaceContent
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const id = blockIds![0]
+    await page.evaluate(
+      ({ bid }: { bid: string }) =>
+        window.__testUtils__?.replaceContent(bid, "hello ~~world~~ test"),
+      { bid: id },
+    )
+    await page.evaluate(
+      ({ bid, from, to }: { bid: string; from: number; to: number }) =>
+        window.__testUtils__?.selectText(bid, from, to),
+      { bid: id, from: 16, to: 16 },
+    )
+
+    await page.keyboard.type("ZZ")
+
+    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(content).toContain("ZZ")
+  })
+})

+ 41 - 0
packages/editor/e2e/drag-drop.spec.ts

@@ -0,0 +1,41 @@
+import { expect, test } from "@playwright/test"
+
+test.describe("Drag-and-drop block reorder", () => {
+  test.beforeEach(async ({ page }) => {
+    await page.goto("/editor?e2e=true")
+  })
+
+  test("drag block 0 to after block 2 reorders correctly", async ({ page }) => {
+    const beforeIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    expect(beforeIds!.length).toBeGreaterThan(2)
+
+    // Use programmatic reorderBlocks instead of HTML5 DnD, since Playwright
+    // cannot reliably trigger the browser's drag-and-drop engine.
+    await page.evaluate(() => {
+      window.__testUtils__?.reorderBlocks(0, 2)
+    })
+
+    // Block 0 should now be at index 2 (after blocks 1 and 2)
+
+    // Block 0 should now be at index 2 (after blocks 1 and 2)
+    const afterIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    expect(afterIds![0]).toBe(beforeIds![1])
+    expect(afterIds![1]).toBe(beforeIds![2])
+    expect(afterIds![2]).toBe(beforeIds![0])
+  })
+
+  test("drag block 2 to before block 0 reorders correctly", async ({ page }) => {
+    const beforeIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    expect(beforeIds!.length).toBeGreaterThan(2)
+
+    // Move block at index 2 to before block 0
+    await page.evaluate(() => {
+      window.__testUtils__?.reorderBlocks(2, 0)
+    })
+
+    const afterIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    expect(afterIds![0]).toBe(beforeIds![2])
+    expect(afterIds![1]).toBe(beforeIds![0])
+    expect(afterIds![2]).toBe(beforeIds![1])
+  })
+})

+ 45 - 0
packages/editor/e2e/focus.spec.ts

@@ -0,0 +1,45 @@
+import { expect, test } from "@playwright/test"
+
+test.describe("Focus management", () => {
+  test.beforeEach(async ({ page }) => {
+    await page.goto("/editor?e2e=true")
+  })
+
+  test("clicking a block focuses its PM editor", async ({ page }) => {
+    const pm = page.locator('[role="textbox"][data-block-id]').nth(2)
+    await pm.click()
+    await expect(pm).toBeFocused()
+  })
+
+  test("typing after focus lands text in the correct block", async ({ page }) => {
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const firstId = blockIds![0]
+    const secondId = blockIds![1]
+
+    // Focus and type in first block
+    const firstPm = page.locator(`[role="textbox"][data-block-id="${firstId}"]`)
+    await firstPm.click()
+    await expect(firstPm).toBeFocused()
+    await page.keyboard.type("AAA")
+    const afterFirstType = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(afterFirstType).toContain("AAA")
+
+    // Click and type in second block
+    const secondPm = page.locator(`[role="textbox"][data-block-id="${secondId}"]`)
+    await secondPm.click()
+    await expect(secondPm).toBeFocused()
+    await page.keyboard.type("BBB")
+    const afterSecondType = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(afterSecondType).toContain("BBB")
+  })
+
+  test("clicking outside editor blurs active block", async ({ page }) => {
+    const pm = page.locator('[role="textbox"][data-block-id]').first()
+    await pm.click()
+    await expect(pm).toBeFocused()
+
+    // Click outside the editor to blur
+    await page.locator("body").click({ position: { x: 0, y: 0 } })
+    await expect(pm).not.toBeFocused()
+  })
+})

+ 50 - 0
packages/editor/e2e/ime.spec.ts

@@ -0,0 +1,50 @@
+import { expect, test } from "@playwright/test"
+
+test.describe("IME composition", () => {
+  test.beforeEach(async ({ page }) => {
+    await page.goto("/editor?e2e=true")
+  })
+
+  test("typing / during composition does not open suggestion menu", async ({ page }) => {
+    // Focus the PM editor
+    const pm = page.locator('[role="textbox"][data-block-id]').first()
+    await pm.click()
+
+    // Simulate IME composition starting, then committing "/" as part of
+    // composed text — the suggestion menu should not appear because the
+    // text was not typed as individual keystrokes.
+    await page.evaluate(() => {
+      const el = document.activeElement
+      if (!el) return
+
+      // Start composition
+      el.dispatchEvent(new CompositionEvent("compositionstart", { data: "" }))
+
+      // Simulate the composition text being entered (beforeinput with
+      // insertCompositionText type). PM's composition handling processes
+      // this and dispatches a transaction with the composed text.
+      if ("InputEvent" in window) {
+        el.dispatchEvent(
+          new InputEvent("beforeinput", {
+            inputType: "insertCompositionText",
+            data: "/",
+            bubbles: true,
+            cancelable: true,
+          }),
+        )
+      }
+
+      // End composition (commit the text)
+      el.dispatchEvent(
+        new CompositionEvent("compositionend", { data: "/" }),
+      )
+    })
+
+    await page.waitForTimeout(200)
+
+    // The suggestion menu should NOT be visible — IME composition text
+    // is committed as a single block, not as individual keystrokes.
+    const menu = page.locator("[data-command-palette]")
+    await expect(menu).not.toBeVisible()
+  })
+})

+ 62 - 0
packages/editor/e2e/paste.spec.ts

@@ -0,0 +1,62 @@
+import { expect, test } from "@playwright/test"
+
+test.describe("Paste interception", () => {
+  test.beforeEach(async ({ page }) => {
+    await page.goto("/editor?e2e=true")
+  })
+
+  async function pasteIntoFocused(page: any, text: string) {
+    // Dispatch a paste event on the focused PM editable element.
+    // Control+V doesn't trigger paste reliably across platforms in headless
+    // mode, so we use the ClipboardEvent constructor directly.
+    await page.evaluate(async (t: string) => {
+      const el = document.activeElement
+      if (!el) return
+      const dt = new DataTransfer()
+      dt.setData("text/plain", t)
+      el.dispatchEvent(
+        new ClipboardEvent("paste", {
+          clipboardData: dt,
+          bubbles: true,
+          cancelable: true,
+        }),
+      )
+    }, text)
+    // Allow PM to process the paste transaction
+    await page.waitForTimeout(100)
+  }
+
+  test("pastes single line into focused block", async ({ page }) => {
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const lastId = blockIds![blockIds!.length - 1]
+    await page.evaluate((bid: string) => window.__testUtils__?.focusAtEnd(bid), lastId)
+    await pasteIntoFocused(page, "pasted text")
+
+    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(content).toContain("pasted text")
+  })
+
+  test("pastes multi-line uniform text into same block", async ({ page }) => {
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const lastId = blockIds![blockIds!.length - 1]
+    await page.evaluate((bid: string) => window.__testUtils__?.focusAtEnd(bid), lastId)
+    await pasteIntoFocused(page, "line1\nline2\nline3")
+
+    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(content).toContain("line1")
+    expect(content).toContain("line2")
+    expect(content).toContain("line3")
+  })
+
+  test("pastes multi-line with mixed types into new block", async ({ page }) => {
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const lastId = blockIds![blockIds!.length - 1]
+    await page.evaluate((bid: string) => window.__testUtils__?.focusAtEnd(bid), lastId)
+
+    const beforeCount = blockIds!.length
+    await pasteIntoFocused(page, "text\n> blockquote")
+
+    const afterIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    expect(afterIds!.length).toBe(beforeCount + 1)
+  })
+})

+ 2 - 0
packages/editor/src/__test-utils__/harness.d.ts

@@ -17,6 +17,8 @@ export interface EditorTestUtils {
   toggleBlur: () => Promise<void>
   /** Return the Lezer AST tree snapshot for assertion */
   getParseTree: () => string
+  /** Reorder a block by index (synthetic DnD alternative) */
+  reorderBlocks: (fromIndex: number, toIndex: number) => void
   /** Fast-forward the coalescing clock without real wait (Phase 2.6) */
   setTimeSource: (fakeMs: number) => void
 }

+ 21 - 1
packages/editor/src/components/Editor.vue

@@ -821,7 +821,7 @@ onMounted(() => {
       )
     },
     getBlockIds() {
-      return Array.from(blockRefs.keys())
+      return blocks.value.map((b) => b.id)
     },
     insertText(blockId: string, text: string) {
       const api = blockRefs.get(blockId) as any
@@ -893,6 +893,24 @@ onMounted(() => {
       if (!firstApi?.view) return ""
       return (firstApi.view as EditorView).state.doc.toString()
     },
+    reorderBlocks(fromIndex: number, toIndex: number) {
+      if (fromIndex === toIndex) return
+      const moved = blocks.value[fromIndex]
+      if (!moved) return
+      blocks.value = moveBlock(blocks.value, fromIndex, toIndex)
+      void history.execute({
+        type: "move-block",
+        fromIndex,
+        toIndex,
+        blockId: moved.id,
+        content: moved.content,
+        depth: moved.depth,
+        timestamp: Date.now(),
+      })
+      canUndo.value = history.canUndo
+      canRedo.value = history.canRedo
+      onBlockContentChange()
+    },
     setTimeSource(fakeMs: number) {
       ;(window as any).__testTimeSource = fakeMs
     },
@@ -938,6 +956,7 @@ defineExpose({
       <template v-for="(block, i) in blocks" :key="block.id">
         <EditorInsertionZone
         :ref="(el: any) => { if (el) zoneRefs.set(i, el); else zoneRefs.delete(i) }"
+        :zone-index="i"
         :class="dropZoneIndex === i ? 'bg-(--ui-primary) rounded' : ''"
         @activate="(content: string) => onZoneActivate(i, content)"
         @focus="onZoneFocus(i)"
@@ -1010,6 +1029,7 @@ defineExpose({
     </template>
     <EditorInsertionZone
       :ref="(el: any) => { if (el) zoneRefs.set(blocks.length, el); else zoneRefs.delete(blocks.length) }"
+      :zone-index="blocks.length"
       :class="dropZoneIndex === blocks.length ? 'bg-(--ui-primary) rounded' : ''"
       @activate="(content: string) => onZoneActivate(blocks.length, content)"
       @focus="onZoneFocus(blocks.length)"

+ 6 - 0
packages/editor/src/components/EditorInsertionZone.vue

@@ -1,6 +1,10 @@
 <script setup lang="ts">
 import { nextTick, ref } from "vue"
 
+const props = defineProps<{
+  zoneIndex?: number
+}>()
+
 const emit = defineEmits<{
   activate: [content: string]
   focus: []
@@ -156,6 +160,8 @@ defineExpose({
     role="button"
     aria-label="Insert new block"
     class="insertion-zone relative outline-none cursor-pointer transition-all duration-150"
+    :data-zone-index="zoneIndex"
+    :data-drop-target="''"
     :class="focused ? 'h-10' : 'h-1'"
     @keydown="onKeydown"
     @paste="onPaste"