Parcourir la source

test(editor): add Playwright E2E harness, data attributes, shared test utils

- Install @playwright/test, configure with webServer auto-start on port 5173
- Inject window.__testUtils__ via ?e2e=true query param (selectText,
  insertText, getContent, toggleBlur, getBlockIds, getParseTree,
  setTimeSource)
- Add data-block-id, data-toolbar-action, data-drag-handle,
  data-menu-item-index, data-codeblock-editor attributes
- Extract shared test utilities (stableId, createMockKeyEvent,
  createMockView, makeParaDoc) into __test-utils__/index.ts
- Add 4 Playwright smoke tests for block render, toolbar, typing,
  text selection
- Add npm scripts: test:e2e, test:e2e:ui, test:visual, test:visual:update
- Exclude e2e/ from vitest config, add playwright-report/ and test-results/
  to .gitignore

ci: split into test → deploy chain with optional E2E job

- Rename workflow to CI with three jobs: test, deploy (needs: test), e2e
- test runs Vitest on codeberg-small (always)
- deploy builds and publishes to Codeberg Pages (push to master or manual)
- e2e runs Playwright only on workflow_dispatch with run_e2e=true checkbox
Zander Hawke il y a 1 jour
Parent
commit
7b66329bf7

+ 31 - 1
.forgejo/workflows/deploy.yml

@@ -1,4 +1,4 @@
-name: Deploy dev app
+name: CI
 on:
   push:
     branches: [master]
@@ -6,9 +6,26 @@ on:
       - "packages/editor/**"
       - "apps/dev/**"
   workflow_dispatch:
+    inputs:
+      run_e2e:
+        description: "Run Playwright E2E tests"
+        type: boolean
+        default: false
 
 jobs:
+  test:
+    runs-on: codeberg-small
+    steps:
+      - uses: actions/checkout@v5
+      - uses: actions/setup-node@v5
+        with:
+          node-version: 22
+      - run: corepack enable && corepack prepare pnpm@latest --activate
+      - run: pnpm install
+      - name: Unit tests
+        run: pnpm test
   deploy:
+    needs: test
     runs-on: codeberg-small
     steps:
       - uses: actions/checkout@v5
@@ -24,3 +41,16 @@ jobs:
           site: https://${{ forge.event.repository.owner.username }}.codeberg.page/${{ forge.event.repository.name }}/
           token: ${{ forge.token }}
           source: apps/dev/dist/
+  e2e:
+    if: ${{ forge.event_name == 'workflow_dispatch' && forge.event.inputs.run_e2e == 'true' }}
+    runs-on: codeberg-small
+    steps:
+      - uses: actions/checkout@v5
+      - uses: actions/setup-node@v5
+        with:
+          node-version: 22
+      - run: corepack enable && corepack prepare pnpm@latest --activate
+      - run: pnpm install
+      - run: pnpm --filter @enesis/editor exec playwright install chromium
+      - name: E2E tests
+        run: pnpm test:e2e

+ 4 - 0
.gitignore

@@ -40,5 +40,9 @@ dist-ssr
 auto-imports.d.ts
 components.d.ts
 
+# Playwright
+**/playwright-report/
+**/test-results/
+
 # Repomix
 repomix*

+ 7 - 39
apps/dev/src/pages/editor.vue

@@ -1,6 +1,11 @@
 <script setup lang="ts">
 import { Editor, EditorToolbar } from "@enesis/editor"
-import { ref } from "vue"
+import { computed, ref } from "vue"
+
+const isE2E = computed(() =>
+  typeof window !== "undefined" &&
+  new URLSearchParams(window.location.search).get("e2e") === "true",
+)
 
 const content = ref(`
 * # Welcome to Enesis Editor
@@ -52,44 +57,6 @@ const content = ref(`
     version:: 0.1.0
 `)
 
-// const content = ref(`# Getting Started with the Editor
-//
-// This document demonstrates all the features the editor supports.
-//
-// ## Text Formatting
-//
-// You can use **bold**, *italic*, \`code\`, ~~strikethrough~~, and ^^highlight^^.
-//
-// ## Links & References
-//
-// Visit [Nuxt UI](https://ui.nuxt.com) for documentation. Reference [[Known Page]] and [[Missing Page]] for comparison. Use #tags for categorization.
-//
-// ## Tasks & Tables
-//
-// * TODO Task tracking with six states
-// * DOING Work in progress
-// * DONE Completed tasks
-//
-// | Feature | Status |
-// |---|---|
-// | Tables | Done |
-// | Undo/Redo | Done |
-//
-// \`\`\`typescript
-// function greet(name: string): string {
-//   return \`Hello, \${name}!\`
-// }
-// \`\`\`
-//
-// > [!NOTE] Use callouts for important information
-//
-// ![Diagram](https://picsum.photos/seed/kitchen/400/200)
-//
-// ## Properties
-//
-// author:: Editor Demo
-// status:: published`)
-
 const resolvedRefs = new Set(["Known Page", "other-block-id"])
 
 function onPageRefClick({ pageName }: { pageName: string }) {
@@ -128,6 +95,7 @@ function onTagClick({ tag }: { tag: string }) {
               marker-mode="always-visible"
               :resolved-refs="resolvedRefs"
               :resolved-refs-version="resolvedRefs.size"
+              :test-mode="isE2E"
               @page-ref-clicked="onPageRefClick"
               @block-ref-clicked="onBlockRefClick"
               @tag-clicked="onTagClick"

+ 1 - 0
package.json

@@ -5,6 +5,7 @@
     "dev": "pnpm run --filter @enesis/dev dev",
     "build": "pnpm -r --filter=!@enesis/tauri build",
     "test": "pnpm run --filter @enesis/editor test",
+    "test:e2e": "pnpm run --filter @enesis/editor test:e2e",
     "tauri": "pnpm run --filter @enesis/tauri tauri",
     "check": "biome check",
     "mix": "repomix --compress --style xml --include \"**/*.ts,**/*.md,**/*.vue\" --token-count-tree --remove-empty-lines"

+ 38 - 0
packages/editor/e2e/smoke.spec.ts

@@ -0,0 +1,38 @@
+import { expect, test } from "@playwright/test"
+
+test.describe("Editor smoke", () => {
+  test.beforeEach(async ({ page }) => {
+    // Enforce ?e2e=true on every navigation so __testUtils__ is always
+    // available and all tests share the same editor bootstrapping path.
+    await page.goto("/editor?e2e=true")
+  })
+
+  test("page loads and renders editor blocks", async ({ page }) => {
+    // toPass auto-retries — crucial for Vue/PM async rendering.
+    await expect(async () => {
+      const count = await page.locator("[data-block-id]").count()
+      expect(count).toBeGreaterThanOrEqual(10)
+    }).toPass({ timeout: 5000 })
+  })
+
+  test("toolbar is visible with undo/redo buttons", async ({ page }) => {
+    const undo = page.locator('[data-toolbar-action="undo"]')
+    const redo = page.locator('[data-toolbar-action="redo"]')
+    await expect(undo).toBeVisible()
+    await expect(redo).toBeVisible()
+  })
+
+  test("typing in first block changes content", async ({ page }) => {
+    const firstBlock = page.locator("[data-block-id]").first()
+    await firstBlock.focus()
+    await page.keyboard.type("Hello Playwright")
+    const content = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(content).toContain("Hello Playwright")
+  })
+
+  test("__testUtils__ selectText works", async ({ page }) => {
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    expect(blockIds).toBeDefined()
+    expect(blockIds!.length).toBeGreaterThan(0)
+  })
+})

+ 5 - 0
packages/editor/package.json

@@ -19,10 +19,15 @@
     "dev": "vite build --watch",
     "test": "vitest run",
     "test:watch": "vitest",
+    "test:e2e": "playwright test",
+    "test:e2e:ui": "playwright test --ui",
+    "test:visual": "playwright test --grep @visual",
+    "test:visual:update": "playwright test --grep @visual --update-snapshots",
     "bench": "vitest bench"
   },
   "devDependencies": {
     "@nuxt/ui": "^4.7.1",
+    "@playwright/test": "^1.61.0",
     "@tsconfig/node24": "^24.0.4",
     "@types/katex": "^0.16.8",
     "@types/node": "^25.9.0",

+ 26 - 0
packages/editor/playwright.config.ts

@@ -0,0 +1,26 @@
+import { defineConfig, devices } from "@playwright/test"
+
+export default defineConfig({
+  testDir: "./e2e",
+  fullyParallel: true,
+  retries: 2,
+  workers: process.env.CI ? 1 : undefined,
+  reporter: [["html", { open: "never" }]],
+  use: {
+    baseURL: "http://localhost:5173",
+    trace: "on-first-retry",
+  },
+  projects: [
+    {
+      name: "chromium",
+      use: { ...devices["Desktop Chrome"] },
+    },
+  ],
+  webServer: {
+    command:
+      "pnpm --filter @enesis/dev dev",
+    url: "http://localhost:5173",
+    reuseExistingServer: !process.env.CI,
+    timeout: 30_000,
+  },
+})

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

@@ -0,0 +1,22 @@
+export interface EditorTestUtils {
+  /** Programmatic PM selection within a block */
+  selectText: (blockId: string, from: number, to: number) => void
+  /** All data-block-id values currently mounted */
+  getBlockIds: () => string[]
+  /** Dispatch a PM transaction to insert text (bypasses auto-close & pattern plugins) */
+  insertText: (blockId: string, text: string) => void
+  /** Serialize all blocks to unified markdown */
+  getContent: () => string
+  /** Click outside the editor to trigger live-preview (blur) */
+  toggleBlur: () => Promise<void>
+  /** Return the Lezer AST tree snapshot for assertion */
+  getParseTree: () => string
+  /** Fast-forward the coalescing clock without real wait (Phase 2.6) */
+  setTimeSource: (fakeMs: number) => void
+}
+
+declare global {
+  interface Window {
+    __testUtils__: EditorTestUtils
+  }
+}

+ 64 - 0
packages/editor/src/__test-utils__/index.ts

@@ -0,0 +1,64 @@
+import type { EditorView } from "prosemirror-view"
+import { TextSelection } from "prosemirror-state"
+import { vi } from "vitest"
+import { contentToDoc } from "@/lib/content-model"
+import { schema } from "@/lib/schema"
+
+/**
+ * Deterministic sequential ID generator for tests.
+ * Usage: `const ids = stableId("block")` → `ids()` → `"block-0"`, `"block-1"`, ...
+ */
+export function stableId(prefix: string): () => string {
+  let n = 0
+  return () => `${prefix}-${n++}`
+}
+
+/**
+ * Create a minimal KeyboardEvent-like object for testing keyboard handlers.
+ */
+export function createMockKeyEvent(
+  key: string,
+  modifiers: Record<string, boolean> = {},
+): KeyboardEvent {
+  return {
+    key,
+    altKey: modifiers.alt ?? false,
+    ctrlKey: modifiers.ctrl ?? false,
+    metaKey: modifiers.meta ?? false,
+    shiftKey: modifiers.shift ?? modifiers.shiftKey ?? false,
+  } as KeyboardEvent
+}
+
+/**
+ * Create a mock EditorView with a document from plain text content.
+ * Only suitable for testing keyboard handlers that inspect
+ * state.doc, state.selection, coordsAtPos, dispatch, and endOfTextblock.
+ */
+export function createMockView(content: string, cursorPos: number) {
+  const doc = contentToDoc(content)
+  const selection = TextSelection.create(doc, cursorPos)
+
+  const endOfTextblock = (dir: string) => {
+    if (dir === "up") return cursorPos <= 1
+    if (dir === "down") return cursorPos >= doc.content.size - 2
+    return false
+  }
+
+  return {
+    state: { doc, selection },
+    coordsAtPos: () => ({ left: 100, top: 200 }),
+    dispatch: vi.fn(),
+    endOfTextblock,
+  } as unknown as EditorView
+}
+
+/**
+ * Create a ProseMirror doc node with a single paragraph containing text.
+ */
+export function makeParaDoc(content: string) {
+  const textNode = content.length > 0 ? schema.text(content) : undefined
+  const para = textNode
+    ? schema.node("paragraph", {}, [textNode])
+    : schema.node("paragraph")
+  return schema.node("doc", {}, [para])
+}

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

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import type { EditorView } from "prosemirror-view"
-import { computed, nextTick, reactive, type Ref, ref, watch } from "vue"
+import { TextSelection } from "prosemirror-state"
+import { computed, nextTick, onMounted, reactive, type Ref, ref, watch } from "vue"
 import EditorBlock from "@/components/EditorBlock.vue"
 import EditorInsertionZone from "@/components/EditorInsertionZone.vue"
 import {
@@ -67,6 +68,8 @@ const props = withDefaults(
     resolvedRefs?: Set<string>
     resolvedRefsVersion?: number
     resolveAsset?: (file: File) => Promise<string>
+    /** Enables window.__testUtils__ for Playwright E2E tests */
+    testMode?: boolean
   }>(),
   {
     focused: false,
@@ -756,6 +759,60 @@ function onZoneActivate(index: number, content: string) {
   }
 }
 
+// ── E2E test mode (dev only) — injects window.__testUtils__ ───────
+// The test utilities access block internals directly via blockRefs and
+// blocks.value. In production (testMode=false), this entire block is
+// tree-shaken since the prop is never true.
+onMounted(() => {
+  if (!props.testMode) return
+  window.__testUtils__ = {
+    selectText(blockId, from, to) {
+      const api = blockRefs.get(blockId) as any
+      if (!api?.view) return
+      const v = api.view as EditorView
+      v.focus()
+      v.dispatch(
+        v.state.tr.setSelection(TextSelection.create(v.state.doc, from, to)),
+      )
+    },
+    getBlockIds() {
+      return Array.from(blockRefs.keys())
+    },
+    insertText(blockId, text) {
+      const api = blockRefs.get(blockId) as any
+      if (!api?.view) return
+      const v = api.view as EditorView
+      v.focus()
+      v.dispatch(v.state.tr.insertText(text))
+    },
+    getContent() {
+      return serializeBlocks(blocks.value)
+    },
+    toggleBlur() {
+      return new Promise<void>((resolve) => {
+        const btn = document.createElement("button")
+        btn.style.position = "fixed"
+        btn.style.top = "0"
+        btn.style.left = "0"
+        document.body.appendChild(btn)
+        btn.focus()
+        requestAnimationFrame(() => {
+          document.body.removeChild(btn)
+          resolve()
+        })
+      })
+    },
+    getParseTree() {
+      const firstApi = blockRefs.values().next().value as any
+      if (!firstApi?.view) return ""
+      return (firstApi.view as EditorView).state.doc.toString()
+    },
+    setTimeSource(fakeMs) {
+      ;(window as any).__testTimeSource = fakeMs
+    },
+  }
+})
+
 defineExpose({
   undo,
   redo,
@@ -808,6 +865,7 @@ defineExpose({
       <div
         class="flex items-start group/block"
         :data-block-index="i"
+        :data-block-id="block.id"
       >
       <div
         class="flex items-center justify-end min-h-7 select-none"
@@ -816,6 +874,7 @@ defineExpose({
         <div
           draggable="true"
           class="flex items-center justify-center cursor-grab active:cursor-grabbing rounded transition-all"
+          data-drag-handle
           :class="dragState?.blockId === block.id ? 'size-7 text-(--ui-primary)' : 'size-6 text-(--ui-text-muted) group-hover/block:text-(--ui-primary)'"
           @dragstart="onDragStart(i, block.id, block.content, block.depth, $event)"
           @dragend="onDragEnd"

+ 1 - 0
packages/editor/src/components/EditorSuggestionMenu.vue

@@ -156,6 +156,7 @@ defineExpose({ forwardKey })
             :role="'option'"
             :aria-selected="getGlobalIndex(gIdx, iIdx) === activeIndex ? 'true' : 'false'"
             :data-highlighted="getGlobalIndex(gIdx, iIdx) === activeIndex ? '' : undefined"
+            :data-menu-item-index="getGlobalIndex(gIdx, iIdx)"
             :class="[
               'flex items-center gap-2.5 px-3 py-2 cursor-pointer text-sm transition-colors',
               item.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-(--ui-bg-elevated)/60',

+ 16 - 0
packages/editor/src/components/EditorToolbar.vue

@@ -136,6 +136,7 @@ function cancelPrompt() {
           variant="ghost"
           size="sm"
           :disabled="!canUndo"
+          data-toolbar-action="undo"
           @click="undo?.()"
         />
       </UTooltip>
@@ -146,6 +147,7 @@ function cancelPrompt() {
           variant="ghost"
           size="sm"
           :disabled="!canRedo"
+          data-toolbar-action="redo"
           @click="redo?.()"
         />
       </UTooltip>
@@ -163,6 +165,7 @@ function cancelPrompt() {
           variant="ghost"
           size="sm"
           :disabled="!handlers"
+          data-toolbar-action="heading"
         />
       </UDropdownMenu>
       <UButton
@@ -175,6 +178,7 @@ function cancelPrompt() {
         :active="currentTask !== null"
         :disabled="!handlers"
         :aria-pressed="currentTask !== null ? 'true' : 'false'"
+          data-toolbar-action="task"
         @click="handlers?.toggleTask()"
       />
     </div>
@@ -194,6 +198,7 @@ function cancelPrompt() {
           :active="active.bold"
           :disabled="!handlers"
           :aria-pressed="active.bold ? 'true' : 'false'"
+          data-toolbar-action="bold"
           @click="handlers?.toggleBold()"
         />
       </UTooltip>
@@ -208,6 +213,7 @@ function cancelPrompt() {
           :active="active.italic"
           :disabled="!handlers"
           :aria-pressed="active.italic ? 'true' : 'false'"
+          data-toolbar-action="italic"
           @click="handlers?.toggleItalic()"
         />
       </UTooltip>
@@ -222,6 +228,7 @@ function cancelPrompt() {
           :active="active.code"
           :disabled="!handlers"
           :aria-pressed="active.code ? 'true' : 'false'"
+          data-toolbar-action="code"
           @click="handlers?.toggleCode()"
         />
       </UTooltip>
@@ -236,6 +243,7 @@ function cancelPrompt() {
           :active="active.strikethrough"
           :disabled="!handlers"
           :aria-pressed="active.strikethrough ? 'true' : 'false'"
+          data-toolbar-action="strikethrough"
           @click="handlers?.toggleStrikethrough()"
         />
       </UTooltip>
@@ -250,6 +258,7 @@ function cancelPrompt() {
           :active="active.highlight"
           :disabled="!handlers"
           :aria-pressed="active.highlight ? 'true' : 'false'"
+          data-toolbar-action="highlight"
           @click="handlers?.toggleHighlight()"
         />
       </UTooltip>
@@ -264,6 +273,7 @@ function cancelPrompt() {
           :active="active.inlineMath"
           :disabled="!handlers"
           :aria-pressed="active.inlineMath ? 'true' : 'false'"
+          data-toolbar-action="inline-math"
           @click="handlers?.insertInlineMath()"
         />
       </UTooltip>
@@ -280,6 +290,7 @@ function cancelPrompt() {
           variant="ghost"
           size="sm"
           :disabled="!handlers"
+          data-toolbar-action="link"
           @click="handlers?.insertLink()"
         />
       </UTooltip>
@@ -293,6 +304,7 @@ function cancelPrompt() {
           size="sm"
           :active="active.inlineMath"
           :disabled="!handlers"
+          data-toolbar-action="inline-math-insert"
           @click="handlers?.insertInlineMath()"
         />
       </UTooltip>
@@ -303,6 +315,7 @@ function cancelPrompt() {
           variant="ghost"
           size="sm"
           :disabled="!handlers"
+          data-toolbar-action="block-math"
           @click="handlers?.insertBlockMath()"
         />
       </UTooltip>
@@ -319,6 +332,7 @@ function cancelPrompt() {
           variant="ghost"
           size="sm"
           :disabled="!handlers"
+          data-toolbar-action="page-ref"
           @click="openPrompt('page')"
         />
       </UTooltip>
@@ -329,6 +343,7 @@ function cancelPrompt() {
           variant="ghost"
           size="sm"
           :disabled="!handlers"
+          data-toolbar-action="block-ref"
           @click="openPrompt('block')"
         />
       </UTooltip>
@@ -339,6 +354,7 @@ function cancelPrompt() {
           variant="ghost"
           size="sm"
           :disabled="!handlers"
+          data-toolbar-action="tag"
           @click="openPrompt('tag')"
         />
       </UTooltip>

+ 1 - 39
packages/editor/src/components/__tests__/block-keyboard.test.ts

@@ -1,50 +1,12 @@
 import { TextSelection } from "prosemirror-state"
-import type { EditorView } from "prosemirror-view"
 import { describe, expect, it, vi } from "vitest"
+import { createMockKeyEvent, createMockView } from "@/__test-utils__"
 import {
   type BlockKeyboardHandlers,
   createBlockKeyboardHandler,
 } from "@/composables/useBlockKeyboardHandlers"
 import { contentToDoc } from "@/lib/content-model"
 
-function createMockKeyEvent(
-  key: string,
-  modifiers: Record<string, boolean> = {},
-): KeyboardEvent {
-  return {
-    key,
-    altKey: modifiers.alt ?? false,
-    ctrlKey: modifiers.ctrl ?? false,
-    metaKey: modifiers.meta ?? false,
-    shiftKey: modifiers.shift ?? modifiers.shiftKey ?? false,
-  } as KeyboardEvent
-}
-
-function createMockView(content: string, cursorPos: number) {
-  const doc = contentToDoc(content)
-  const selection = TextSelection.create(doc, cursorPos)
-
-  const endOfTextblock = (dir: string) => {
-    if (dir === "up") {
-      return cursorPos <= 1
-    }
-    if (dir === "down") {
-      return cursorPos >= doc.content.size - 2
-    }
-    return false
-  }
-
-  return {
-    state: {
-      doc,
-      selection,
-    },
-    coordsAtPos: () => ({ left: 100, top: 200 }),
-    dispatch: vi.fn(),
-    endOfTextblock,
-  } as unknown as EditorView
-}
-
 function createTestHandlers(): {
   handlers: BlockKeyboardHandlers
   emit: ReturnType<typeof vi.fn>

+ 1 - 5
packages/editor/src/components/__tests__/editor-integration.test.ts

@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"
 import { ref } from "vue"
 import { EditorState } from "prosemirror-state"
 import { EditorView } from "prosemirror-view"
+import { stableId } from "@/__test-utils__"
 import type { PatternOpenPayload } from "@/composables/usePatternPlugin"
 import {
   buildMentionGroups,
@@ -471,11 +472,6 @@ describe("drag reorder", () => {
   })
 })
 
-function stableId(prefix: string): () => string {
-  let n = 0
-  return () => `${prefix}-${n++}`
-}
-
 describe("suggestion menu selection", () => {
   it("slash command Heading 1 inserts # markdown syntax", () => {
     const respond = vi.fn()

+ 1 - 0
packages/editor/src/composables/useCodeBlockView.ts

@@ -189,6 +189,7 @@ export class CodeBlockView implements NodeView {
 
     this.dom = document.createElement("div")
     this.dom.className = "cm-code-block"
+    this.dom.setAttribute("data-codeblock-editor", "")
 
     const header = document.createElement("div")
     header.className = "cm-code-block-header"

+ 1 - 8
packages/editor/src/lib/__tests__/auto-close-plugin.test.ts

@@ -2,6 +2,7 @@ import type { Node } from "prosemirror-model"
 import { EditorState, TextSelection } from "prosemirror-state"
 import { EditorView } from "prosemirror-view"
 import { afterEach, beforeEach, describe, expect, it } from "vitest"
+import { makeParaDoc } from "@/__test-utils__"
 import {
   autoClosePlugin,
   createPairRule,
@@ -50,14 +51,6 @@ function createPlugin() {
   ])
 }
 
-function makeParaDoc(content: string) {
-  const textNode = content.length > 0 ? schema.text(content) : undefined
-  const para = textNode
-    ? schema.node("paragraph", {}, [textNode])
-    : schema.node("paragraph")
-  return schema.node("doc", {}, [para])
-}
-
 function getView(content: string, cursorOffset?: number) {
   const doc = makeParaDoc(content)
   const pos = 1 + (cursorOffset ?? content.length)

+ 1 - 8
packages/editor/src/lib/__tests__/editor-operations.test.ts

@@ -1,4 +1,5 @@
 import { describe, expect, it, vi } from "vitest"
+import { stableId } from "@/__test-utils__"
 import type { EditorBlockData } from "@/lib/block-parser"
 import {
   deleteIfEmpty,
@@ -15,14 +16,6 @@ function block(overrides: Partial<EditorBlockData> & { content: string }): Edito
   return { id: "auto", depth: 0, ...overrides }
 }
 
-/**
- * Return a stable ID generator for deterministic tests.
- */
-function stableId(prefix: string): () => string {
-  let n = 0
-  return () => `${prefix}-${n++}`
-}
-
 // ── splitBlocks ───────────────────────────────────────────────────
 
 describe("splitBlocks", () => {

+ 1 - 8
packages/editor/src/lib/__tests__/formatting.test.ts

@@ -1,6 +1,7 @@
 import { EditorState, TextSelection } from "prosemirror-state"
 import { EditorView } from "prosemirror-view"
 import { afterEach, beforeEach, describe, expect, it } from "vitest"
+import { makeParaDoc } from "@/__test-utils__"
 import {
   insertBlockMath,
   insertBlockRef,
@@ -32,14 +33,6 @@ afterEach(() => {
   views = []
 })
 
-function makeParaDoc(content: string) {
-  const textNode = content.length > 0 ? schema.text(content) : undefined
-  const para = textNode
-    ? schema.node("paragraph", {}, [textNode])
-    : schema.node("paragraph")
-  return schema.node("doc", {}, [para])
-}
-
 function getView(content: string, cursorOffset?: number) {
   const doc = makeParaDoc(content)
   const pos = 1 + (cursorOffset ?? content.length)

+ 1 - 0
packages/editor/vitest.config.ts

@@ -12,5 +12,6 @@ export default defineConfig({
   test: {
     environment: "jsdom",
     setupFiles: ["./vitest.setup.ts"],
+    exclude: ["e2e/**", "node_modules/**"],
   },
 })

+ 38 - 0
pnpm-lock.yaml

@@ -197,6 +197,9 @@ importers:
       '@nuxt/ui':
         specifier: ^4.7.1
         version: 4.7.1(@internationalized/[email protected])(@internationalized/[email protected])(@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))(@tiptap/[email protected]([email protected])([email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected])([email protected])([email protected]([email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))([email protected](@vue/[email protected])([email protected]([email protected])([email protected]([email protected])))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))([email protected]([email protected])))([email protected]([email protected]))([email protected])([email protected])
+      '@playwright/test':
+        specifier: ^1.61.0
+        version: 1.61.0
       '@tsconfig/node24':
         specifier: ^24.0.4
         version: 24.0.4
@@ -1788,6 +1791,11 @@ packages:
     resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
     engines: {node: '>=14'}
 
+  '@playwright/[email protected]':
+    resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==}
+    engines: {node: '>=18'}
+    hasBin: true
+
   '@polka/[email protected]':
     resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
 
@@ -3719,6 +3727,11 @@ packages:
     resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
     engines: {node: '>= 0.8'}
 
+  [email protected]:
+    resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+
   [email protected]:
     resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
     engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -4573,6 +4586,16 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
 
+  [email protected]:
+    resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  [email protected]:
+    resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==}
+    engines: {node: '>=18'}
+    hasBin: true
+
   [email protected]:
     resolution: {integrity: sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==}
     engines: {node: ^18.12 || ^20.9 || >=22.0}
@@ -8005,6 +8028,10 @@ snapshots:
   '@pkgjs/[email protected]':
     optional: true
 
+  '@playwright/[email protected]':
+    dependencies:
+      playwright: 1.61.0
+
   '@polka/[email protected]': {}
 
   '@poppinss/[email protected]':
@@ -9955,6 +9982,9 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]:
+    optional: true
+
   [email protected]:
     optional: true
 
@@ -11010,6 +11040,14 @@ snapshots:
       exsolve: 1.0.8
       pathe: 2.0.3
 
+  [email protected]: {}
+
+  [email protected]:
+    dependencies:
+      playwright-core: 1.61.0
+    optionalDependencies:
+      fsevents: 2.3.2
+
   [email protected]([email protected]):
     dependencies:
       postcss: 8.5.15