diff --git a/.github/workflows/ci-behavior.yml b/.github/workflows/ci-behavior.yml new file mode 100644 index 000000000..983a76d3e --- /dev/null +++ b/.github/workflows/ci-behavior.yml @@ -0,0 +1,83 @@ +name: Behavior Tests + +permissions: + contents: read + +on: + pull_request: + branches: [main] + paths: + - 'packages/superdoc/**' + - 'packages/layout-engine/**' + - 'packages/super-editor/**' + - 'packages/ai/**' + - 'packages/word-layout/**' + - 'packages/preset-geometry/**' + - 'tests/behavior/**' + - 'shared/**' + - '!**/*.md' + workflow_dispatch: + +concurrency: + group: ci-behavior-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install dependencies + run: pnpm install --ignore-scripts + + - name: Build SuperDoc + run: pnpm build + + - name: Get Playwright version + id: pw + run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT + working-directory: tests/behavior + + - name: Cache Playwright browsers + uses: actions/cache@v5 + id: pw-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }} + + - name: Install Playwright browsers + if: steps.pw-cache.outputs.cache-hit != 'true' + run: pnpm exec playwright install --with-deps chromium firefox webkit + working-directory: tests/behavior + + - name: Install Playwright system deps + if: steps.pw-cache.outputs.cache-hit == 'true' + run: pnpm exec playwright install-deps chromium firefox webkit + working-directory: tests/behavior + + - name: Run behavior tests (shard ${{ matrix.shard }}/3) + run: pnpm exec playwright test --shard=${{ matrix.shard }}/3 + working-directory: tests/behavior + + validate: + if: always() + needs: [test] + runs-on: ubuntu-latest + steps: + - name: Check results + run: | + if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then + exit 1 + fi diff --git a/package.json b/package.json index d4908ab93..3b3c49ef2 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,12 @@ "test:editor": "vitest run --root ./packages/super-editor", "test:superdoc": "vitest run --root ./packages/superdoc", "test:cov": "node scripts/test-cov.mjs", + "test:behavior": "pnpm --filter @superdoc-testing/behavior test", + "test:behavior:trace": "pnpm --filter @superdoc-testing/behavior test:trace", + "test:behavior:screenshots": "pnpm --filter @superdoc-testing/behavior test:screenshots", + "test:behavior:headed": "pnpm --filter @superdoc-testing/behavior test:headed", + "test:behavior:ui": "pnpm --filter @superdoc-testing/behavior test:ui", + "test:behavior:html": "pnpm --filter @superdoc-testing/behavior test:html", "type-check": "tsc -b tsconfig.references.json", "type-check:force": "tsc -b --force tsconfig.references.json", "rebuild:types": "pnpm run --filter=@superdoc/common --filter=@superdoc/word-layout --filter=@superdoc/contracts --filter=@superdoc/geometry-utils --filter=@superdoc/style-engine --filter=@superdoc/pm-adapter --filter=@superdoc/measuring-dom --filter=@superdoc/layout-engine --filter=@superdoc/painter-dom --filter=@superdoc/layout-bridge build", diff --git a/packages/document-api/src/types/inline.types.ts b/packages/document-api/src/types/inline.types.ts index 1cb68b1ec..36f14fc9e 100644 --- a/packages/document-api/src/types/inline.types.ts +++ b/packages/document-api/src/types/inline.types.ts @@ -22,6 +22,7 @@ export interface RunProperties { bold?: boolean; italic?: boolean; underline?: boolean; + strike?: boolean; font?: string; size?: number; color?: string; diff --git a/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.test.ts b/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.test.ts index 15b29b368..d80f628e4 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.test.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.test.ts @@ -294,6 +294,72 @@ describe('mapNodeInfo — inline nodes', () => { language: 'en-US', }); }); + + it('maps run from OOXML-style boolean tokens and fallback fields', () => { + const result = mapNodeInfo( + makeInlineCandidate('run', { + nodeAttrs: { + runProperties: { + bold: { 'w:val': '0' }, + italic: 'on', + underline: { 'w:val': 'none' }, + dstrike: { val: 'true' }, + fontFamily: { hAnsi: 'Cambria' }, + fontSize: '16pt', + color: { 'w:val': '00FF00' }, + highlight: { 'w:fill': 'FF00AA' }, + styleId: 'Emphasis', + lang: 'fr-CA', + }, + }, + }), + ); + + expect(result.nodeType).toBe('run'); + expect(result.properties).toMatchObject({ + bold: false, + italic: true, + underline: false, + strike: true, + font: 'Cambria', + size: 16, + color: '00FF00', + highlight: '#FF00AA', + styleId: 'Emphasis', + language: 'fr-CA', + }); + }); + + it('maps run highlight "none" to transparent and keeps explicit false strike', () => { + const result = mapNodeInfo( + makeInlineCandidate('run', { + nodeAttrs: { + runProperties: { + strike: { val: 'off' }, + u: { val: 'single' }, + rFonts: { ascii: 'Calibri' }, + size: '24', + color: '112233', + highlight: { val: 'none' }, + rStyle: 'Strong', + lang: { val: 'de-DE' }, + }, + }, + }), + ); + + expect(result.nodeType).toBe('run'); + expect(result.properties).toMatchObject({ + strike: false, + underline: true, + font: 'Calibri', + size: 24, + color: '112233', + highlight: 'transparent', + styleId: 'Strong', + language: 'de-DE', + }); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.ts b/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.ts index bcbfbebba..74b44555e 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/node-info-mapper.ts @@ -289,48 +289,142 @@ function mapFootnoteRefNode(candidate: InlineCandidate): FootnoteRefNodeInfo { return { nodeType: 'footnoteRef', kind: 'inline', properties }; } +function parseBooleanToken(value: string): boolean | undefined { + const normalized = value.trim().toLowerCase(); + if (!normalized) return undefined; + if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'none') return false; + if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'single') return true; + return undefined; +} + +function resolveBooleanLike(value: unknown): boolean | undefined { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + if (typeof value === 'string') return parseBooleanToken(value); + if (value && typeof value === 'object') { + const record = value as Record; + const explicit = resolveBooleanLike( + record.val ?? record.value ?? record.type ?? record['w:val'] ?? record['w:value'], + ); + if (explicit != null) return explicit; + return true; + } + return undefined; +} + +function resolveUnderlineLike(values: unknown[]): boolean | undefined { + for (const value of values) { + if (value == null) continue; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (!normalized) continue; + return normalized !== 'none' && normalized !== 'false' && normalized !== '0'; + } + const resolved = resolveBooleanLike(value); + if (resolved != null) return resolved; + } + return undefined; +} + +function resolveColorValue(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (value && typeof value === 'object') { + const record = value as Record; + if (typeof record.val === 'string') return record.val; + if (typeof record.value === 'string') return record.value; + if (typeof record['w:val'] === 'string') return record['w:val'] as string; + } + return undefined; +} + +function resolveHighlightValue(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (value && typeof value === 'object') { + const record = value as Record; + const highlightValue = record.val ?? record.value ?? record['w:val']; + if (typeof highlightValue === 'string') { + const normalized = highlightValue.trim(); + if (!normalized) return undefined; + if (normalized.toLowerCase() === 'none') return 'transparent'; + return normalized; + } + + const fill = record.fill ?? record['w:fill']; + if (typeof fill === 'string') { + const normalized = fill.trim(); + if (!normalized || normalized.toLowerCase() === 'auto') return undefined; + return normalized.startsWith('#') ? normalized : `#${normalized}`; + } + } + return undefined; +} + +function resolveFontValue(runProperties: Record): string | undefined { + const fromRFonts = runProperties.rFonts; + if (fromRFonts && typeof fromRFonts === 'object') { + const fonts = fromRFonts as Record; + const selected = fonts.ascii ?? fonts.hAnsi ?? fonts.eastAsia ?? fonts.cs; + if (typeof selected === 'string') return selected; + } + + const fromFontFamily = runProperties.fontFamily; + if (typeof fromFontFamily === 'string') return fromFontFamily; + if (fromFontFamily && typeof fromFontFamily === 'object') { + const fonts = fromFontFamily as Record; + const selected = fonts.ascii ?? fonts.hAnsi ?? fonts.eastAsia ?? fonts.cs; + if (typeof selected === 'string') return selected; + } + + return undefined; +} + +function resolveFontSizeValue(runProperties: Record): number | undefined { + const candidate = runProperties.sz ?? runProperties.size ?? runProperties.fontSize; + if (typeof candidate === 'number') return candidate; + if (typeof candidate === 'string') { + const parsed = Number.parseFloat(candidate); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + function mapRunNode(candidate: InlineCandidate): RunNodeInfo { - const attrs = (candidate.node?.attrs ?? candidate.attrs ?? {}) as { - runProperties?: { - bold?: boolean; - italic?: boolean; - underline?: { val?: string } | boolean; - rFonts?: { ascii?: string; hAnsi?: string; eastAsia?: string; cs?: string }; - sz?: number; - color?: { val?: string }; - highlight?: string; - rStyle?: string; - lang?: { val?: string }; - u?: { val?: string }; - } | null; - }; - const runProperties = attrs.runProperties ?? undefined; - const underline = Boolean( - runProperties?.underline === true || - runProperties?.u?.val === 'single' || - (typeof runProperties?.underline === 'object' && - typeof runProperties?.underline?.val === 'string' && - runProperties.underline.val !== 'none'), - ); - const font = - runProperties?.rFonts?.ascii ?? - runProperties?.rFonts?.hAnsi ?? - runProperties?.rFonts?.eastAsia ?? - runProperties?.rFonts?.cs; + const attrs = (candidate.node?.attrs ?? candidate.attrs ?? {}) as { runProperties?: Record | null }; + const runProperties = + attrs.runProperties && typeof attrs.runProperties === 'object' + ? (attrs.runProperties as Record) + : undefined; + const underline = resolveUnderlineLike([runProperties?.underline, runProperties?.u]); + const strike = resolveBooleanLike(runProperties?.strike) ?? resolveBooleanLike(runProperties?.dstrike) ?? undefined; + const languageRaw = runProperties?.lang; + const language = + typeof languageRaw === 'string' + ? languageRaw + : languageRaw && + typeof languageRaw === 'object' && + typeof (languageRaw as Record).val === 'string' + ? ((languageRaw as Record).val as string) + : undefined; return { nodeType: 'run', kind: 'inline', properties: { - bold: runProperties?.bold ?? undefined, - italic: runProperties?.italic ?? undefined, - underline: underline || undefined, - font: typeof font === 'string' ? font : undefined, - size: typeof runProperties?.sz === 'number' ? runProperties.sz : undefined, - color: runProperties?.color?.val ?? undefined, - highlight: runProperties?.highlight ?? undefined, - styleId: runProperties?.rStyle ?? undefined, - language: runProperties?.lang?.val ?? undefined, + bold: resolveBooleanLike(runProperties?.bold) ?? undefined, + italic: resolveBooleanLike(runProperties?.italic) ?? undefined, + underline: underline ?? undefined, + strike, + font: runProperties ? resolveFontValue(runProperties) : undefined, + size: runProperties ? resolveFontSizeValue(runProperties) : undefined, + color: resolveColorValue(runProperties?.color), + highlight: resolveHighlightValue(runProperties?.highlight), + styleId: + typeof runProperties?.rStyle === 'string' + ? runProperties.rStyle + : typeof runProperties?.styleId === 'string' + ? runProperties.styleId + : undefined, + language, }, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e57a4ef0c..51d1337db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1400,6 +1400,22 @@ importers: shared/url-validation: {} + tests/behavior: + dependencies: + '@superdoc/document-api': + specifier: workspace:* + version: link:../../packages/document-api + superdoc: + specifier: workspace:* + version: link:../../packages/superdoc + devDependencies: + '@playwright/test': + specifier: 'catalog:' + version: 1.58.1 + vite: + specifier: npm:rolldown-vite@7.3.1 + version: rolldown-vite@7.3.1(@types/node@22.19.8)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + tests/visual: dependencies: superdoc: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 80d987fb4..d521a0a45 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - e2e-tests - e2e-tests/templates/vue - tests/visual + - tests/behavior - apps/* - packages/**/* - shared/* diff --git a/tests/behavior/.gitignore b/tests/behavior/.gitignore new file mode 100644 index 000000000..9e475bf50 --- /dev/null +++ b/tests/behavior/.gitignore @@ -0,0 +1,3 @@ +playwright-report/ +test-results/ +test-data/ diff --git a/tests/behavior/AGENTS.md b/tests/behavior/AGENTS.md new file mode 100644 index 000000000..a3a0c0da1 --- /dev/null +++ b/tests/behavior/AGENTS.md @@ -0,0 +1,261 @@ +# Writing Behavior Tests — Agent Guide + +## Explicit: Run and Debug with Playwright CLI + +While creating tests, agents are encouraged to run the harness and tests directly with Playwright CLI to iterate quickly, inspect behavior, and debug issues in real time. Use modes like headed/UI/trace as needed (`playwright test --headed`, `playwright test --ui`, `TRACE=1 playwright test`). + +## Core Rule + +SuperDoc uses a custom rendering pipeline (DomPainter), NOT ProseMirror's DOM output. +**Prefer Document API assertions (`editor.doc.*`) for content, structure, and formatting.** +Use ProseMirror state only when the behavior under test is selection-specific or not yet exposed by document-api. + +## Imports + +Always import `test` and `expect` from the fixture, never from `@playwright/test`: + +```ts +import { test, expect } from '../../fixtures/superdoc.js'; +``` + +Import `SuperDocFixture` as a type when writing shared helpers: + +```ts +import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; +``` + +## Test Configuration + +Set harness options at the file level with `test.use()`. Every toolbar test needs this: + +```ts +test.use({ config: { toolbar: 'full', showSelection: true } }); +``` + +Only set what you need. Defaults are: `layout: true`, `showCaret: false`, `showSelection: false`, no toolbar. + +## waitForStable() — and when it's not enough + +Call `waitForStable()` after any interaction that mutates the DOM. This includes: +- `type()`, `newLine()`, `press()`, `bold()`, `italic()`, `underline()` +- `executeCommand()` +- Toolbar button clicks +- `setTextSelection()` +- `setDocumentMode()` + +```ts +await superdoc.type('Hello'); +await superdoc.waitForStable(); // always before assertions or next interaction +``` + +Do NOT write your own settle/wait helpers. Use `superdoc.waitForStable()` everywhere. + +### When waitForStable() is NOT enough + +`waitForStable()` uses a MutationObserver that resolves after 50ms of DOM silence. This +works for most interactions, but **UI components with animations (dropdowns, modals, popups) +can have brief pauses between mutation bursts** that cause `waitForStable()` to return too +early — before the animation finishes. + +For these cases, **wait for the specific element state change** instead of (or in addition +to) `waitForStable()`: + +```ts +// Bad — waitForStable() may return while the dropdown is still closing +await page.locator('[data-item="btn-link-apply"]').click(); +await superdoc.waitForStable(); +// dropdown may still be visible here! + +// Good — wait for the specific UI element to disappear +await page.locator('[data-item="btn-link-apply"]').click(); +await page.locator('.link-input-ctn').waitFor({ state: 'hidden', timeout: 5000 }); +await superdoc.waitForStable(); +``` + +This applies to any component that animates open/closed: link dropdowns, color pickers, +table action menus, document mode dropdowns, etc. + +### Stale positions after mark changes + +ProseMirror may re-index node positions after marks are applied or removed. Always +re-find text positions after applying marks — never reuse a position from before the change: + +```ts +const pos = await superdoc.findTextPos('website'); +await superdoc.setTextSelection(pos, pos + 'website'.length); +await applyLink(superdoc, 'https://example.com'); + +// Bad — reusing stale positions for another selection can target the wrong span +await superdoc.setTextSelection(pos, pos + 'website'.length); + +// Good — re-find after the mark was applied +const freshPos = await superdoc.findTextPos('website'); +await superdoc.setTextSelection(freshPos, freshPos + 'website'.length); + +// Prefer text-based assertions so no position refresh is needed for assertions +await superdoc.assertTextHasMarks('website', ['link']); +``` + +This is mainly relevant for selection workflows. For formatting assertions, prefer text-based fixture helpers +(`assertTextHasMarks`, `assertTextMarkAttrs`) so tests do not depend on PM positions. + +## Selecting Text + +Use `findTextPos()` + `setTextSelection()` for deterministic selection. Never rely on click +coordinates to select text — click positions are fragile across browsers and viewport sizes. + +```ts +const pos = await superdoc.findTextPos('target text'); +await superdoc.setTextSelection(pos, pos + 'target text'.length); +await superdoc.waitForStable(); +``` + +## Asserting Marks and Styles + +Use document-api-backed text assertions from the fixture, not DOM inspection: + +```ts +// Good — text-targeted assertions (document-api only) +await superdoc.assertTextHasMarks('target text', ['bold', 'italic']); +await superdoc.assertTextMarkAttrs('target text', 'textStyle', { fontFamily: 'Georgia' }); +await superdoc.assertTextMarkAttrs('target text', 'link', { href: 'https://example.com' }); + +// Bad — fragile, depends on DomPainter's rendering implementation +const el = superdoc.page.locator('.some-rendered-span'); +await expect(el).toHaveCSS('font-weight', '700'); +``` + +Exception: toolbar button state, element visibility, and CSS properties that ARE the +thing being tested (hover states, border styles) should use DOM assertions. + +## Toolbar Buttons + +Toolbar elements use `data-item` attributes: + +```ts +// Buttons +superdoc.page.locator('[data-item="btn-bold"]') +superdoc.page.locator('[data-item="btn-italic"]') +superdoc.page.locator('[data-item="btn-fontFamily"]') +superdoc.page.locator('[data-item="btn-color"]') +superdoc.page.locator('[data-item="btn-textAlign"]') +superdoc.page.locator('[data-item="btn-table"]') +superdoc.page.locator('[data-item="btn-link"]') +superdoc.page.locator('[data-item="btn-tableActions"]') +superdoc.page.locator('[data-item="btn-documentMode"]') + +// Dropdown options: append "-option" to the button's data-item +superdoc.page.locator('[data-item="btn-fontFamily-option"]').filter({ hasText: 'Georgia' }) +superdoc.page.locator('[data-item="btn-fontSize-option"]').filter({ hasText: '18' }) + +// Color swatches +superdoc.page.locator('.option[aria-label="red"]').first() + +// Active state +await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/active/); +``` + +Dropdown workflow: click the button to open, then click the option, with `waitForStable()` after each. + +## Tables + +DomPainter renders tables as flat divs, not `//
`. Use fixture assertions for +table structure (document-api only): + +```ts +// Insert via command, not toolbar (faster, more reliable) +await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false }); +await superdoc.waitForStable(); + +// Assert structure +await superdoc.assertTableExists(2, 2); + +// Navigate between cells +await superdoc.press('Tab'); // next cell +await superdoc.press('Shift+Tab'); // previous cell +``` + +`assertTableExists()` requires `window.editor.doc` in the behavior harness. + +## Using page.evaluate() + +For anything the fixture doesn't cover, use `superdoc.page.evaluate()` to run code in the +browser. The editor is on `window.editor` and the SuperDoc instance on `window.superdoc`. +If exposed, document-api is on `window.editor.doc`. + +```ts +const result = await superdoc.page.evaluate(() => { + const { state } = (window as any).editor; + // ... inspect PM state + return something; +}); +``` + +For commands: + +```ts +await superdoc.page.evaluate(() => { + (window as any).editor.commands.someCommand({ arg: 'value' }); +}); +``` + +Prefer `superdoc.executeCommand()` when possible — it includes a wait for `editor.commands` +to be available. + +## Snapshots + +`snapshot()` is a debug aid, not an assertion. It only captures when `SCREENSHOTS=1` is set. +Use it to mark key visual states in a test for the HTML report: + +```ts +await superdoc.snapshot('before bold'); // label describes the state +// ... apply bold ... +await superdoc.snapshot('after bold'); +``` + +## File Structure + +Group tests by feature area: + +``` +tests/ + toolbar/ toolbar button interactions + tables/ table-specific behavior (resize, structure) + sdt/ structured content (content controls) + helpers/ unit tests for shared helper functions +``` + +## Shared Setup Patterns + +Extract repeated setup into a helper function at the top of the file: + +```ts +async function typeAndSelect(superdoc: SuperDocFixture): Promise { + await superdoc.type('This is a sentence'); + await superdoc.waitForStable(); + const pos = await superdoc.findTextPos('is a sentence'); + await superdoc.setTextSelection(pos, pos + 'is a sentence'.length); + await superdoc.waitForStable(); + return pos; +} +``` + +Use `test.describe()` + `test.beforeEach()` when a group of tests shares identical setup. + +## Common Mistakes + +1. **Missing `waitForStable()`** — flaky assertions that sometimes pass, sometimes fail. +2. **Relying on `waitForStable()` for animated UI** — dropdowns, modals, and popups animate + open/closed. `waitForStable()` may return mid-animation. Wait for the specific element + state (e.g. `waitFor({ state: 'hidden' })`) instead. +3. **Using stale positions after mark changes** — PM re-indexes after marks are applied. + Always call `findTextPos()` again after applying/removing marks. +4. **Asserting DOM for content** — DomPainter's output differs from the editor model. + Prefer document-api fixture helpers for content/format assertions. +5. **Clicking to select text** — fragile across browsers. Use `findTextPos()` + `setTextSelection()`. +6. **Writing custom settle helpers** — use the fixture's `waitForStable()`. +7. **Importing from `@playwright/test`** — the fixture re-exports `test` and `expect` with + the `superdoc` fixture pre-wired. Only import types from `@playwright/test`. +8. **Forgetting `toolbar: 'full'`** — toolbar buttons won't exist without this config. +9. **Forgetting `showSelection: true`** — selection overlays are hidden by default; + tests that need to verify or interact with selection rects must opt in. diff --git a/tests/behavior/README.md b/tests/behavior/README.md new file mode 100644 index 000000000..d9b32753a --- /dev/null +++ b/tests/behavior/README.md @@ -0,0 +1,115 @@ +# Behavior Tests + +Playwright tests that run against a real SuperDoc instance in the browser (Chromium, Firefox, WebKit). + +## Setup + +```sh +pnpm install +pnpm --filter @superdoc-testing/behavior setup # install browser binaries +``` + +## Running + +```sh +pnpm test:behavior # all browsers, headless +pnpm test:behavior:ui # Playwright UI mode +pnpm test:behavior:html # run + open HTML report +pnpm test:behavior:headed # watch the browser +pnpm test:behavior -- --project=chromium # single browser +``` + +### Debugging flags + +Traces and screenshots are **off** by default for speed: + +```sh +pnpm test:behavior:trace # enable Playwright traces +pnpm test:behavior:screenshots # enable auto-screenshots + snapshot() captures +``` + +### CI sharding + +Split across runners with `--shard`: + +```sh +playwright test --shard=1/3 +playwright test --shard=2/3 +playwright test --shard=3/3 +``` + +## Writing a test + +### 1. Create a spec file + +``` +tests/ + toolbar/ + my-feature.spec.ts <-- group by feature area +``` + +### 2. Use the `superdoc` fixture + +```ts +import { test, expect } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +test('my feature works', async ({ superdoc }) => { + await superdoc.type('Hello world'); + await superdoc.waitForStable(); + + const pos = await superdoc.findTextPos('Hello'); + await superdoc.setTextSelection(pos, pos + 5); + await superdoc.bold(); + await superdoc.waitForStable(); + + await superdoc.assertTextHasMarks('Hello', ['bold']); + await superdoc.snapshot('bold applied'); // only captured when SCREENSHOTS=1 +}); +``` + +The fixture navigates to the harness, boots SuperDoc, and focuses the editor for you. + +### 3. Fixture config options + +Pass via `test.use({ config: { ... } })`: + +| Option | Default | Description | +|--------|---------|-------------| +| `layout` | `true` | Enable layout engine | +| `toolbar` | none | `'none'` \| `'full'` | +| `comments` | `'off'` | `'off'` \| `'on'` \| `'panel'` \| `'readonly'` | +| `trackChanges` | `false` | Show tracked changes | +| `showCaret` | `false` | Show caret (hidden by default to reduce flakiness) | +| `showSelection` | `false` | Show selection overlays | + +### 4. Key fixture methods + +**Interact:** +`type()`, `press()`, `newLine()`, `shortcut()`, `bold()`, `italic()`, `underline()`, `undo()`, `redo()`, `selectAll()`, `tripleClickLine()`, `clickOnLine()`, `setTextSelection()`, `executeCommand()`, `setDocumentMode()`, `loadDocument()`, `waitForStable()` + +**Assert:** +`assertTextContent()`, `assertTextContains()`, `assertLineText()`, `assertLineCount()`, `assertPageCount()`, `assertTextHasMarks()`, `assertTextLacksMarks()`, `assertTextMarkAttrs()`, `assertTextAlignment()`, `assertMarksAtPos()`, `assertMarkActive()`, `assertMarkAttrsAtPos()`, `assertTableExists()`, `assertElementExists()`, `assertElementVisible()`, `assertElementHidden()`, `assertElementCount()`, `assertSelection()`, `assertLinkExists()`, `assertTrackedChangeExists()`, `assertDocumentMode()` + +**Get (for custom assertions):** +`getTextContent()`, `getSelection()`, `getMarksAtPos()`, `getMarkAttrsAtPos()`, `findTextPos()` + +### 5. Loading .docx files + +Place fixtures in `test-data/` (gitignored) and use `loadDocument`: + +```ts +test('renders imported doc', async ({ superdoc }) => { + await superdoc.loadDocument('test-data/my-fixture.docx'); + await superdoc.assertTextContains('Expected content'); +}); +``` + +### 6. Tips + +- Call `waitForStable()` after interactions that mutate the DOM before making assertions. +- Use `findTextPos()` + `setTextSelection()` instead of clicking to select text — it's deterministic. +- Prefer text-based assertions (`assertTextHasMarks`, `assertTextMarkAttrs`, `assertTextAlignment`) to avoid PM position coupling. +- Use `executeCommand()` to call ProseMirror commands directly (e.g. `insertTable`). +- Access `superdoc.page` for any raw Playwright API when the fixture methods aren't enough. diff --git a/tests/behavior/fixtures/superdoc.ts b/tests/behavior/fixtures/superdoc.ts new file mode 100644 index 000000000..581ee3763 --- /dev/null +++ b/tests/behavior/fixtures/superdoc.ts @@ -0,0 +1,798 @@ +import { test as base, expect, type Page, type Locator } from '@playwright/test'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const HARNESS_URL = 'http://localhost:9990'; + +interface HarnessConfig { + layout?: boolean; + toolbar?: 'none' | 'full'; + comments?: 'off' | 'on' | 'panel' | 'readonly'; + trackChanges?: boolean; + showCaret?: boolean; + showSelection?: boolean; +} + +type DocumentMode = 'editing' | 'suggesting' | 'viewing'; + +type TextRange = { + blockId: string; + start: number; + end: number; +}; + +type InlineSpan = { + blockId: string; + start: number; + end: number; + properties: Record; +}; + +type DocTextSnapshot = { + ranges: TextRange[]; + blockAddress: unknown; + runs: InlineSpan[]; + hyperlinks: InlineSpan[]; +}; + +function buildHarnessUrl(config: HarnessConfig = {}): string { + const params = new URLSearchParams(); + if (config.layout !== undefined) params.set('layout', config.layout ? '1' : '0'); + if (config.toolbar) params.set('toolbar', config.toolbar); + if (config.comments) params.set('comments', config.comments); + if (config.trackChanges) params.set('trackChanges', '1'); + if (config.showCaret !== undefined) params.set('showCaret', config.showCaret ? '1' : '0'); + if (config.showSelection !== undefined) params.set('showSelection', config.showSelection ? '1' : '0'); + const qs = params.toString(); + return qs ? `${HARNESS_URL}?${qs}` : HARNESS_URL; +} + +async function waitForReady(page: Page, timeout = 30_000): Promise { + await page.waitForFunction(() => (window as any).superdocReady === true, null, { polling: 100, timeout }); +} + +async function waitForStable(page: Page, ms?: number): Promise { + if (ms !== undefined) { + await page.waitForTimeout(ms); + return; + } + + // Smart wait: let the current interaction trigger its effects (rAF), + // then wait until the DOM stops mutating for SETTLE_MS. + await page.evaluate(() => { + return new Promise((resolve) => { + requestAnimationFrame(() => { + const SETTLE_MS = 50; + const MAX_WAIT = 5_000; + let timer: ReturnType; + + const done = () => { + clearTimeout(timer); + observer.disconnect(); + resolve(); + }; + + const observer = new MutationObserver(() => { + clearTimeout(timer); + timer = setTimeout(done, SETTLE_MS); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + characterData: true, + }); + + // If nothing mutates within SETTLE_MS, we're already stable + timer = setTimeout(done, SETTLE_MS); + // Safety net — never block longer than MAX_WAIT + setTimeout(done, MAX_WAIT); + }); + }); + }); +} + +// --------------------------------------------------------------------------- +// SuperDoc fixture +// --------------------------------------------------------------------------- + +function createFixture(page: Page, editor: Locator, modKey: string) { + const normalizeHexColor = (value: unknown): string | null => { + if (typeof value !== 'string') return null; + const normalized = value.replace(/^#/, '').trim().toUpperCase(); + return normalized || null; + }; + + const matchesTextStyleAttr = (props: Record, key: string, expectedValue: unknown): boolean => { + if (key === 'fontFamily') { + return props.font === expectedValue; + } + + if (key === 'color') { + const expectedColor = normalizeHexColor(expectedValue); + return expectedColor ? normalizeHexColor(props.color) === expectedColor : false; + } + + if (key === 'fontSize') { + const parsed = + typeof expectedValue === 'number' + ? expectedValue + : Number.parseFloat(String(expectedValue).replace(/pt$/i, '')); + if (!Number.isFinite(parsed)) return false; + const sizeValue = props.size; + if (typeof sizeValue !== 'number') return false; + return [parsed, parsed * 2, parsed / 2].some((candidate) => Math.abs(sizeValue - candidate) < 0.01); + } + + return false; + }; + + const getTextContentFromDocApi = async (): Promise => + page.evaluate(() => { + const docApi = (window as any).editor?.doc; + if (!docApi?.getText) { + throw new Error('Document API is unavailable: expected editor.doc.getText().'); + } + return docApi.getText({}); + }); + + const getDocTextSnapshot = async (text: string, occurrence = 0): Promise => + page.evaluate( + ({ searchText, matchIndex }) => { + const docApi = (window as any).editor?.doc; + if (!docApi?.find) { + throw new Error('Document API is unavailable: expected editor.doc.find().'); + } + + const textResult = docApi.find({ + select: { type: 'text', pattern: searchText, mode: 'contains', caseSensitive: true }, + }); + const context = textResult?.context?.[matchIndex]; + if (!context?.address) return null; + + const ranges = (context.textRanges ?? []).map((range: any) => ({ + blockId: range.blockId, + start: range.range.start, + end: range.range.end, + })); + if (!ranges.length) return null; + + const toInlineSpans = (result: any): InlineSpan[] => + (result?.matches ?? []) + .map((address: any, i: number) => { + if (address?.kind !== 'inline') return null; + const { start, end } = address.anchor ?? {}; + if (!start || !end) return null; + return { + blockId: start.blockId, + start: start.offset, + end: end.offset, + properties: result.nodes?.[i]?.properties ?? {}, + }; + }) + .filter(Boolean); + + const runResult = docApi.find({ + select: { type: 'node', nodeType: 'run', kind: 'inline' }, + within: context.address, + includeNodes: true, + }); + + const hyperlinkResult = docApi.find({ + select: { type: 'node', nodeType: 'hyperlink', kind: 'inline' }, + within: context.address, + includeNodes: true, + }); + + return { + ranges, + blockAddress: context.address, + runs: toInlineSpans(runResult), + hyperlinks: toInlineSpans(hyperlinkResult), + } satisfies DocTextSnapshot; + }, + { searchText: text, matchIndex: occurrence }, + ); + + const overlapsRange = (span: InlineSpan, ranges: TextRange[]): boolean => + ranges.some((range) => { + if (range.blockId !== span.blockId) return false; + return Math.max(span.start, range.start) < Math.min(span.end, range.end); + }); + + const getDocMarksByText = async (text: string, occurrence = 0): Promise => { + const snapshot = await getDocTextSnapshot(text, occurrence); + if (!snapshot) return null; + + const marks = new Set(); + for (const run of snapshot.runs) { + if (!overlapsRange(run, snapshot.ranges)) continue; + if (run.properties.bold === true) marks.add('bold'); + if (run.properties.italic === true) marks.add('italic'); + if (run.properties.underline === true) marks.add('underline'); + if (run.properties.strike === true || run.properties.strikethrough === true) marks.add('strike'); + if (run.properties.highlight) marks.add('highlight'); + } + for (const link of snapshot.hyperlinks) { + if (overlapsRange(link, snapshot.ranges)) marks.add('link'); + } + + return [...marks]; + }; + + const getDocRunPropertiesByText = async ( + text: string, + occurrence = 0, + ): Promise> | null> => { + const snapshot = await getDocTextSnapshot(text, occurrence); + if (!snapshot) return null; + const runs = snapshot.runs.filter((run) => overlapsRange(run, snapshot.ranges)); + return runs.map((run) => run.properties); + }; + + const getDocLinkHrefsByText = async (text: string, occurrence = 0): Promise => { + const snapshot = await getDocTextSnapshot(text, occurrence); + if (!snapshot) return null; + + const hrefs = snapshot.hyperlinks + .filter((link) => overlapsRange(link, snapshot.ranges)) + .map((link) => link.properties.href) + .filter((href): href is string => typeof href === 'string'); + + return hrefs; + }; + const fixture = { + page, + + // ----- Interaction methods ----- + + async type(text: string) { + await editor.focus(); + await page.keyboard.type(text); + }, + + async press(key: string) { + await page.keyboard.press(key); + }, + + async newLine() { + await page.keyboard.press('Enter'); + }, + + async shortcut(key: string) { + await page.keyboard.press(`${modKey}+${key}`); + }, + + async bold() { + await page.keyboard.press(`${modKey}+b`); + }, + + async italic() { + await page.keyboard.press(`${modKey}+i`); + }, + + async underline() { + await page.keyboard.press(`${modKey}+u`); + }, + + async undo() { + await page.keyboard.press(`${modKey}+z`); + }, + + async redo() { + await page.keyboard.press(`${modKey}+Shift+z`); + }, + + async selectAll() { + await page.keyboard.press(`${modKey}+a`); + }, + + async tripleClickLine(lineIndex: number) { + const line = page.locator('.superdoc-line').nth(lineIndex); + await line.click({ clickCount: 3, timeout: 10_000 }); + }, + + async setDocumentMode(mode: DocumentMode) { + await page.evaluate((m) => { + const sd = (window as any).superdoc; + if (sd?.toolbar && typeof sd?.setDocumentMode === 'function') { + sd.setDocumentMode(m); + } else { + sd.activeEditor?.setDocumentMode(m); + } + }, mode); + }, + + async setTextSelection(from: number, to?: number) { + await page.waitForFunction(() => (window as any).editor?.commands, null, { timeout: 10_000 }); + await page.evaluate( + ({ f, t }) => { + const editor = (window as any).editor; + editor.commands.setTextSelection({ from: f, to: t ?? f }); + }, + { f: from, t: to }, + ); + }, + + async clickOnLine(lineIndex: number, xOffset = 10) { + const line = page.locator('.superdoc-line').nth(lineIndex); + const box = await line.boundingBox(); + if (!box) throw new Error(`Line ${lineIndex} not visible`); + await page.mouse.click(box.x + xOffset, box.y + box.height / 2); + }, + + async clickOnCommentedText(textMatch: string) { + const highlights = page.locator('.superdoc-comment-highlight'); + const count = await highlights.count(); + let bestIndex = -1; + let bestArea = Infinity; + + for (let i = 0; i < count; i++) { + const hl = highlights.nth(i); + const text = await hl.textContent(); + if (text && text.includes(textMatch)) { + const box = await hl.boundingBox(); + if (box) { + const area = box.width * box.height; + if (area < bestArea) { + bestArea = area; + bestIndex = i; + } + } + } + } + + if (bestIndex === -1) throw new Error(`No comment highlight found for "${textMatch}"`); + await highlights.nth(bestIndex).click(); + }, + + async pressTimes(key: string, count: number) { + for (let i = 0; i < count; i++) { + await page.keyboard.press(key); + } + }, + + async executeCommand(name: string, args?: Record) { + await page.waitForFunction(() => (window as any).editor?.commands, null, { timeout: 10_000 }); + await page.evaluate( + ({ cmd, cmdArgs }) => { + const editor = (window as any).editor; + if (!editor?.commands?.[cmd]) throw new Error(`Command "${cmd}" not found`); + if (cmdArgs && Object.keys(cmdArgs).length > 0) { + editor.commands[cmd](cmdArgs); + } else { + editor.commands[cmd](); + } + }, + { cmd: name, cmdArgs: args }, + ); + }, + + async waitForStable(ms?: number) { + await waitForStable(page, ms); + }, + + async snapshot(label: string) { + if (process.env.SCREENSHOTS !== '1') return; + const screenshot = await page.screenshot(); + await base.info().attach(label, { body: screenshot, contentType: 'image/png' }); + }, + + async loadDocument(filePath: string) { + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(filePath); + await page.waitForFunction( + () => (window as any).superdoc !== undefined && (window as any).editor !== undefined, + null, + { polling: 100, timeout: 30_000 }, + ); + await waitForStable(page, 1000); + }, + + // ----- Assertion methods ----- + + async assertTextContent(expected: string) { + await expect.poll(() => fixture.getTextContent()).toBe(expected); + }, + + async assertTextContains(sub: string) { + await expect.poll(() => fixture.getTextContent()).toContain(sub); + }, + + async assertTextNotContains(sub: string) { + await expect.poll(() => fixture.getTextContent()).not.toContain(sub); + }, + + async assertLineText(lineIndex: number, expected: string) { + await expect(page.locator('.superdoc-line').nth(lineIndex)).toHaveText(expected); + }, + + async assertLineCount(expected: number) { + await expect(page.locator('.superdoc-line')).toHaveCount(expected); + }, + + async assertPageCount(expected: number) { + await expect(page.locator('.superdoc-page[data-page-index]')).toHaveCount(expected, { timeout: 15_000 }); + }, + + async assertElementExists(selector: string) { + await expect(page.locator(selector).first()).toBeAttached(); + }, + + async assertElementVisible(selector: string) { + await expect(page.locator(selector).first()).toBeVisible(); + }, + + async assertElementHidden(selector: string) { + await expect(page.locator(selector).first()).toBeHidden(); + }, + + async assertElementCount(selector: string, expected: number) { + await expect(page.locator(selector)).toHaveCount(expected); + }, + + async assertSelection(from: number, to?: number) { + const expectedSelection = to !== undefined ? { from, to } : { from, to: from }; + await expect + .poll(() => + page.evaluate(() => { + const { state } = (window as any).editor; + return { from: state.selection.from, to: state.selection.to }; + }), + ) + .toEqual(expect.objectContaining(expectedSelection)); + }, + + async assertMarkActive(markName: string) { + await expect + .poll(() => + page.evaluate((name) => { + const { state } = (window as any).editor; + const { from, $from, to, empty } = state.selection; + if (empty) return $from.marks().some((m: any) => m.type.name === name); + let found = false; + state.doc.nodesBetween(from, to, (node: any) => { + if (node.marks?.some((m: any) => m.type.name === name)) found = true; + }); + return found; + }, markName), + ) + .toBe(true); + }, + + async assertMarksAtPos(pos: number, expectedNames: string[]) { + await expect + .poll(() => + page.evaluate((p) => { + const { state } = (window as any).editor; + const node = state.doc.nodeAt(p); + return node?.marks?.map((m: any) => m.type.name) ?? []; + }, pos), + ) + .toEqual(expect.arrayContaining(expectedNames)); + }, + + async assertTextHasMarks(text: string, expectedNames: string[], occurrence = 0) { + const marks = await getDocMarksByText(text, occurrence); + expect(marks).not.toBeNull(); + expect(marks ?? []).toEqual(expect.arrayContaining(expectedNames)); + }, + + async assertTextLacksMarks(text: string, disallowedNames: string[], occurrence = 0) { + const marks = await getDocMarksByText(text, occurrence); + expect(marks).not.toBeNull(); + for (const markName of disallowedNames) { + expect(marks ?? []).not.toContain(markName); + } + }, + + async assertTableExists(rows?: number, cols?: number) { + if ((rows === undefined) !== (cols === undefined)) { + throw new Error('assertTableExists expects both rows and cols, or neither.'); + } + + await expect + .poll(() => + page.evaluate( + ({ expectedRows, expectedCols }) => { + const docApi = (window as any).editor?.doc; + if (!docApi?.find) { + throw new Error('Document API is unavailable: expected editor.doc.find().'); + } + + const tableResult = docApi.find({ select: { type: 'node', nodeType: 'table' }, limit: 1 }); + const tableAddress = tableResult?.matches?.[0]; + if (!tableAddress) return 'no table found in document'; + + if (expectedRows !== undefined && expectedCols !== undefined) { + const expectedCellCount = expectedRows * expectedCols; + + const rowResult = docApi.find({ select: { type: 'node', nodeType: 'tableRow' }, within: tableAddress }); + const rowCount = rowResult?.matches?.length ?? 0; + + // Only validate row count when the adapter exposes row-level querying. + if (rowCount > 0 && rowCount !== expectedRows) { + return `expected ${expectedRows} rows, got ${rowCount}`; + } + + const cellResult = docApi.find({ + select: { type: 'node', nodeType: 'tableCell' }, + within: tableAddress, + }); + let cellCount = cellResult?.matches?.length ?? 0; + try { + const headerResult = docApi.find({ + select: { type: 'node', nodeType: 'tableHeader' }, + within: tableAddress, + }); + cellCount += headerResult?.matches?.length ?? 0; + } catch { + /* tableHeader may not be queryable */ + } + + // Fallback: count paragraphs when cell-level querying isn't available. + if (cellCount === 0) { + const paragraphResult = docApi.find({ + select: { type: 'node', nodeType: 'paragraph' }, + within: tableAddress, + }); + cellCount = paragraphResult?.matches?.length ?? 0; + } + + if (cellCount !== expectedCellCount) { + return `expected ${expectedCellCount} cells, got ${cellCount}`; + } + } + + return 'ok'; + }, + { expectedRows: rows, expectedCols: cols }, + ), + ) + .toBe('ok'); + }, + + async assertCommentHighlightExists(opts?: { text?: string; commentId?: string }) { + const highlights = page.locator('.superdoc-comment-highlight'); + await expect(highlights.first()).toBeAttached(); + + if (opts?.text) { + await expect(highlights.filter({ hasText: opts.text }).first()).toBeAttached(); + } + if (opts?.commentId) { + const commentId = opts.commentId; + await expect + .poll(() => + page.evaluate( + (id) => + Array.from(document.querySelectorAll('.superdoc-comment-highlight')).some((el) => + (el.getAttribute('data-comment-ids') ?? '') + .split(/[\s,]+/) + .filter(Boolean) + .includes(id), + ), + commentId, + ), + ) + .toBe(true); + } + }, + + async assertTrackedChangeExists(type: 'insert' | 'delete' | 'format') { + await expect(page.locator(`.track-${type}-dec`).first()).toBeAttached(); + }, + + async assertLinkExists(href: string) { + await expect + .poll(() => + page.evaluate( + (h) => Array.from(document.querySelectorAll('.superdoc-link')).some((el) => el.getAttribute('href') === h), + href, + ), + ) + .toBe(true); + }, + + async assertListMarkerText(lineIndex: number, expected: string) { + const line = page.locator('.superdoc-line').nth(lineIndex); + await expect(line.locator('.superdoc-paragraph-marker')).toHaveText(expected); + }, + + async assertMarkNotActive(markName: string) { + await expect + .poll(() => + page.evaluate((name) => { + const { state } = (window as any).editor; + const { from, $from, to, empty } = state.selection; + if (empty) return $from.marks().some((m: any) => m.type.name === name); + let found = false; + state.doc.nodesBetween(from, to, (node: any) => { + if (node.marks?.some((m: any) => m.type.name === name)) found = true; + }); + return found; + }, markName), + ) + .toBe(false); + }, + + async assertDocumentMode(mode: DocumentMode) { + await expect + .poll(() => + page.evaluate( + ({ expectedMode }: { expectedMode: DocumentMode }) => { + const sd = (window as any).superdoc; + const editorMode = (window as any).editor?.options?.documentMode; + const hasToolbar = Boolean(sd?.toolbar); + if (hasToolbar) { + const configMode = sd?.config?.documentMode; + return configMode === expectedMode; + } + return editorMode === expectedMode; + }, + { expectedMode: mode }, + ), + ) + .toBe(true); + }, + + async assertMarkAttrsAtPos(pos: number, markName: string, attrs: Record) { + await expect + .poll(() => + page.evaluate( + ({ p, name }) => { + const { state } = (window as any).editor; + const node = state.doc.nodeAt(p); + const mark = node?.marks?.find((m: any) => m.type.name === name); + return mark ? mark.attrs : null; + }, + { p: pos, name: markName }, + ), + ) + .toEqual(expect.objectContaining(attrs)); + }, + + async assertTextMarkAttrs(text: string, markName: string, attrs: Record, occurrence = 0) { + if (markName === 'link') { + const hrefs = await getDocLinkHrefsByText(text, occurrence); + expect(hrefs).not.toBeNull(); + expect(typeof attrs.href).toBe('string'); + expect(hrefs ?? []).toContain(attrs.href as string); + return; + } + + if (markName === 'textStyle') { + const runProperties = await getDocRunPropertiesByText(text, occurrence); + expect(runProperties).not.toBeNull(); + expect((runProperties ?? []).length).toBeGreaterThan(0); + const entries = Object.entries(attrs); + const allMatched = (runProperties ?? []).some((props) => + entries.every(([key, expectedValue]) => matchesTextStyleAttr(props, key, expectedValue)), + ); + expect(allMatched).toBe(true); + return; + } + + throw new Error(`assertTextMarkAttrs only supports "link" and "textStyle" via document-api; got "${markName}".`); + }, + + async assertTextAlignment(text: string, expectedAlignment: string, occurrence = 0) { + await expect + .poll(() => + page.evaluate( + ({ searchText, matchIndex }) => { + const docApi = (window as any).editor?.doc; + if (!docApi?.find || !docApi?.getNode) { + throw new Error('Document API is unavailable: expected editor.doc.find/getNode.'); + } + + const textResult = docApi.find({ + select: { type: 'text', pattern: searchText, mode: 'contains', caseSensitive: true }, + }); + const contexts = Array.isArray(textResult?.context) ? textResult.context : []; + const context = contexts[matchIndex]; + if (!context?.address) return null; + + const node = docApi.getNode(context.address); + return node?.properties?.alignment ?? null; + }, + { searchText: text, matchIndex: occurrence }, + ), + ) + .toBe(expectedAlignment); + }, + + // ----- Getter methods ----- + + async getTextContent(): Promise { + return getTextContentFromDocApi(); + }, + + async getSelection(): Promise<{ from: number; to: number }> { + return page.evaluate(() => { + const { state } = (window as any).editor; + return { from: state.selection.from, to: state.selection.to }; + }); + }, + + async getMarksAtPos(pos: number): Promise { + return page.evaluate((p) => { + const { state } = (window as any).editor; + const node = state.doc.nodeAt(p); + return node?.marks?.map((m: any) => m.type.name) ?? []; + }, pos); + }, + + async getMarkAttrsAtPos(pos: number): Promise }>> { + return page.evaluate((p) => { + const { state } = (window as any).editor; + const node = state.doc.nodeAt(p); + return node?.marks?.map((m: any) => ({ name: m.type.name, attrs: m.attrs })) ?? []; + }, pos); + }, + + async findTextPos(text: string, occurrence = 0): Promise { + return page.evaluate( + ({ search, targetOccurrence }) => { + const doc = (window as any).editor.state.doc; + let found = -1; + let seen = 0; + + doc.descendants((node: any, pos: number) => { + if (found !== -1) return false; + if (!node.isText || !node.text) return; + + let fromIndex = 0; + while (fromIndex <= node.text.length) { + const hit = node.text.indexOf(search, fromIndex); + if (hit === -1) break; + if (seen === targetOccurrence) { + found = pos + hit; + return false; + } + seen++; + fromIndex = hit + 1; + } + }); + + if (found === -1) throw new Error(`Text "${search}" (occurrence ${targetOccurrence}) not found in document`); + return found; + }, + { search: text, targetOccurrence: occurrence }, + ); + }, + }; + + return fixture; +} + +export type SuperDocFixture = ReturnType; + +interface SuperDocOptions { + config?: HarnessConfig; +} + +export const test = base.extend<{ superdoc: SuperDocFixture } & SuperDocOptions>({ + config: [{}, { option: true }], + + superdoc: async ({ page, config }, use) => { + const modKey = process.platform === 'darwin' ? 'Meta' : 'Control'; + + // Navigate to harness + const url = buildHarnessUrl({ layout: true, ...config }); + await page.goto(url); + await waitForReady(page); + + // Focus the editor — use .focus() not .click() because in layout mode + // the ProseMirror contenteditable is positioned off-screen (DomPainter renders visuals). + const editor = page.locator('[contenteditable="true"]').first(); + await editor.waitFor({ state: 'visible', timeout: 10_000 }); + await editor.focus(); + + await use(createFixture(page, editor, modKey)); + }, +}); + +export { expect }; diff --git a/tests/behavior/harness/index.html b/tests/behavior/harness/index.html new file mode 100644 index 000000000..ba5cb7263 --- /dev/null +++ b/tests/behavior/harness/index.html @@ -0,0 +1,14 @@ + + + + + + SuperDoc Behavior Test Harness + + + +
+
+ + + diff --git a/tests/behavior/harness/main.ts b/tests/behavior/harness/main.ts new file mode 100644 index 000000000..ed3dc70e5 --- /dev/null +++ b/tests/behavior/harness/main.ts @@ -0,0 +1,97 @@ +import 'superdoc/style.css'; +import { SuperDoc } from 'superdoc'; + +type SuperDocConfig = ConstructorParameters[0]; +type SuperDocInstance = InstanceType; +type SuperDocReadyPayload = Parameters>[0]; + +type HarnessWindow = Window & + typeof globalThis & { + superdocReady?: boolean; + superdoc?: SuperDocInstance; + editor?: unknown; + }; + +const harnessWindow = window as HarnessWindow; + +const params = new URLSearchParams(location.search); +const layout = params.get('layout') !== '0'; +const showCaret = params.get('showCaret') === '1'; +const showSelection = params.get('showSelection') === '1'; +const toolbar = params.get('toolbar'); +const comments = params.get('comments'); +const trackChanges = params.get('trackChanges') === '1'; + +if (!showCaret) { + document.documentElement.style.setProperty('caret-color', 'transparent', 'important'); +} + +let instance: SuperDocInstance | null = null; + +function init(file?: File) { + if (instance) { + instance.destroy(); + instance = null; + } + + harnessWindow.superdocReady = false; + + const config: SuperDocConfig = { + selector: '#editor', + useLayoutEngine: layout, + telemetry: { enabled: false }, + onReady: ({ superdoc }: SuperDocReadyPayload) => { + harnessWindow.superdoc = superdoc; + superdoc.activeEditor.on('create', (payload: unknown) => { + if (!payload || typeof payload !== 'object' || !('editor' in payload)) return; + harnessWindow.editor = (payload as { editor: unknown }).editor; + }); + harnessWindow.superdocReady = true; + }, + }; + + if (file) { + config.document = file; + } + + // Toolbar — pass selector string, not DOM element + // (SuperToolbar.findElementBySelector expects a string) + if (toolbar && toolbar !== 'none') { + config.toolbar = '#toolbar'; + } + + // Comments + if (comments === 'on' || comments === 'panel') { + config.comments = { visible: true }; + } else if (comments === 'readonly') { + config.comments = { visible: true, readOnly: true }; + } + + // Track changes + if (trackChanges) { + config.trackChanges = { visible: true }; + } + + instance = new SuperDoc(config); + + if (!showSelection) { + const style = document.createElement('style'); + style.textContent = ` + .superdoc-selection-overlay, + .superdoc-caret { display: none !important; } + `; + document.head.appendChild(style); + } +} + +const fileInput = document.querySelector('input[type="file"]'); +if (!fileInput) { + throw new Error('Behavior harness requires an input[type="file"] element.'); +} + +fileInput.addEventListener('change', (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) init(file); +}); + +init(); diff --git a/tests/behavior/harness/vite.config.ts b/tests/behavior/harness/vite.config.ts new file mode 100644 index 000000000..f3bc953c7 --- /dev/null +++ b/tests/behavior/harness/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + port: 9990, + strictPort: true, + }, + optimizeDeps: { + exclude: ['superdoc'], + }, +}); diff --git a/tests/behavior/helpers/document-api.ts b/tests/behavior/helpers/document-api.ts new file mode 100644 index 000000000..5328805e4 --- /dev/null +++ b/tests/behavior/helpers/document-api.ts @@ -0,0 +1,187 @@ +import type { Page } from '@playwright/test'; +import type { + TextAddress, + MatchContext, + TrackChangeType, + CommentsListResult, + TrackChangesListResult, + TextMutationReceipt, +} from '@superdoc/document-api'; +import type { ListsListResult } from '@superdoc/document-api'; + +export type { TextAddress, TextMutationReceipt, TrackChangeType }; +export type ChangeMode = 'direct' | 'tracked'; + +export async function assertDocumentApiReady(page: Page): Promise { + await page.evaluate(() => { + const docApi = (window as any).editor?.doc; + if (!docApi) { + throw new Error('Document API is unavailable: expected editor.doc.'); + } + + const required: Array<[string, unknown]> = [ + ['editor.doc.getText', docApi.getText], + ['editor.doc.find', docApi.find], + ['editor.doc.comments.list', docApi.comments?.list], + ['editor.doc.comments.add', docApi.comments?.add], + ['editor.doc.trackChanges.list', docApi.trackChanges?.list], + ]; + + for (const [methodPath, method] of required) { + if (typeof method !== 'function') { + throw new Error(`Document API is unavailable: expected ${methodPath}().`); + } + } + }); +} + +export async function getDocumentText(page: Page): Promise { + return page.evaluate(() => (window as any).editor.doc.getText({})); +} + +export async function findTextContexts( + page: Page, + pattern: string, + options: { mode?: 'contains' | 'exact' | 'regex'; caseSensitive?: boolean } = {}, +): Promise { + return page.evaluate( + ({ searchPattern, searchMode, caseSensitive }) => { + const result = (window as any).editor.doc.find({ + select: { type: 'text', pattern: searchPattern, mode: searchMode, caseSensitive }, + }); + return result?.context ?? []; + }, + { + searchPattern: pattern, + searchMode: options.mode ?? 'contains', + caseSensitive: options.caseSensitive ?? true, + }, + ); +} + +export async function findFirstTextRange( + page: Page, + pattern: string, + options: { + occurrence?: number; + rangeIndex?: number; + mode?: 'contains' | 'exact' | 'regex'; + caseSensitive?: boolean; + } = {}, +): Promise { + const contexts = await findTextContexts(page, pattern, { + mode: options.mode, + caseSensitive: options.caseSensitive, + }); + const context = contexts[options.occurrence ?? 0]; + return context?.textRanges?.[options.rangeIndex ?? 0] ?? null; +} + +export async function addComment(page: Page, input: { target: TextAddress; text: string }): Promise { + await page.evaluate((payload) => (window as any).editor.doc.comments.add(payload), input); +} + +export async function addCommentByText( + page: Page, + input: { + pattern: string; + text: string; + occurrence?: number; + mode?: 'contains' | 'exact' | 'regex'; + caseSensitive?: boolean; + }, +): Promise { + await page.evaluate((payload) => { + const docApi = (window as any).editor.doc; + const found = docApi.find({ + select: { + type: 'text', + pattern: payload.pattern, + mode: payload.mode ?? 'contains', + caseSensitive: payload.caseSensitive ?? true, + }, + }); + const target = found?.context?.[payload.occurrence ?? 0]?.textRanges?.[0]; + if (!target) throw new Error(`No text range found for pattern "${payload.pattern}".`); + docApi.comments.add({ target, text: payload.text }); + }, input); +} + +export async function editComment(page: Page, input: { commentId: string; text: string }): Promise { + await page.evaluate((payload) => (window as any).editor.doc.comments.edit(payload), input); +} + +export async function replyToComment(page: Page, input: { parentCommentId: string; text: string }): Promise { + await page.evaluate((payload) => (window as any).editor.doc.comments.reply(payload), input); +} + +export async function resolveComment(page: Page, input: { commentId: string }): Promise { + await page.evaluate((payload) => (window as any).editor.doc.comments.resolve(payload), input); +} + +export async function listComments( + page: Page, + query: { includeResolved?: boolean } = { includeResolved: true }, +): Promise { + return page.evaluate((input) => (window as any).editor.doc.comments.list(input), query); +} + +export async function insertText( + page: Page, + input: { text: string; target?: TextAddress }, + options: { changeMode?: ChangeMode; dryRun?: boolean } = {}, +): Promise { + return page.evaluate(({ payload, opts }) => (window as any).editor.doc.insert(payload, opts), { + payload: input, + opts: options, + }); +} + +export async function replaceText( + page: Page, + input: { target: TextAddress; text: string }, + options: { changeMode?: ChangeMode; dryRun?: boolean } = {}, +): Promise { + return page.evaluate(({ payload, opts }) => (window as any).editor.doc.replace(payload, opts), { + payload: input, + opts: options, + }); +} + +export async function deleteText( + page: Page, + input: { target: TextAddress }, + options: { changeMode?: ChangeMode; dryRun?: boolean } = {}, +): Promise { + return page.evaluate(({ payload, opts }) => (window as any).editor.doc.delete(payload, opts), { + payload: input, + opts: options, + }); +} + +export async function listTrackChanges( + page: Page, + query: { limit?: number; offset?: number; type?: TrackChangeType } = {}, +): Promise { + return page.evaluate((input) => (window as any).editor.doc.trackChanges.list(input), query); +} + +export async function listItems(page: Page): Promise { + return page.evaluate(() => (window as any).editor.doc.lists.list({})); +} + +export async function acceptTrackChange(page: Page, input: { id: string }): Promise { + await page.evaluate((payload) => (window as any).editor.doc.trackChanges.accept(payload), input); +} + +export async function rejectTrackChange(page: Page, input: { id: string }): Promise { + await page.evaluate((payload) => (window as any).editor.doc.trackChanges.reject(payload), input); +} + +export async function acceptAllTrackChanges(page: Page): Promise { + await page.evaluate(() => (window as any).editor.doc.trackChanges.acceptAll({})); +} + +export async function rejectAllTrackChanges(page: Page): Promise { + await page.evaluate(() => (window as any).editor.doc.trackChanges.rejectAll({})); +} diff --git a/tests/behavior/helpers/field-annotations.ts b/tests/behavior/helpers/field-annotations.ts new file mode 100644 index 000000000..ae8aa9f80 --- /dev/null +++ b/tests/behavior/helpers/field-annotations.ts @@ -0,0 +1,82 @@ +import type { Page } from '@playwright/test'; + +export interface AnnotationAttrs { + type?: string; + displayLabel: string; + fieldId: string; + fieldColor?: string; + highlighted?: boolean; + bold?: boolean; + italic?: boolean; + underline?: boolean; + linkUrl?: string; + rawHtml?: string; + [key: string]: unknown; +} + +interface DocTextNode { + isText?: boolean; + text?: string | null; +} + +interface DescendantDoc { + descendants(callback: (node: DocTextNode, pos: number) => boolean | void): void; +} + +interface AnnotationEditor { + state?: { doc?: DescendantDoc }; + commands?: { + replaceWithFieldAnnotation?: (ranges: Array<{ from: number; to: number; attrs: AnnotationAttrs }>) => void; + }; +} + +/** + * Find a text placeholder in the document and replace it with a field annotation. + */ +export async function replaceTextWithAnnotation(page: Page, searchText: string, attrs: AnnotationAttrs): Promise { + await page.evaluate( + ({ search, annotationAttrs }) => { + const editor = (window as { editor?: AnnotationEditor }).editor; + const doc = editor?.state?.doc; + const replaceWithFieldAnnotation = editor?.commands?.replaceWithFieldAnnotation; + + if (!doc || typeof replaceWithFieldAnnotation !== 'function') { + throw new Error( + 'Field annotation helper requires editor.state.doc.descendants() and editor.commands.replaceWithFieldAnnotation().', + ); + } + + let from = -1; + let to = -1; + + doc.descendants((node, pos) => { + if (from >= 0 && to >= 0) return false; + if (node.isText && typeof node.text === 'string') { + const index = node.text.indexOf(search); + if (index !== -1) { + from = pos + index; + to = pos + index + search.length; + return false; + } + } + return true; + }); + + if (from < 0 || to < 0) throw new Error(`Text "${search}" not found`); + + replaceWithFieldAnnotation([ + { + from, + to, + attrs: { + fieldColor: '#6366f1', + highlighted: true, + type: 'text', + ...annotationAttrs, + }, + }, + ]); + }, + { search: searchText, annotationAttrs: attrs }, + ); +} diff --git a/tests/behavior/helpers/sdt.ts b/tests/behavior/helpers/sdt.ts new file mode 100644 index 000000000..29d4d647f --- /dev/null +++ b/tests/behavior/helpers/sdt.ts @@ -0,0 +1,85 @@ +import type { Page } from '@playwright/test'; + +/** Insert a block SDT with a paragraph of text via the editor command. */ +export async function insertBlockSdt(page: Page, alias: string, text: string): Promise { + await page.evaluate( + ({ alias, text }) => { + (window as any).editor.commands.insertStructuredContentBlock({ + attrs: { alias }, + html: `

${text}

`, + }); + }, + { alias, text }, + ); +} + +/** Insert an inline SDT with text via the editor command. */ +export async function insertInlineSdt(page: Page, alias: string, text: string): Promise { + await page.evaluate( + ({ alias, text }) => { + (window as any).editor.commands.insertStructuredContentInline({ + attrs: { alias }, + text, + }); + }, + { alias, text }, + ); +} + +/** Get the bounding box center of an element. */ +export async function getCenter(page: Page, selector: string): Promise<{ x: number; y: number }> { + return page.evaluate((sel) => { + const el = document.querySelector(sel); + if (!el) throw new Error(`Element not found: ${sel}`); + const rect = el.getBoundingClientRect(); + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + }, selector); +} + +/** Check whether an element has a given CSS class. */ +export async function hasClass(page: Page, selector: string, className: string): Promise { + return page.evaluate( + ({ sel, cls }) => { + const el = document.querySelector(sel); + return el ? el.classList.contains(cls) : false; + }, + { sel: selector, cls: className }, + ); +} + +/** Check whether the PM selection targets or is inside a structuredContentBlock node. */ +export async function isSelectionOnBlockSdt(page: Page): Promise { + return page.evaluate(() => { + const { state } = (window as any).editor; + const { selection } = state; + if (selection.node?.type.name === 'structuredContentBlock') return true; + const $pos = selection.$from; + for (let d = $pos.depth; d > 0; d--) { + if ($pos.node(d).type.name === 'structuredContentBlock') return true; + } + return false; + }); +} + +/** + * Deselect the SDT by placing the cursor inside the first text node + * that contains `anchorText`. Falls back to position 1 if not found. + */ +export async function deselectSdt(page: Page, anchorText = 'Before SDT'): Promise { + await page.evaluate((text) => { + const editor = (window as any).editor; + const doc = editor.state.doc; + let pos = 1; // safe fallback: start of first text node + + doc.descendants((node: any, nodePos: number) => { + if (pos > 1) return false; + if (node.isText && node.text?.includes(text)) { + pos = nodePos + 1; + return false; + } + return true; + }); + + editor.commands.setTextSelection({ from: pos, to: pos }); + }, anchorText); +} diff --git a/tests/behavior/helpers/table.ts b/tests/behavior/helpers/table.ts new file mode 100644 index 000000000..3c0f2bb07 --- /dev/null +++ b/tests/behavior/helpers/table.ts @@ -0,0 +1,36 @@ +import type { Page } from '@playwright/test'; + +/** + * Count table cells in the first table found via document-api. + * + * Tries tableCell + tableHeader first. Falls back to counting paragraphs + * when the adapter doesn't expose cell-level querying. + */ +export async function countTableCells(page: Page): Promise { + return page.evaluate(() => { + const docApi = (window as any).editor?.doc; + if (!docApi?.find) { + throw new Error('Document API is unavailable: expected editor.doc.find().'); + } + + const tableResult = docApi.find({ select: { type: 'node', nodeType: 'table' }, limit: 1 }); + const tableAddress = tableResult?.matches?.[0]; + if (!tableAddress) return 0; + + const cellResult = docApi.find({ select: { type: 'node', nodeType: 'tableCell' }, within: tableAddress }); + let cellCount = cellResult?.matches?.length ?? 0; + + try { + const headerResult = docApi.find({ select: { type: 'node', nodeType: 'tableHeader' }, within: tableAddress }); + cellCount += headerResult?.matches?.length ?? 0; + } catch { + /* tableHeader may not be queryable */ + } + + if (cellCount > 0) return cellCount; + + // Fallback: count paragraphs when cell-level querying isn't available. + const paragraphResult = docApi.find({ select: { type: 'node', nodeType: 'paragraph' }, within: tableAddress }); + return paragraphResult?.matches?.length ?? 0; + }); +} diff --git a/tests/behavior/helpers/tracked-changes.ts b/tests/behavior/helpers/tracked-changes.ts new file mode 100644 index 000000000..e38f8eaf7 --- /dev/null +++ b/tests/behavior/helpers/tracked-changes.ts @@ -0,0 +1,14 @@ +import type { Page } from '@playwright/test'; + +/** + * Reject all tracked changes in the document via document-api. + */ +export async function rejectAllTrackedChanges(page: Page): Promise { + await page.evaluate(() => { + const rejectAll = (window as any).editor?.doc?.trackChanges?.rejectAll; + if (typeof rejectAll !== 'function') { + throw new Error('Document API is unavailable: expected editor.doc.trackChanges.rejectAll.'); + } + rejectAll({}); + }); +} diff --git a/tests/behavior/package.json b/tests/behavior/package.json new file mode 100644 index 000000000..ab09d0d67 --- /dev/null +++ b/tests/behavior/package.json @@ -0,0 +1,23 @@ +{ + "name": "@superdoc-testing/behavior", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test", + "test:trace": "TRACE=1 playwright test", + "test:screenshots": "SCREENSHOTS=1 playwright test", + "test:headed": "playwright test --headed", + "test:ui": "playwright test --ui", + "test:html": "playwright test --reporter=html && playwright show-report", + "harness": "vite --config harness/vite.config.ts harness/", + "setup": "playwright install --with-deps" + }, + "dependencies": { + "superdoc": "workspace:*", + "@superdoc/document-api": "workspace:*" + }, + "devDependencies": { + "@playwright/test": "catalog:", + "vite": "catalog:" + } +} diff --git a/tests/behavior/playwright.config.ts b/tests/behavior/playwright.config.ts new file mode 100644 index 000000000..e58a8cfc3 --- /dev/null +++ b/tests/behavior/playwright.config.ts @@ -0,0 +1,39 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + testIgnore: '**/legacy/**', + fullyParallel: true, + workers: process.env.CI ? '50%' : 8, + timeout: 60_000, + retries: process.env.CI ? 1 : 0, + reporter: 'list', + + use: { + viewport: { width: 1600, height: 1200 }, + trace: process.env.TRACE === '1' ? 'on' : 'off', + screenshot: process.env.SCREENSHOTS === '1' ? 'on' : 'off', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + + // CI: shard across runners with --shard=1/3, --shard=2/3, --shard=3/3 + webServer: { + command: 'pnpm exec vite --config harness/vite.config.ts harness/', + port: 9990, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/tests/behavior/test-data b/tests/behavior/test-data new file mode 120000 index 000000000..d5319f59f --- /dev/null +++ b/tests/behavior/test-data @@ -0,0 +1 @@ +../../test-corpus \ No newline at end of file diff --git a/tests/behavior/tests/basic-commands/drag-selection-autoscroll.spec.ts b/tests/behavior/tests/basic-commands/drag-selection-autoscroll.spec.ts new file mode 100644 index 000000000..615575d4e --- /dev/null +++ b/tests/behavior/tests/basic-commands/drag-selection-autoscroll.spec.ts @@ -0,0 +1,89 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/h_f-normal-odd-even.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +const EDITOR_VIEWPORT_HEIGHT_PX = 700; +const DRAG_START_OFFSET_X = 120; +const DRAG_START_OFFSET_Y = 140; +const DRAG_EDGE_OFFSET_X = 260; +const DRAG_INSIDE_EDGE_OFFSET_Y = 10; +const DRAG_OUTSIDE_EDGE_OFFSET_Y = 120; +const DRAG_TO_EDGE_STEPS = 18; +const DRAG_OUTSIDE_STEPS = 10; +const HOLD_EDGE_ITERATIONS = 6; +const HOLD_EDGE_STEPS = 2; +const HOLD_EDGE_WAIT_MS = 200; + +test('drag selection near viewport edge autoscrolls the editor', async ({ superdoc, browserName }) => { + // Firefox headless does not consistently surface drag-triggered autoscroll in this harness. + test.skip(browserName === 'firefox', 'Drag-triggered autoscroll is not deterministic in Firefox headless.'); + + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + await superdoc.page.evaluate((heightPx) => { + const editor = document.querySelector('#editor') as HTMLElement | null; + if (!editor) return; + // Constrain viewport so multi-page docs require scrolling in all browsers. + editor.style.height = `${heightPx}px`; + editor.style.maxHeight = `${heightPx}px`; + editor.style.overflowY = 'auto'; + }, EDITOR_VIEWPORT_HEIGHT_PX); + await superdoc.waitForStable(); + + const editor = superdoc.page.locator('#editor'); + const editorBox = await editor.boundingBox(); + if (!editorBox) throw new Error('Editor container is not visible.'); + await expect.poll(() => superdoc.page.locator('.superdoc-page[data-page-index]').count()).toBeGreaterThanOrEqual(2); + + const getScrollSignal = () => + superdoc.page.evaluate(() => { + const maxElementScrollTop = Array.from(document.querySelectorAll('*')).reduce((max, node) => { + const el = node as HTMLElement; + if (el.scrollHeight <= el.clientHeight + 1) return max; + return Math.max(max, el.scrollTop); + }, 0); + + return { + maxElementScrollTop, + windowScrollY: window.scrollY, + }; + }); + + const startX = editorBox.x + DRAG_START_OFFSET_X; + const startY = editorBox.y + DRAG_START_OFFSET_Y; + const edgeX = editorBox.x + DRAG_EDGE_OFFSET_X; + const insideEdgeY = editorBox.y + editorBox.height - DRAG_INSIDE_EDGE_OFFSET_Y; + const outsideEdgeY = editorBox.y + editorBox.height + DRAG_OUTSIDE_EDGE_OFFSET_Y; + + // Drag toward and then past the bottom edge while holding selection to trigger auto-scroll. + const scrollBefore = await getScrollSignal(); + await superdoc.page.mouse.move(startX, startY); + await superdoc.page.mouse.down(); + await superdoc.page.mouse.move(edgeX, insideEdgeY, { steps: DRAG_TO_EDGE_STEPS }); + await superdoc.page.mouse.move(edgeX, outsideEdgeY, { steps: DRAG_OUTSIDE_STEPS }); + for (let i = 0; i < HOLD_EDGE_ITERATIONS; i += 1) { + await superdoc.page.mouse.move(edgeX, outsideEdgeY, { steps: HOLD_EDGE_STEPS }); + await superdoc.waitForStable(HOLD_EDGE_WAIT_MS); + } + await superdoc.page.mouse.up(); + await superdoc.waitForStable(); + + const scrollAfter = await getScrollSignal(); + const didScroll = + scrollAfter.maxElementScrollTop > scrollBefore.maxElementScrollTop || + scrollAfter.windowScrollY > scrollBefore.windowScrollY; + + const selectionAfter = await superdoc.getSelection(); + const selectionSpan = Math.abs(selectionAfter.to - selectionAfter.from); + expect(selectionSpan).toBeGreaterThan(0); + expect(didScroll).toBe(true); +}); diff --git a/tests/behavior/tests/basic-commands/multi-paragraph.spec.ts b/tests/behavior/tests/basic-commands/multi-paragraph.spec.ts new file mode 100644 index 000000000..9901a7f6e --- /dev/null +++ b/tests/behavior/tests/basic-commands/multi-paragraph.spec.ts @@ -0,0 +1,38 @@ +import { test } from '../../fixtures/superdoc.js'; + +const CONTENT_LINES = [ + 'Heading: Introduction to SuperDoc', + '', + 'SuperDoc is a powerful document editor that provides rich text editing capabilities. It supports various formatting options, tables, images, and more.', + '', + 'Key features include:', + '- Real-time collaboration', + '- Track changes and comments', + '- Export to multiple formats', + '', + 'Start creating your documents today!', +]; + +test('type a multi-paragraph sample document', async ({ superdoc }) => { + for (let i = 0; i < CONTENT_LINES.length; i += 1) { + const line = CONTENT_LINES[i]; + const isLast = i === CONTENT_LINES.length - 1; + + if (line.length === 0) { + await superdoc.newLine(); + continue; + } + + await superdoc.type(line); + if (!isLast) { + await superdoc.newLine(); + } + } + + await superdoc.waitForStable(); + + await superdoc.assertTextContains('Heading: Introduction to SuperDoc'); + await superdoc.assertTextContains('Key features include:'); + await superdoc.assertTextContains('- Track changes and comments'); + await superdoc.assertTextContains('Start creating your documents today!'); +}); diff --git a/tests/behavior/tests/basic-commands/select-all-complex-doc.spec.ts b/tests/behavior/tests/basic-commands/select-all-complex-doc.spec.ts new file mode 100644 index 000000000..c0490c3e9 --- /dev/null +++ b/tests/behavior/tests/basic-commands/select-all-complex-doc.spec.ts @@ -0,0 +1,35 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, '../../test-data/basic/advanced-tables.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +test('select all captures entire document in a complex table doc', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + // Re-focus the editor after loading a new document + await superdoc.clickOnLine(0); + await superdoc.waitForStable(); + + // Use document-api text length as a stable baseline for full-document selection. + const docText = await superdoc.getTextContent(); + expect(docText.length).toBeGreaterThan(0); + + // Use the editor command for select-all (keyboard shortcut produces AllSelection + // which reports from=0, to=docSize; the command gives a reliable TextSelection). + await superdoc.executeCommand('selectAll'); + await superdoc.waitForStable(); + + // Selection should span the entire document content + const selection = await superdoc.getSelection(); + expect(selection.to - selection.from).toBeGreaterThan(0); + expect(selection.from).toBeLessThanOrEqual(1); + expect(selection.to - selection.from).toBeGreaterThanOrEqual(docText.length); +}); diff --git a/tests/behavior/tests/basic-commands/type-basic-text.spec.ts b/tests/behavior/tests/basic-commands/type-basic-text.spec.ts new file mode 100644 index 000000000..245e68bdd --- /dev/null +++ b/tests/behavior/tests/basic-commands/type-basic-text.spec.ts @@ -0,0 +1,12 @@ +import { test } from '../../fixtures/superdoc.js'; + +test('type basic text into a blank document', async ({ superdoc }) => { + await superdoc.type('Hello, SuperDoc!'); + await superdoc.newLine(); + await superdoc.type('This is a simple paragraph of text.'); + await superdoc.waitForStable(); + + await superdoc.assertLineCount(2); + await superdoc.assertTextContains('Hello, SuperDoc!'); + await superdoc.assertTextContains('This is a simple paragraph of text.'); +}); diff --git a/tests/behavior/tests/basic-commands/undo-redo.spec.ts b/tests/behavior/tests/basic-commands/undo-redo.spec.ts new file mode 100644 index 000000000..998a4aa60 --- /dev/null +++ b/tests/behavior/tests/basic-commands/undo-redo.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test('undo removes last typed text', async ({ superdoc }) => { + await superdoc.type('First paragraph.'); + await superdoc.newLine(); + await superdoc.type('Second paragraph.'); + await superdoc.waitForStable(); + + await superdoc.assertTextContains('Second paragraph.'); + + await superdoc.undo(); + await superdoc.waitForStable(); + + await superdoc.assertTextNotContains('Second paragraph.'); + await superdoc.assertTextContains('First paragraph.'); +}); + +test('redo restores undone text', async ({ superdoc }) => { + await superdoc.type('First paragraph.'); + await superdoc.newLine(); + await superdoc.type('Second paragraph.'); + await superdoc.waitForStable(); + + await superdoc.undo(); + await superdoc.waitForStable(); + await superdoc.assertTextNotContains('Second paragraph.'); + + await superdoc.redo(); + await superdoc.waitForStable(); + + await superdoc.assertTextContains('First paragraph.'); + await superdoc.assertTextContains('Second paragraph.'); +}); diff --git a/tests/behavior/tests/comments/backspace-empty-paragraph-suggesting.spec.ts b/tests/behavior/tests/comments/backspace-empty-paragraph-suggesting.spec.ts new file mode 100644 index 000000000..df31b53a0 --- /dev/null +++ b/tests/behavior/tests/comments/backspace-empty-paragraph-suggesting.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady, getDocumentText } from '../../helpers/document-api.js'; + +test.use({ config: { toolbar: 'full', comments: 'off', trackChanges: true } }); + +test('backspace removes empty paragraphs in suggesting mode', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('Hello World'); + await superdoc.waitForStable(); + + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + await superdoc.assertDocumentMode('suggesting'); + + // Enter creates an empty paragraph after the text. + await superdoc.press('Enter'); + await superdoc.waitForStable(); + await superdoc.assertLineCount(2); + + // Backspace should remove the empty paragraph and return to one line. + await superdoc.press('Backspace'); + await superdoc.waitForStable(); + await superdoc.assertLineCount(1); + await expect.poll(() => getDocumentText(superdoc.page)).toBe('Hello World'); + + // Regression flow: Enter -> Enter -> Backspace -> Backspace should join back. + await superdoc.press('Enter'); + await superdoc.waitForStable(); + await superdoc.press('Enter'); + await superdoc.waitForStable(); + await superdoc.assertLineCount(3); + + await superdoc.press('Backspace'); + await superdoc.waitForStable(); + await superdoc.assertLineCount(2); + + await superdoc.press('Backspace'); + await superdoc.waitForStable(); + await superdoc.assertLineCount(1); + await expect.poll(() => getDocumentText(superdoc.page)).toBe('Hello World'); +}); diff --git a/tests/behavior/tests/comments/basic-comment-insertion.spec.ts b/tests/behavior/tests/comments/basic-comment-insertion.spec.ts new file mode 100644 index 000000000..ee77c27fb --- /dev/null +++ b/tests/behavior/tests/comments/basic-comment-insertion.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { addCommentByText, assertDocumentApiReady, listComments } from '../../helpers/document-api.js'; + +test.use({ config: { toolbar: 'full', comments: 'on' } }); + +test('add a comment programmatically via document-api', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('hello'); + await superdoc.newLine(); + await superdoc.newLine(); + await superdoc.type('world'); + await superdoc.waitForStable(); + + await superdoc.assertTextContains('hello'); + await superdoc.assertTextContains('world'); + + const initialComments = await listComments(superdoc.page, { includeResolved: true }); + const initialCount = initialComments.total; + + await addCommentByText(superdoc.page, { + pattern: 'world', + text: 'This is a programmatic comment', + }); + await superdoc.waitForStable(); + + await superdoc.assertCommentHighlightExists({ text: 'world' }); + await expect + .poll(async () => { + const listed = await listComments(superdoc.page, { includeResolved: true }); + return listed.matches.some((entry) => entry.text === 'This is a programmatic comment'); + }) + .toBe(true); + await expect + .poll(async () => (await listComments(superdoc.page, { includeResolved: true })).total) + .toBeGreaterThan(initialCount); + + await superdoc.snapshot('comment added programmatically'); +}); + +test('add a comment via the UI bubble', async ({ superdoc }) => { + await superdoc.type('Some text to comment on'); + await superdoc.waitForStable(); + const initialCount = (await listComments(superdoc.page, { includeResolved: true })).total; + + // Select "comment" via PM positions + const commentPos = await superdoc.findTextPos('comment'); + await superdoc.setTextSelection(commentPos, commentPos + 'comment'.length); + await superdoc.waitForStable(); + + // The floating comment bubble should appear + const bubble = superdoc.page.locator('.superdoc__tools'); + await expect(bubble).toBeVisible({ timeout: 5_000 }); + + // Click the comment button + await bubble.locator('[data-id="is-tool"]').click(); + await superdoc.waitForStable(); + + // Comment dialog should open + const dialog = superdoc.page.locator('.comments-dialog.is-active').last(); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // Type the comment text in the input + const commentInput = dialog.locator('.comment-entry .editor-element'); + await commentInput.click(); + await superdoc.page.keyboard.type('UI comment on selected text'); + await superdoc.waitForStable(); + + // Submit by clicking the "Comment" button + await dialog.locator('.sd-button.primary', { hasText: 'Comment' }).first().click(); + await superdoc.waitForStable(); + + // Comment highlight should exist on the word "comment" + await superdoc.assertCommentHighlightExists({ text: 'comment' }); + + await expect + .poll(async () => (await listComments(superdoc.page, { includeResolved: true })).total) + .toBeGreaterThan(initialCount); + // CommentInfo.text is optional in the contract — some adapters don't populate it. + // Verify via the API when available; the DOM assertion below covers all adapters. + const listedAfterSubmit = await listComments(superdoc.page, { includeResolved: true }); + const commentTexts = listedAfterSubmit.matches.map((e) => e.text).filter(Boolean); + if (commentTexts.length > 0) { + expect(commentTexts).toContain('UI comment on selected text'); + } + + // Verify the comment text appears in the floating dialog + const commentDialog = superdoc.page.locator('.floating-comment > .comments-dialog').last(); + const commentText = commentDialog.locator('.comment-body .comment'); + await expect(commentText.first()).toBeAttached({ timeout: 5_000 }); + await expect(commentText.first()).toContainText('UI comment on selected text'); + + await superdoc.snapshot('comment added via UI'); +}); diff --git a/tests/behavior/tests/comments/comment-on-tracked-change.spec.ts b/tests/behavior/tests/comments/comment-on-tracked-change.spec.ts new file mode 100644 index 000000000..23b98da15 --- /dev/null +++ b/tests/behavior/tests/comments/comment-on-tracked-change.spec.ts @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady, listComments, listTrackChanges } from '../../helpers/document-api.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, '../../test-data/comments-tcs/gdocs-comment-on-change.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); + +test.use({ config: { toolbar: 'full', comments: 'on', trackChanges: true } }); + +test('comment thread on tracked change shows both the change and replies', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.page.waitForSelector('.superdoc-comment-highlight', { timeout: 30_000 }); + await superdoc.waitForStable(); + await assertDocumentApiReady(superdoc.page); + + await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(1); + await expect.poll(async () => (await listComments(superdoc.page, { includeResolved: true })).total).toBe(4); + + // Both "new text" and "Test" should have comment highlights + await superdoc.assertCommentHighlightExists({ text: 'new text' }); + await superdoc.assertCommentHighlightExists({ text: 'Test' }); + + // Click on the "new text" comment highlight to activate its dialog + await superdoc.clickOnCommentedText('new text'); + await superdoc.waitForStable(); + + // Find the dialog that contains "new text" tracked change info + const dialog = superdoc.page.locator('.floating-comment > .comments-dialog', { + has: superdoc.page.locator('.tracked-change-text', { hasText: 'new text' }), + }); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // The tracked change should show "Added:" and "Deleted:" labels + await expect(dialog.locator('.change-type', { hasText: 'Added' }).first()).toBeVisible(); + await expect(dialog.locator('.tracked-change-text', { hasText: 'new text' })).toBeVisible(); + await expect(dialog.locator('.change-type', { hasText: 'Deleted' }).first()).toBeVisible(); + + // The threaded comment replies should be visible below the tracked change + const commentBodies = dialog.locator('.comment-body .comment'); + await expect(commentBodies).toHaveCount(2); + await expect(commentBodies.nth(0)).toContainText('reply to tracked change'); + await expect(commentBodies.nth(1)).toContainText('reply to reply'); + + await superdoc.snapshot('comment thread on tracked change'); +}); + +test('clicking a different comment activates its dialog', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.page.waitForSelector('.superdoc-comment-highlight', { timeout: 30_000 }); + await superdoc.waitForStable(); + await assertDocumentApiReady(superdoc.page); + + // Click on the "Test" comment highlight + await superdoc.clickOnCommentedText('Test'); + await superdoc.waitForStable(); + + // The active dialog should switch to the clicked "Test" thread + const activeDialog = superdoc.page.locator('.floating-comment > .comments-dialog.is-active').last(); + await expect(activeDialog).toBeVisible({ timeout: 5_000 }); + const activeComments = activeDialog.locator('.comment-body .comment'); + await expect(activeComments).toHaveCount(2); + await expect(activeComments.nth(0)).toContainText('abc'); + await expect(activeComments.nth(1)).toContainText('xyz'); + + // Click away to deselect + await superdoc.clickOnLine(4); + await superdoc.waitForStable(); + await expect(superdoc.page.locator('.floating-comment > .comments-dialog.is-active')).toHaveCount(0); + + await superdoc.snapshot('comment deselected after clicking away'); +}); diff --git a/tests/behavior/tests/comments/comments-tcs-regression-suite.spec.ts b/tests/behavior/tests/comments/comments-tcs-regression-suite.spec.ts new file mode 100644 index 000000000..ae1cdabe1 --- /dev/null +++ b/tests/behavior/tests/comments/comments-tcs-regression-suite.spec.ts @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady, listComments, listTrackChanges } from '../../helpers/document-api.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const COMMENTS_TCS_DIR = path.resolve(__dirname, '../../test-data/comments-tcs'); + +type RegressionExpectation = { + file: string; + comments: number; + trackChanges: number; + highlights: number; + trackTypes: { insert: number; delete: number; format: number }; + highlightTexts: string[]; +}; + +const expectationsPath = path.join(COMMENTS_TCS_DIR, 'expectations.json'); +const EXPECTATIONS: RegressionExpectation[] = fs.existsSync(expectationsPath) + ? JSON.parse(fs.readFileSync(expectationsPath, 'utf-8')) + : []; + +function normalizeText(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +async function listHighlightTexts(page: import('@playwright/test').Page): Promise { + return page.evaluate(() => + Array.from(document.querySelectorAll('.superdoc-comment-highlight')) + .map((el) => (el.textContent ?? '').replace(/\s+/g, ' ').trim()) + .filter(Boolean), + ); +} + +test.skip(!fs.existsSync(COMMENTS_TCS_DIR), 'Test documents not available — run pnpm corpus:pull'); + +test.use({ config: { toolbar: 'full', comments: 'panel', trackChanges: true } }); + +for (const expectation of EXPECTATIONS) { + test(`comments+tcs regression: ${expectation.file}`, async ({ superdoc }) => { + const filePath = path.join(COMMENTS_TCS_DIR, expectation.file); + if (!fs.existsSync(filePath)) { + test.skip(true, `Missing fixture: ${expectation.file}`); + return; + } + + await superdoc.loadDocument(filePath); + await superdoc.waitForStable(); + await assertDocumentApiReady(superdoc.page); + + await expect + .poll(async () => (await listComments(superdoc.page, { includeResolved: true })).total) + .toBe(expectation.comments); + + await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBe(expectation.trackChanges); + + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { type: 'insert' })).total) + .toBe(expectation.trackTypes.insert); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { type: 'delete' })).total) + .toBe(expectation.trackTypes.delete); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { type: 'format' })).total) + .toBe(expectation.trackTypes.format); + + await expect.poll(async () => (await listHighlightTexts(superdoc.page)).length).toBe(expectation.highlights); + + const expectedHighlightTexts = expectation.highlightTexts.map(normalizeText).sort(); + await expect + .poll(async () => (await listHighlightTexts(superdoc.page)).map(normalizeText).sort()) + .toEqual(expectedHighlightTexts); + }); +} diff --git a/tests/behavior/tests/comments/edit-comment-text.spec.ts b/tests/behavior/tests/comments/edit-comment-text.spec.ts new file mode 100644 index 000000000..028d18de7 --- /dev/null +++ b/tests/behavior/tests/comments/edit-comment-text.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady, listComments } from '../../helpers/document-api.js'; + +test.use({ config: { toolbar: 'full', comments: 'on' } }); + +test('editing a comment updates its text', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('hello comments'); + await superdoc.waitForStable(); + + // Select "comments" and add an initial comment through the UI. + const pos = await superdoc.findTextPos('comments'); + await superdoc.setTextSelection(pos, pos + 'comments'.length); + await superdoc.waitForStable(); + + const bubble = superdoc.page.locator('.superdoc__tools'); + await expect(bubble).toBeVisible({ timeout: 5_000 }); + await bubble.locator('[data-id="is-tool"]').click(); + await superdoc.waitForStable(); + + const pendingDialog = superdoc.page.locator('.comments-dialog').first(); + await pendingDialog.locator('.comment-entry .editor-element').first().click(); + await superdoc.page.keyboard.type('original comment'); + await superdoc.waitForStable(); + await pendingDialog.locator('.sd-button.primary', { hasText: 'Comment' }).first().click(); + await superdoc.waitForStable(); + + // Click on the comment highlight to activate the floating dialog + await superdoc.clickOnCommentedText('comments'); + await superdoc.waitForStable(); + + // The active dialog should show the submitted comment (use .last() to skip measure layer) + const activeDialog = superdoc.page.locator('.comments-dialog.is-active').last(); + await expect(activeDialog).toBeVisible({ timeout: 5_000 }); + await expect(activeDialog.locator('.comment-body .comment').first()).toContainText('original comment'); + + // Open the overflow "..." menu and click Edit + await activeDialog.locator('.overflow-icon').click(); + await superdoc.waitForStable(); + + const editOption = superdoc.page.locator('.n-dropdown-option-body__label', { hasText: 'Edit' }); + await expect(editOption.first()).toBeVisible({ timeout: 5_000 }); + await editOption.first().click(); + await superdoc.waitForStable(); + + // The comment should now be in edit mode + const editInput = activeDialog.locator('.comment-editing .editor-element'); + await expect(editInput).toBeVisible({ timeout: 5_000 }); + + // Select all text in the edit input, then type the replacement + await editInput.click(); + await superdoc.shortcut('a'); + await superdoc.page.keyboard.type('changed comment'); + await superdoc.waitForStable(); + + // Click Update + await activeDialog.locator('.comment-editing .sd-button.primary', { hasText: 'Update' }).click(); + await superdoc.waitForStable(); + + // After update the dialog loses is-active; verify the text changed via the visible sidebar dialog + const updatedDialog = superdoc.page.locator('.floating-comment > .comments-dialog'); + await expect(updatedDialog.locator('.comment-body .comment').first()).toContainText('changed comment'); + // CommentInfo.text is optional in the contract — some adapters don't populate it. + // Verify via the API when available; the DOM assertion above covers all adapters. + const listed = await listComments(superdoc.page, { includeResolved: true }); + expect(listed.total).toBeGreaterThanOrEqual(1); + const commentTexts = listed.matches.map((e) => e.text).filter(Boolean); + if (commentTexts.length > 0) { + expect(commentTexts).toContain('changed comment'); + } + + // Comment highlight should still exist + await superdoc.assertCommentHighlightExists({ text: 'comments' }); + + await superdoc.snapshot('comment edited'); +}); diff --git a/tests/behavior/tests/comments/nested-comments.spec.ts b/tests/behavior/tests/comments/nested-comments.spec.ts new file mode 100644 index 000000000..f16a1d66f --- /dev/null +++ b/tests/behavior/tests/comments/nested-comments.spec.ts @@ -0,0 +1,134 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady, listComments } from '../../helpers/document-api.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const GDOCS_PATH = path.resolve(__dirname, '../../test-data/comments-tcs/nested-comments-gdocs.docx'); +const WORD_PATH = path.resolve(__dirname, '../../test-data/comments-tcs/nested-comments-word.docx'); + +test.use({ config: { toolbar: 'full', comments: 'panel' } }); + +// --------------------------------------------------------------------------- +// Google Docs nested/overlapping comments +// --------------------------------------------------------------------------- + +test.describe('nested comments from Google Docs', () => { + test.skip(!fs.existsSync(GDOCS_PATH), 'Test document not available — run pnpm corpus:pull'); + + test('overlapping comment highlights exist and dialogs activate on click', async ({ superdoc }) => { + await superdoc.loadDocument(GDOCS_PATH); + await superdoc.page.waitForSelector('.superdoc-comment-highlight', { timeout: 30_000 }); + await superdoc.waitForStable(); + await assertDocumentApiReady(superdoc.page); + + // Multiple comment highlights should be present + const highlights = superdoc.page.locator('.superdoc-comment-highlight'); + const count = await highlights.count(); + expect(count).toBe(7); + await expect.poll(async () => (await listComments(superdoc.page, { includeResolved: true })).total).toBe(5); + + // Click "Licensee" — dialog shows "licensee...distribute" + "modify" replies + await superdoc.clickOnCommentedText('Licensee'); + await superdoc.waitForStable(); + + const licenseeDialog = superdoc.page.locator('.floating-comment > .comments-dialog.is-active').last(); + await expect(licenseeDialog).toBeVisible({ timeout: 5_000 }); + const licenseeComments = licenseeDialog.locator('.comment-body .comment'); + await expect(licenseeComments).toHaveCount(2); + await expect(licenseeComments.nth(0)).toContainText('licensee'); + await expect(licenseeComments.nth(1)).toContainText('modify'); + + // Click "proprietary" — different comment activates + await superdoc.clickOnCommentedText('proprietary'); + await superdoc.waitForStable(); + + const proprietaryDialog = superdoc.page.locator('.floating-comment > .comments-dialog.is-active').last(); + await expect(proprietaryDialog).toBeVisible({ timeout: 5_000 }); + await expect(proprietaryDialog.locator('.comment-body .comment').first()).toContainText('proprietary notices'); + + // Click "labels" — shows comment with reply + await superdoc.clickOnCommentedText('labels'); + await superdoc.waitForStable(); + + const labelsDialog = superdoc.page.locator('.floating-comment > .comments-dialog.is-active').last(); + await expect(labelsDialog).toBeVisible({ timeout: 5_000 }); + const labelsComments = labelsDialog.locator('.comment-body .comment'); + await expect(labelsComments).toHaveCount(2); + await expect(labelsComments.nth(0)).toContainText('notices or labels'); + await expect(labelsComments.nth(1)).toContainText('with reply'); + + // Click away to deselect — no active dialog + await superdoc.clickOnLine(1, 50); + await superdoc.waitForStable(); + await expect(superdoc.page.locator('.floating-comment > .comments-dialog.is-active')).toHaveCount(0); + + await superdoc.snapshot('nested-comments-gdocs'); + }); +}); + +// --------------------------------------------------------------------------- +// MS Word nested/overlapping comments +// --------------------------------------------------------------------------- + +test.describe('nested comments from MS Word', () => { + test.skip(!fs.existsSync(WORD_PATH), 'Test document not available — run pnpm corpus:pull'); + + test('overlapping comment highlights exist and dialogs activate on click', async ({ superdoc }) => { + await superdoc.loadDocument(WORD_PATH); + await superdoc.page.waitForSelector('.superdoc-comment-highlight', { timeout: 30_000 }); + await superdoc.waitForStable(); + await assertDocumentApiReady(superdoc.page); + + // Multiple comment highlights should be present + const highlights = superdoc.page.locator('.superdoc-comment-highlight'); + const count = await highlights.count(); + expect(count).toBe(7); + await expect.poll(async () => (await listComments(superdoc.page, { includeResolved: true })).total).toBe(5); + + // Click "modify" — dialog shows "comment on modify" + await superdoc.clickOnCommentedText('modify'); + await superdoc.waitForStable(); + + const modifyDialog = superdoc.page.locator('.floating-comment > .comments-dialog.is-active').last(); + await expect(modifyDialog).toBeVisible({ timeout: 5_000 }); + await expect(modifyDialog.locator('.comment-body .comment').first()).toContainText('comment on modify'); + + // Click "Licensee" — different, broader comment activates + await superdoc.clickOnCommentedText('Licensee'); + await superdoc.waitForStable(); + + const licenseeDialog = superdoc.page.locator('.floating-comment > .comments-dialog.is-active').last(); + await expect(licenseeDialog).toBeVisible({ timeout: 5_000 }); + await expect(licenseeDialog.locator('.comment-body .comment').first()).toContainText( + 'comment from licensee to distribute', + ); + + // Click "proprietary" — shows "proprietary notices" + await superdoc.clickOnCommentedText('proprietary'); + await superdoc.waitForStable(); + + const proprietaryDialog = superdoc.page.locator('.floating-comment > .comments-dialog.is-active').last(); + await expect(proprietaryDialog).toBeVisible({ timeout: 5_000 }); + await expect(proprietaryDialog.locator('.comment-body .comment').first()).toContainText('proprietary notices'); + + // Click "labels" — comment with reply thread + await superdoc.clickOnCommentedText('labels'); + await superdoc.waitForStable(); + + const labelsDialog = superdoc.page.locator('.floating-comment > .comments-dialog.is-active').last(); + await expect(labelsDialog).toBeVisible({ timeout: 5_000 }); + const labelsComments = labelsDialog.locator('.comment-body .comment'); + await expect(labelsComments).toHaveCount(2); + await expect(labelsComments.nth(0)).toContainText('notices or labels'); + await expect(labelsComments.nth(1)).toContainText('with reply'); + + // Click away to deselect — no active dialog + await superdoc.clickOnLine(1, 50); + await superdoc.waitForStable(); + await expect(superdoc.page.locator('.floating-comment > .comments-dialog.is-active')).toHaveCount(0); + + await superdoc.snapshot('nested-comments-word'); + }); +}); diff --git a/tests/behavior/tests/comments/programmatic-tracked-change.spec.ts b/tests/behavior/tests/comments/programmatic-tracked-change.spec.ts new file mode 100644 index 000000000..36f80b838 --- /dev/null +++ b/tests/behavior/tests/comments/programmatic-tracked-change.spec.ts @@ -0,0 +1,159 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { + assertDocumentApiReady, + deleteText, + findFirstTextRange, + getDocumentText, + insertText, + listTrackChanges, + replaceText, +} from '../../helpers/document-api.js'; +import type { TextAddress, TextMutationReceipt } from '../../helpers/document-api.js'; + +test.use({ config: { toolbar: 'full', comments: 'panel', trackChanges: true } }); + +async function assertTrackChangeTypeCount( + superdoc: { page: Page }, + type: 'insert' | 'delete' | 'format', + minimumCount = 1, +): Promise { + await expect + .poll(async () => { + const listed = await listTrackChanges(superdoc.page, { type }); + return listed.total; + }) + .toBeGreaterThanOrEqual(minimumCount); +} + +function requireTextTarget(target: TextAddress | null, pattern: string): TextAddress { + if (target == null) { + throw new Error(`Could not find a text target for pattern "${pattern}".`); + } + return target; +} + +function assertMutationSucceeded( + operationName: string, + receipt: TextMutationReceipt, +): asserts receipt is Extract { + if (receipt.success) { + return; + } + + throw new Error(`${operationName} failed (${receipt.failure.code}): ${receipt.failure.message}`); +} + +test('tracked replace via document-api', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('Here is a tracked style change'); + await superdoc.waitForStable(); + + const target = requireTextTarget(await findFirstTextRange(superdoc.page, 'a tracked style'), 'a tracked style'); + + const receipt = await replaceText(superdoc.page, { target, text: 'new fancy' }, { changeMode: 'tracked' }); + assertMutationSucceeded('replaceText', receipt); + await superdoc.waitForStable(); + + await expect.poll(() => getDocumentText(superdoc.page)).toContain('new fancy'); + await assertTrackChangeTypeCount(superdoc, 'insert'); + + await superdoc.snapshot('programmatic-tc-replaced'); +}); + +test('tracked delete via document-api', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('Here is some text to delete'); + await superdoc.waitForStable(); + + const target = requireTextTarget(await findFirstTextRange(superdoc.page, 'Here'), 'Here'); + + const receipt = await deleteText(superdoc.page, { target }, { changeMode: 'tracked' }); + assertMutationSucceeded('deleteText', receipt); + await superdoc.waitForStable(); + + await assertTrackChangeTypeCount(superdoc, 'delete'); + + await superdoc.snapshot('programmatic-tc-deleted'); +}); + +test('direct insert via document-api', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('Hello World'); + await superdoc.waitForStable(); + + const target = requireTextTarget(await findFirstTextRange(superdoc.page, 'World'), 'World'); + + // insert requires a collapsed target range in the write adapter. + const insertionTarget: TextAddress = { + ...target, + range: { + start: target.range.start, + end: target.range.start, + }, + }; + + const receipt = await insertText(superdoc.page, { text: 'Beautiful ', target: insertionTarget }); + assertMutationSucceeded('insertText', receipt); + await superdoc.waitForStable(); + + await expect.poll(() => getDocumentText(superdoc.page)).toContain('Beautiful'); + + await superdoc.snapshot('programmatic-direct-insert'); +}); + +test('tracked insert at cursor position in suggesting mode', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('Hello World'); + await superdoc.waitForStable(); + + // Place cursor right before "World" + const pos = await superdoc.findTextPos('World'); + await superdoc.setTextSelection(pos); + await superdoc.waitForStable(); + + // Switch to suggesting mode and type — produces a tracked insertion + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + await superdoc.type('ABC '); + await superdoc.waitForStable(); + + await expect.poll(() => getDocumentText(superdoc.page)).toContain('ABC'); + await assertTrackChangeTypeCount(superdoc, 'insert'); + + await superdoc.snapshot('programmatic-tc-inserted'); +}); + +test('tracked insert with addToHistory:false survives undo', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('Hello World'); + await superdoc.waitForStable(); + + // addToHistory is a PM-level option not exposed through document-api, + // so this test uses the editor command directly to verify undo behavior. + await superdoc.page.evaluate(() => { + (window as any).editor.commands.insertTrackedChange({ + from: 1, + to: 1, + text: 'PERSISTENT ', + user: { name: 'No-History Bot' }, + addToHistory: false, + }); + }); + await superdoc.waitForStable(); + + await expect.poll(() => getDocumentText(superdoc.page)).toContain('PERSISTENT'); + + await superdoc.undo(); + await superdoc.waitForStable(); + + await expect.poll(() => getDocumentText(superdoc.page)).toContain('PERSISTENT'); + + await superdoc.snapshot('programmatic-tc-persistent-after-undo'); +}); diff --git a/tests/behavior/tests/comments/reject-format-suggestion.spec.ts b/tests/behavior/tests/comments/reject-format-suggestion.spec.ts new file mode 100644 index 000000000..d0b23b4d8 --- /dev/null +++ b/tests/behavior/tests/comments/reject-format-suggestion.spec.ts @@ -0,0 +1,190 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { rejectAllTrackChanges } from '../../helpers/document-api.js'; + +test.use({ config: { toolbar: 'full', comments: 'panel', trackChanges: true } }); + +const TEXT = 'Agreement signed by both parties'; + +// --------------------------------------------------------------------------- +// Command helpers — [commandName, ...args] tuples executed via editor.commands +// --------------------------------------------------------------------------- + +type EditorCommand = [name: string, ...args: unknown[]]; + +async function runCommands(page: Page, commands: EditorCommand[]): Promise { + for (const [name, ...args] of commands) { + await page.evaluate(({ name, args }) => (window as any).editor.commands[name](...args), { name, args }); + } +} + +// --------------------------------------------------------------------------- +// Test matrix — each entry describes one rejection scenario +// --------------------------------------------------------------------------- + +type FormatCase = { + name: string; + setup?: EditorCommand[]; + suggest: EditorCommand[]; + lacksMarks?: string[]; + restoredStyle?: Record; + restoredFontFamily?: string; + restoredFontSize?: string; +}; + +const SINGLE_MARK_CASES: FormatCase[] = [ + { + name: 'bold', + suggest: [['toggleBold']], + lacksMarks: ['bold'], + }, + { + name: 'italic', + suggest: [['toggleItalic']], + lacksMarks: ['italic'], + }, + { + name: 'underline', + suggest: [['toggleUnderline']], + lacksMarks: ['underline'], + }, + { + name: 'strikethrough', + suggest: [['toggleStrike']], + lacksMarks: ['strike'], + }, +]; + +const STYLE_CASES: FormatCase[] = [ + { + name: 'color', + setup: [ + ['setFontFamily', 'Times New Roman, serif'], + ['setColor', '#112233'], + ], + suggest: [['setColor', '#FF0000']], + restoredStyle: { color: '#112233' }, + }, + { + name: 'font family', + setup: [ + ['setFontFamily', 'Times New Roman, serif'], + ['setColor', '#112233'], + ], + suggest: [['setFontFamily', 'Arial, sans-serif']], + restoredFontFamily: 'Times New Roman', + }, + { + name: 'font size', + setup: [['setFontSize', '16pt']], + suggest: [['setFontSize', '24pt']], + restoredFontSize: '16', + }, +]; + +const COMBINATION_CASES: FormatCase[] = [ + { + name: 'multiple marks', + suggest: [['toggleBold'], ['toggleItalic'], ['toggleUnderline']], + lacksMarks: ['bold', 'italic', 'underline'], + }, + { + name: 'multiple textStyle properties', + setup: [ + ['setFontFamily', 'Arial, sans-serif'], + ['setColor', '#112233'], + ['setFontSize', '16pt'], + ], + suggest: [ + ['setColor', '#FF00AA'], + ['setFontFamily', 'Courier New'], + ['setFontSize', '18pt'], + ], + restoredStyle: { color: '#112233' }, + restoredFontFamily: 'Arial', + restoredFontSize: '16', + }, + { + name: 'mixed marks and textStyle', + setup: [ + ['setFontFamily', 'Arial, sans-serif'], + ['setColor', '#112233'], + ], + suggest: [ + ['toggleBold'], + ['toggleUnderline'], + ['setColor', '#FF00AA'], + ['setFontFamily', 'Times New Roman, serif'], + ], + lacksMarks: ['bold', 'underline'], + restoredStyle: { color: '#112233' }, + restoredFontFamily: 'Arial', + }, +]; + +const ALL_CASES = [ + ...SINGLE_MARK_CASES.map((c) => ({ ...c, name: `reject tracked ${c.name} suggestion` })), + ...STYLE_CASES.map((c) => ({ ...c, name: `reject tracked ${c.name} suggestion` })), + ...COMBINATION_CASES.map((c) => ({ ...c, name: `reject ${c.name} suggestions restores original` })), +]; + +for (const tc of ALL_CASES) { + test(tc.name, async ({ superdoc }) => { + await superdoc.type(TEXT); + await superdoc.waitForStable(); + + // Optional: set initial styles in editing mode. + if (tc.setup) { + await superdoc.selectAll(); + await runCommands(superdoc.page, tc.setup); + await superdoc.waitForStable(); + } + + // Switch to suggesting mode. + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + // Apply the suggested format change. + await superdoc.selectAll(); + await runCommands(superdoc.page, tc.suggest); + await superdoc.waitForStable(); + + await superdoc.assertTrackedChangeExists('format'); + + // Reject all tracked changes. + await rejectAllTrackChanges(superdoc.page); + await superdoc.waitForStable(); + + // No tracked format decorations should remain. + await expect(superdoc.page.locator('.track-format-dec')).toHaveCount(0); + + // Verify marks were removed. + if (tc.lacksMarks) { + await superdoc.assertTextLacksMarks('Agreement', tc.lacksMarks); + } + + // Verify textStyle attrs were restored. + if (tc.restoredStyle) { + await superdoc.assertTextMarkAttrs('Agreement', 'textStyle', tc.restoredStyle); + } + + // Verify toolbar shows restored font family. + if (tc.restoredFontFamily) { + await superdoc.selectAll(); + await superdoc.waitForStable(); + await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .button-label')).toHaveText( + tc.restoredFontFamily, + ); + } + + // Verify toolbar shows restored font size. + if (tc.restoredFontSize) { + await superdoc.selectAll(); + await superdoc.waitForStable(); + await expect(superdoc.page.locator('#inlineTextInput-fontSize')).toHaveValue(tc.restoredFontSize); + } + + // Document text should always be unchanged. + await superdoc.assertTextContent(TEXT); + }); +} diff --git a/tests/behavior/tests/comments/tracked-change-existing-doc.spec.ts b/tests/behavior/tests/comments/tracked-change-existing-doc.spec.ts new file mode 100644 index 000000000..90f3e5f4b --- /dev/null +++ b/tests/behavior/tests/comments/tracked-change-existing-doc.spec.ts @@ -0,0 +1,61 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady, getDocumentText, listTrackChanges } from '../../helpers/document-api.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, '../../test-data/comments-tcs/tracked-changes.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); + +test.use({ config: { toolbar: 'full', comments: 'on', trackChanges: true } }); + +test('tracked change replacement in existing document', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + await assertDocumentApiReady(superdoc.page); + + const textBefore = await getDocumentText(superdoc.page); + expect(textBefore.length).toBeGreaterThan(0); + + // Grab the first line's text before replacing + const firstLineText = await superdoc.page.locator('.superdoc-line').first().textContent(); + expect(firstLineText).toBeTruthy(); + + // Switch to suggesting mode + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + await superdoc.assertDocumentMode('suggesting'); + + // Select the first line and type a replacement + await superdoc.tripleClickLine(0); + await superdoc.waitForStable(); + + await superdoc.type('programmatically inserted'); + await superdoc.waitForStable(); + + await expect.poll(() => getDocumentText(superdoc.page)).toContain('programmatically inserted'); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { type: 'insert' })).total) + .toBeGreaterThanOrEqual(1); + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { type: 'delete' })).total) + .toBeGreaterThanOrEqual(1); + + // The floating comment dialog for our change should appear with tracked change details. + // Scope to .floating-comment > .comments-dialog to skip the measurement-layer duplicate. + const dialog = superdoc.page.locator('.floating-comment > .comments-dialog', { + has: superdoc.page.locator('.tracked-change-text', { hasText: 'programmatically inserted' }), + }); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // It should show the "Added:" label with the new content + await expect(dialog.locator('.change-type', { hasText: 'Added' }).first()).toBeVisible(); + await expect(dialog.locator('.tracked-change-text', { hasText: 'programmatically inserted' })).toBeVisible(); + + // It should show the "Deleted:" label with the original content + await expect(dialog.locator('.change-type', { hasText: 'Deleted' }).first()).toBeVisible(); + + await superdoc.snapshot('tracked change replacement in existing doc'); +}); diff --git a/tests/behavior/tests/comments/tracked-change-replacement-bubble.spec.ts b/tests/behavior/tests/comments/tracked-change-replacement-bubble.spec.ts new file mode 100644 index 000000000..a381624fa --- /dev/null +++ b/tests/behavior/tests/comments/tracked-change-replacement-bubble.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady, listTrackChanges } from '../../helpers/document-api.js'; + +test.use({ config: { toolbar: 'full', comments: 'on', trackChanges: true } }); + +test('SD-1739 tracked change replacement does not duplicate text in bubble', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('editing'); + await superdoc.waitForStable(); + + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + // Select "editing" and replace with "redlining" + await superdoc.tripleClickLine(0); + await superdoc.waitForStable(); + await superdoc.type('redlining'); + await superdoc.waitForStable(); + + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { type: 'insert' })).total) + .toBeGreaterThanOrEqual(1); + await expect.poll(async () => (await listTrackChanges(superdoc.page)).total).toBeGreaterThanOrEqual(1); + + // The floating dialog should show the tracked change with correct text + // (Bug SD-1739 would show "Added: redliningg" with duplicated trailing char) + const dialog = superdoc.page.locator('.floating-comment > .comments-dialog', { + has: superdoc.page.locator('.tracked-change-text'), + }); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // "Added:" label with "redlining" text — must NOT contain "redliningg" + const addedText = dialog.locator('.tracked-change-text').first(); + await expect(addedText).toContainText('redlining'); + // Verify exact text doesn't have the duplication bug + const textContent = await addedText.textContent(); + expect(textContent).not.toContain('redliningg'); + + // "Deleted:" label with "editing" text + await expect(dialog.locator('.change-type', { hasText: 'Deleted' }).first()).toBeVisible(); + + await superdoc.snapshot('tracked-change-replacement-bubble'); +}); diff --git a/tests/behavior/tests/comments/type-after-fully-deleted-content.spec.ts b/tests/behavior/tests/comments/type-after-fully-deleted-content.spec.ts new file mode 100644 index 000000000..ee79a5528 --- /dev/null +++ b/tests/behavior/tests/comments/type-after-fully-deleted-content.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady, getDocumentText, listTrackChanges } from '../../helpers/document-api.js'; + +test.use({ config: { toolbar: 'full', comments: 'on', trackChanges: true } }); + +test('typing after fully track-deleted content produces correct text', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('Hello World'); + await superdoc.waitForStable(); + await expect.poll(() => getDocumentText(superdoc.page)).toBe('Hello World'); + + // Switch to suggesting mode + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + + // Select all and delete + await superdoc.selectAll(); + await superdoc.waitForStable(); + await superdoc.press('Backspace'); + await superdoc.waitForStable(); + + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { type: 'delete' })).total) + .toBeGreaterThanOrEqual(1); + + // Type new text — a cursor-positioning bug would produce "TSET" instead of "TEST" + await superdoc.type('TEST'); + await superdoc.waitForStable(); + + // Assert "TEST" appears in the document (not "TSET") + await expect.poll(() => getDocumentText(superdoc.page)).toContain('TEST'); + await expect.poll(() => getDocumentText(superdoc.page)).not.toContain('TSET'); + + await expect + .poll(async () => (await listTrackChanges(superdoc.page, { type: 'insert' })).total) + .toBeGreaterThanOrEqual(1); + + await superdoc.snapshot('type-after-fully-deleted-content'); +}); diff --git a/tests/behavior/tests/field-annotations/annotation-formatting.spec.ts b/tests/behavior/tests/field-annotations/annotation-formatting.spec.ts new file mode 100644 index 000000000..966e4c02c --- /dev/null +++ b/tests/behavior/tests/field-annotations/annotation-formatting.spec.ts @@ -0,0 +1,118 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { replaceTextWithAnnotation } from '../../helpers/field-annotations.js'; + +test('field annotations render with bold, italic, underline formatting', async ({ superdoc }) => { + // Type placeholders + await superdoc.type('[PLAIN]'); + await superdoc.newLine(); + await superdoc.type('[BOLD]'); + await superdoc.newLine(); + await superdoc.type('[ITALIC]'); + await superdoc.newLine(); + await superdoc.type('[UNDERLINE]'); + await superdoc.newLine(); + await superdoc.type('[BOLD_ITALIC]'); + await superdoc.newLine(); + await superdoc.type('[ALL]'); + await superdoc.waitForStable(); + + // Replace each placeholder with a field annotation + await replaceTextWithAnnotation(superdoc.page, '[PLAIN]', { displayLabel: 'Plain text', fieldId: 'field-plain' }); + await replaceTextWithAnnotation(superdoc.page, '[BOLD]', { + displayLabel: 'Bold text', + fieldId: 'field-bold', + bold: true, + }); + await replaceTextWithAnnotation(superdoc.page, '[ITALIC]', { + displayLabel: 'Italic text', + fieldId: 'field-italic', + italic: true, + }); + await replaceTextWithAnnotation(superdoc.page, '[UNDERLINE]', { + displayLabel: 'Underlined', + fieldId: 'field-underline', + underline: true, + }); + await replaceTextWithAnnotation(superdoc.page, '[BOLD_ITALIC]', { + displayLabel: 'Bold italic', + fieldId: 'field-bi', + bold: true, + italic: true, + }); + await replaceTextWithAnnotation(superdoc.page, '[ALL]', { + displayLabel: 'All formats', + fieldId: 'field-all', + bold: true, + italic: true, + underline: true, + }); + await superdoc.waitForStable(); + + // Use DomPainter annotations (inside .superdoc-line) to avoid PM DOM duplicates + const annotation = (fieldId: string) => + superdoc.page.locator(`.superdoc-line .annotation[data-field-id="${fieldId}"]`); + + // All 6 annotations should exist with correct display labels + await expect(annotation('field-plain').locator('.annotation-content')).toHaveText('Plain text'); + await expect(annotation('field-bold').locator('.annotation-content')).toHaveText('Bold text'); + await expect(annotation('field-italic').locator('.annotation-content')).toHaveText('Italic text'); + await expect(annotation('field-underline').locator('.annotation-content')).toHaveText('Underlined'); + await expect(annotation('field-bi').locator('.annotation-content')).toHaveText('Bold italic'); + await expect(annotation('field-all').locator('.annotation-content')).toHaveText('All formats'); + + // Plain: no formatting styles + await expect(annotation('field-plain')).not.toHaveCSS('font-weight', /bold|700/); + await expect(annotation('field-plain')).not.toHaveCSS('font-style', 'italic'); + + // Bold: font-weight bold + await expect(annotation('field-bold')).toHaveCSS('font-weight', /bold|700/); + + // Italic: font-style italic + await expect(annotation('field-italic')).toHaveCSS('font-style', 'italic'); + + // Underline: text-decoration includes underline + const underlineDecoration = await annotation('field-underline').evaluate( + (el: HTMLElement) => getComputedStyle(el).textDecorationLine || getComputedStyle(el).textDecoration, + ); + expect(underlineDecoration).toContain('underline'); + + // Bold+Italic: both + await expect(annotation('field-bi')).toHaveCSS('font-weight', /bold|700/); + await expect(annotation('field-bi')).toHaveCSS('font-style', 'italic'); + + // All formats: bold + italic + underline + await expect(annotation('field-all')).toHaveCSS('font-weight', /bold|700/); + await expect(annotation('field-all')).toHaveCSS('font-style', 'italic'); + const allDecoration = await annotation('field-all').evaluate( + (el: HTMLElement) => getComputedStyle(el).textDecorationLine || getComputedStyle(el).textDecoration, + ); + expect(allDecoration).toContain('underline'); + + // Verify PM nodes have correct attrs + const pmNodes = await superdoc.page.evaluate(() => { + const doc = (window as any).editor.state.doc; + const nodes: Array<{ fieldId: string; bold: boolean; italic: boolean; underline: boolean }> = []; + doc.descendants((node: any) => { + if (node.type.name === 'fieldAnnotation') { + nodes.push({ + fieldId: node.attrs.fieldId, + bold: node.attrs.bold, + italic: node.attrs.italic, + underline: node.attrs.underline, + }); + } + }); + return nodes; + }); + + expect(pmNodes).toHaveLength(6); + const byId = Object.fromEntries(pmNodes.map((n) => [n.fieldId, n])); + expect(byId['field-plain']).toMatchObject({ bold: false, italic: false, underline: false }); + expect(byId['field-bold']).toMatchObject({ bold: true, italic: false, underline: false }); + expect(byId['field-italic']).toMatchObject({ bold: false, italic: true, underline: false }); + expect(byId['field-underline']).toMatchObject({ bold: false, italic: false, underline: true }); + expect(byId['field-bi']).toMatchObject({ bold: true, italic: true, underline: false }); + expect(byId['field-all']).toMatchObject({ bold: true, italic: true, underline: true }); + + await superdoc.snapshot('annotation-formatting'); +}); diff --git a/tests/behavior/tests/field-annotations/insert-all-types.spec.ts b/tests/behavior/tests/field-annotations/insert-all-types.spec.ts new file mode 100644 index 000000000..2110aabab --- /dev/null +++ b/tests/behavior/tests/field-annotations/insert-all-types.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { replaceTextWithAnnotation } from '../../helpers/field-annotations.js'; + +test('insert all 6 field annotation types', async ({ superdoc }) => { + await superdoc.type('[NAME]'); + await superdoc.newLine(); + await superdoc.type('[CHECKBOX]'); + await superdoc.newLine(); + await superdoc.type('[SIGNATURE]'); + await superdoc.newLine(); + await superdoc.type('[IMAGE]'); + await superdoc.newLine(); + await superdoc.type('[LINK]'); + await superdoc.newLine(); + await superdoc.type('[HTML]'); + await superdoc.waitForStable(); + + await replaceTextWithAnnotation(superdoc.page, '[NAME]', { + type: 'text', + displayLabel: 'Enter name', + fieldId: 'field-name', + }); + await replaceTextWithAnnotation(superdoc.page, '[CHECKBOX]', { + type: 'checkbox', + displayLabel: '☐', + fieldId: 'field-checkbox', + }); + await replaceTextWithAnnotation(superdoc.page, '[SIGNATURE]', { + type: 'signature', + displayLabel: 'Sign here', + fieldId: 'field-signature', + }); + await replaceTextWithAnnotation(superdoc.page, '[IMAGE]', { + type: 'image', + displayLabel: 'Add photo', + fieldId: 'field-image', + }); + await replaceTextWithAnnotation(superdoc.page, '[LINK]', { + type: 'link', + displayLabel: 'example.com', + fieldId: 'field-link', + linkUrl: 'https://example.com', + }); + await replaceTextWithAnnotation(superdoc.page, '[HTML]', { + type: 'html', + displayLabel: '', + fieldId: 'field-html', + rawHtml: '

Custom HTML

', + }); + await superdoc.waitForStable(); + + // All 6 annotations should exist in the rendered DOM + const annotation = (fieldId: string) => + superdoc.page.locator(`.superdoc-line .annotation[data-field-id="${fieldId}"]`); + + await expect(annotation('field-name')).toBeVisible(); + await expect(annotation('field-checkbox')).toBeVisible(); + await expect(annotation('field-signature')).toBeVisible(); + await expect(annotation('field-image')).toBeVisible(); + await expect(annotation('field-link')).toBeVisible(); + await expect(annotation('field-html')).toBeVisible(); + + // Each annotation should have the correct data-type attribute + await expect(annotation('field-name')).toHaveAttribute('data-type', 'text'); + await expect(annotation('field-checkbox')).toHaveAttribute('data-type', 'checkbox'); + await expect(annotation('field-signature')).toHaveAttribute('data-type', 'signature'); + await expect(annotation('field-image')).toHaveAttribute('data-type', 'image'); + await expect(annotation('field-link')).toHaveAttribute('data-type', 'link'); + await expect(annotation('field-html')).toHaveAttribute('data-type', 'html'); + + // Display labels should match + await expect(annotation('field-name')).toHaveAttribute('data-display-label', 'Enter name'); + await expect(annotation('field-checkbox')).toHaveAttribute('data-display-label', '☐'); + await expect(annotation('field-signature')).toHaveAttribute('data-display-label', 'Sign here'); + await expect(annotation('field-image')).toHaveAttribute('data-display-label', 'Add photo'); + await expect(annotation('field-link')).toHaveAttribute('data-display-label', 'example.com'); + await expect(annotation('field-html')).toHaveAttribute('data-display-label', ''); + + // Verify PM nodes have correct types and attrs + const pmNodes = await superdoc.page.evaluate(() => { + const doc = (window as any).editor.state.doc; + const nodes: Array<{ fieldId: string; type: string; linkUrl: string | null; rawHtml: string | null }> = []; + doc.descendants((node: any) => { + if (node.type.name === 'fieldAnnotation') { + nodes.push({ + fieldId: node.attrs.fieldId, + type: node.attrs.type, + linkUrl: node.attrs.linkUrl, + rawHtml: node.attrs.rawHtml, + }); + } + }); + return nodes; + }); + + expect(pmNodes).toHaveLength(6); + const byId = Object.fromEntries(pmNodes.map((n) => [n.fieldId, n])); + expect(byId['field-name'].type).toBe('text'); + expect(byId['field-checkbox'].type).toBe('checkbox'); + expect(byId['field-signature'].type).toBe('signature'); + expect(byId['field-image'].type).toBe('image'); + expect(byId['field-link'].type).toBe('link'); + expect(byId['field-link'].linkUrl).toBe('https://example.com'); + expect(byId['field-html'].type).toBe('html'); + expect(byId['field-html'].rawHtml).toContain('Custom HTML'); + + await superdoc.snapshot('insert-all-types'); +}); diff --git a/tests/behavior/tests/field-annotations/table-cell-leading-caret.spec.ts b/tests/behavior/tests/field-annotations/table-cell-leading-caret.spec.ts new file mode 100644 index 000000000..9417f1dd7 --- /dev/null +++ b/tests/behavior/tests/field-annotations/table-cell-leading-caret.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test('cursor placement and typing before field annotation at start of table cell', async ({ superdoc }) => { + // Insert a 2x2 table + await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false }); + await superdoc.waitForStable(); + await superdoc.assertTableExists(2, 2); + + // Insert field annotation at cursor (start of first cell) + await superdoc.page.evaluate(() => { + const editor = (window as any).editor; + editor.commands.addFieldAnnotationAtSelection({ + type: 'text', + displayLabel: 'Enter value', + fieldId: 'field-in-cell', + fieldColor: '#6366f1', + highlighted: true, + }); + }); + await superdoc.waitForStable(); + + // Annotation should be inside the table + const annotation = superdoc.page.locator('.superdoc-line .annotation[data-field-id="field-in-cell"]'); + await expect(annotation).toBeVisible(); + await expect(annotation).toHaveAttribute('data-display-label', 'Enter value'); + + // Navigate to start of cell (before the annotation) + // Use programmatic cursor placement instead of Home key — webkit handles + // Home differently inside table cells and doesn't reliably move before atoms. + await superdoc.page.evaluate(() => { + const editor = (window as any).editor; + const { doc } = editor.state; + let annotationPos: number | null = null; + doc.descendants((node: any, pos: number) => { + if (node.type.name === 'fieldAnnotation' && node.attrs.fieldId === 'field-in-cell') { + annotationPos = pos; + } + }); + if (annotationPos !== null) { + editor.commands.setTextSelection(annotationPos); + } + }); + await superdoc.waitForStable(); + + // Type before annotation — text should appear before the annotation, not after + await superdoc.type('Prefix: '); + await superdoc.waitForStable(); + + // The annotation should still exist + await expect(annotation).toBeVisible(); + + // The typed text should be in the document + await superdoc.assertTextContains('Prefix:'); + + // Verify the annotation PM node still exists with correct attrs + const pmNode = await superdoc.page.evaluate(() => { + const doc = (window as any).editor.state.doc; + let found: any = null; + doc.descendants((node: any) => { + if (node.type.name === 'fieldAnnotation' && node.attrs.fieldId === 'field-in-cell') { + found = { type: node.attrs.type, displayLabel: node.attrs.displayLabel }; + } + }); + return found; + }); + expect(pmNode).toBeTruthy(); + expect(pmNode.displayLabel).toBe('Enter value'); + + await superdoc.snapshot('table-cell-leading-caret'); +}); diff --git a/tests/behavior/tests/formatting/apply-font.spec.ts b/tests/behavior/tests/formatting/apply-font.spec.ts new file mode 100644 index 000000000..1b5926f3a --- /dev/null +++ b/tests/behavior/tests/formatting/apply-font.spec.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, '../../test-data/other/sd-1778-apply-font.docx'); + +test.use({ config: { toolbar: 'full' } }); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); + +test('apply Courier New font to selected text in loaded document', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const originalText = await superdoc.getTextContent(); + expect(originalText.length).toBeGreaterThan(0); + + // Focus editor by clicking into it, then select all + await superdoc.clickOnLine(0); + await superdoc.waitForStable(); + await superdoc.selectAll(); + await superdoc.waitForStable(); + + // Apply font + await superdoc.page.evaluate(() => { + (window as any).editor.commands.setFontFamily('Courier New'); + }); + await superdoc.waitForStable(); + + // Text content should be unchanged + await superdoc.assertTextContains(originalText.substring(0, 20)); + + // Verify font applied via toolbar state for the current selection. + await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .button-label')).toHaveText('Courier New'); + + await superdoc.snapshot('apply-font-courier'); +}); diff --git a/tests/behavior/tests/formatting/bold-italic-formatting.spec.ts b/tests/behavior/tests/formatting/bold-italic-formatting.spec.ts new file mode 100644 index 000000000..f74d8efaf --- /dev/null +++ b/tests/behavior/tests/formatting/bold-italic-formatting.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test('bold and italic formatting applied per-line', async ({ superdoc }) => { + await superdoc.type('This text will be bold.'); + await superdoc.newLine(); + await superdoc.type('This text will be italic.'); + await superdoc.newLine(); + await superdoc.type('This text will be both bold and italic.'); + await superdoc.waitForStable(); + + // Select line 0 and apply bold + await superdoc.tripleClickLine(0); + await superdoc.bold(); + await superdoc.waitForStable(); + + // Select line 1 and apply italic + await superdoc.tripleClickLine(1); + await superdoc.italic(); + await superdoc.waitForStable(); + + // Select line 2 and apply bold + italic + await superdoc.tripleClickLine(2); + await superdoc.bold(); + await superdoc.italic(); + await superdoc.waitForStable(); + + // Assert marks + await superdoc.assertTextHasMarks('This text will be bold', ['bold']); + await superdoc.assertTextLacksMarks('This text will be bold', ['italic']); + + await superdoc.assertTextHasMarks('This text will be italic', ['italic']); + await superdoc.assertTextLacksMarks('This text will be italic', ['bold']); + + await superdoc.assertTextHasMarks('This text will be both', ['bold', 'italic']); + + await superdoc.snapshot('bold-italic-formatting'); +}); diff --git a/tests/behavior/tests/formatting/clear-format-undo.spec.ts b/tests/behavior/tests/formatting/clear-format-undo.spec.ts new file mode 100644 index 000000000..23172476d --- /dev/null +++ b/tests/behavior/tests/formatting/clear-format-undo.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test('clear formatting removes marks and undo restores them', async ({ superdoc }) => { + // Type all text first as plain + await superdoc.type('Bold text here.'); + await superdoc.newLine(); + await superdoc.type('Italic text here.'); + await superdoc.newLine(); + await superdoc.type('Plain text here.'); + await superdoc.waitForStable(); + + // Apply bold to line 0 + await superdoc.tripleClickLine(0); + await superdoc.bold(); + await superdoc.waitForStable(); + + // Apply italic to line 1 + await superdoc.tripleClickLine(1); + await superdoc.italic(); + await superdoc.waitForStable(); + + // Verify formatting before clear + await superdoc.assertTextHasMarks('Bold text', ['bold']); + await superdoc.assertTextLacksMarks('Bold text', ['italic']); + await superdoc.assertTextHasMarks('Italic text', ['italic']); + await superdoc.assertTextLacksMarks('Italic text', ['bold']); + await superdoc.assertTextLacksMarks('Plain text', ['bold', 'italic']); + + // Clear formatting on all text + await superdoc.selectAll(); + await superdoc.executeCommand('clearFormat'); + await superdoc.waitForStable(); + + // All text should now lack bold and italic + await superdoc.assertTextLacksMarks('Bold text', ['bold']); + await superdoc.assertTextLacksMarks('Italic text', ['italic']); + + // Undo should restore formatting + await superdoc.undo(); + await superdoc.waitForStable(); + + await superdoc.assertTextHasMarks('Bold text', ['bold']); + await superdoc.assertTextHasMarks('Italic text', ['italic']); + await superdoc.assertTextLacksMarks('Plain text', ['bold', 'italic']); + + await superdoc.snapshot('clear-format-undo'); +}); diff --git a/tests/behavior/tests/formatting/insert-hyperlink.spec.ts b/tests/behavior/tests/formatting/insert-hyperlink.spec.ts new file mode 100644 index 000000000..70890746e --- /dev/null +++ b/tests/behavior/tests/formatting/insert-hyperlink.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test('insert hyperlink on selected text via setLink command', async ({ superdoc }) => { + await superdoc.type('Visit our website for more information'); + await superdoc.waitForStable(); + + // Select "website" + const pos = await superdoc.findTextPos('website'); + await superdoc.setTextSelection(pos, pos + 'website'.length); + await superdoc.waitForStable(); + + // Apply hyperlink + await superdoc.executeCommand('setLink', { href: 'https://example.com' }); + await superdoc.waitForStable(); + + // Link mark should exist on "website" + await superdoc.assertTextHasMarks('website', ['link']); + await superdoc.assertTextMarkAttrs('website', 'link', { href: 'https://example.com' }); + + // Link should render in the DOM + await superdoc.assertLinkExists('https://example.com'); + + // Surrounding text should not have link mark + await superdoc.assertTextLacksMarks('Visit our', ['link']); + await superdoc.assertTextLacksMarks('for more', ['link']); + + await superdoc.snapshot('insert-hyperlink'); +}); diff --git a/tests/behavior/tests/formatting/paragraph-style-inheritance.spec.ts b/tests/behavior/tests/formatting/paragraph-style-inheritance.spec.ts new file mode 100644 index 000000000..7633fee72 --- /dev/null +++ b/tests/behavior/tests/formatting/paragraph-style-inheritance.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test('new paragraphs inherit bold formatting through Enter', async ({ superdoc }) => { + // Type with bold active + await superdoc.bold(); + await superdoc.type('First paragraph bold'); + await superdoc.waitForStable(); + + await superdoc.assertTextHasMarks('First paragraph', ['bold']); + + // Press Enter — new paragraph should inherit bold + await superdoc.newLine(); + await superdoc.type('Second paragraph inherits bold'); + await superdoc.waitForStable(); + + await superdoc.assertTextHasMarks('Second paragraph', ['bold']); + + // Type more paragraphs to verify continued inheritance + await superdoc.newLine(); + await superdoc.type('Third paragraph also bold'); + await superdoc.waitForStable(); + + await superdoc.assertTextHasMarks('Third paragraph', ['bold']); + + await superdoc.snapshot('paragraph-style-inheritance'); +}); + +test('new paragraphs inherit combined bold+italic formatting', async ({ superdoc }) => { + // Type with both bold and italic active from the start + await superdoc.bold(); + await superdoc.italic(); + await superdoc.type('First paragraph both'); + await superdoc.waitForStable(); + + await superdoc.assertTextHasMarks('First paragraph', ['bold', 'italic']); + + // Press Enter — new paragraph should inherit both + await superdoc.newLine(); + await superdoc.type('Second paragraph inherits both'); + await superdoc.waitForStable(); + + await superdoc.assertTextHasMarks('Second paragraph', ['bold', 'italic']); + + await superdoc.snapshot('paragraph-style-inheritance-combined'); +}); diff --git a/tests/behavior/tests/formatting/toggle-formatting-off.spec.ts b/tests/behavior/tests/formatting/toggle-formatting-off.spec.ts new file mode 100644 index 000000000..21315c3a5 --- /dev/null +++ b/tests/behavior/tests/formatting/toggle-formatting-off.spec.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, '../../test-data/styles/sd-1727-formatting-lost.docx'); + +test.use({ config: { toolbar: 'full' } }); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); + +test('toggle bold off retains other formatting', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const originalText = await superdoc.getTextContent(); + expect(originalText.length).toBeGreaterThan(0); + + // Focus editor, then select all and apply bold + await superdoc.clickOnLine(0); + await superdoc.waitForStable(); + await superdoc.selectAll(); + await superdoc.waitForStable(); + await superdoc.bold(); + await superdoc.waitForStable(); + + // Verify bold mark is now present + const firstChunk = originalText.substring(0, 5); + await superdoc.assertTextHasMarks(firstChunk, ['bold']); + + // Toggle bold off on same selection + await superdoc.selectAll(); + await superdoc.bold(); + await superdoc.waitForStable(); + + // Bold mark should be removed + await superdoc.assertTextLacksMarks(firstChunk, ['bold']); + + // Move cursor to end of current selection, then press Enter. + // This avoids PM-specific doc-size introspection. + await superdoc.press('ArrowRight'); + await superdoc.waitForStable(); + await superdoc.press('Enter'); + await superdoc.italic(); + await superdoc.type('hello italic'); + await superdoc.waitForStable(); + + await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-bold"]')).not.toHaveClass(/active/); + + await superdoc.snapshot('toggle-formatting-off'); +}); diff --git a/tests/behavior/tests/headers/double-click-edit-header.spec.ts b/tests/behavior/tests/headers/double-click-edit-header.spec.ts new file mode 100644 index 000000000..6a2808dd6 --- /dev/null +++ b/tests/behavior/tests/headers/double-click-edit-header.spec.ts @@ -0,0 +1,89 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); + +test('double-click header to enter edit mode, type, and exit', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + // Header should be visible + const header = superdoc.page.locator('.superdoc-page-header').first(); + await header.waitFor({ state: 'visible', timeout: 15_000 }); + + // Double-click at the header's coordinates (header has pointer-events:none, + // so we must use raw mouse to reach the viewport host's dblclick handler) + const box = await header.boundingBox(); + expect(box).toBeTruthy(); + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + + // After dblclick, SuperDoc creates a separate editor host for the header + const editorHost = superdoc.page.locator('.superdoc-header-editor-host').first(); + await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); + + // Focus the PM editor inside the host, select all, move to end, then insert text + const pm = editorHost.locator('.ProseMirror'); + await pm.click(); + await superdoc.page.keyboard.press('End'); + // Use insertText instead of type() to avoid character-by-character key events + // which may trigger PM shortcuts + await superdoc.page.keyboard.insertText(' - Edited'); + await superdoc.waitForStable(); + + // Editor host should contain the typed text + await expect(editorHost).toContainText('Edited'); + + // Press Escape to exit header edit mode + await superdoc.page.keyboard.press('Escape'); + await superdoc.waitForStable(); + + // After exiting, the static header is re-rendered with the edited content + await expect(header).toContainText('Edited'); + + await superdoc.snapshot('header-edited'); +}); + +test('double-click footer to enter edit mode, type, and exit', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + // Footer should be visible — scroll into view first since it's at page bottom + const footer = superdoc.page.locator('.superdoc-page-footer').first(); + await footer.scrollIntoViewIfNeeded(); + await footer.waitFor({ state: 'visible', timeout: 15_000 }); + + // Double-click at the footer's coordinates + const box = await footer.boundingBox(); + expect(box).toBeTruthy(); + await superdoc.page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2); + await superdoc.waitForStable(); + + // After dblclick, SuperDoc creates a separate editor host for the footer + const editorHost = superdoc.page.locator('.superdoc-footer-editor-host').first(); + await editorHost.waitFor({ state: 'visible', timeout: 10_000 }); + + // Focus the PM editor inside the host, select all, move to end, then insert text + const pm = editorHost.locator('.ProseMirror'); + await pm.click(); + await superdoc.page.keyboard.press('End'); + await superdoc.page.keyboard.insertText(' - Edited'); + await superdoc.waitForStable(); + + // Editor host should contain the typed text + await expect(editorHost).toContainText('Edited'); + + // Press Escape to exit footer edit mode + await superdoc.page.keyboard.press('Escape'); + await superdoc.waitForStable(); + + // After exiting, the static footer is re-rendered with the edited content + await expect(footer).toContainText('Edited'); + + await superdoc.snapshot('footer-edited'); +}); diff --git a/tests/behavior/tests/helpers/tracked-changes.spec.ts b/tests/behavior/tests/helpers/tracked-changes.spec.ts new file mode 100644 index 000000000..7f318b465 --- /dev/null +++ b/tests/behavior/tests/helpers/tracked-changes.spec.ts @@ -0,0 +1,49 @@ +import { test, expect, type Page } from '@playwright/test'; +import { rejectAllTrackedChanges } from '../../helpers/tracked-changes.js'; + +interface FakeEditor { + doc?: { + trackChanges?: { + rejectAll?: (input: Record) => void; + }; + }; +} + +type WindowWithEditor = Window & typeof globalThis & { editor: FakeEditor }; + +function createMockPageFromEditor(editor: FakeEditor): Page { + (globalThis as { window?: WindowWithEditor }).window = { editor } as WindowWithEditor; + + const pageLike = { + evaluate: async (fn: () => T): Promise => fn(), + }; + + return pageLike as unknown as Page; +} + +test.afterEach(() => { + delete (globalThis as { window?: Window }).window; +}); + +test('calls rejectAll on the trackChanges API', async () => { + let called = false; + const page = createMockPageFromEditor({ + doc: { + trackChanges: { + rejectAll: () => { + called = true; + }, + }, + }, + }); + + await rejectAllTrackedChanges(page); + expect(called).toBe(true); +}); + +test('throws when document-api trackChanges.rejectAll is missing', async () => { + const page = createMockPageFromEditor({}); + await expect(rejectAllTrackedChanges(page)).rejects.toThrow( + 'Document API is unavailable: expected editor.doc.trackChanges.rejectAll.', + ); +}); diff --git a/tests/behavior/tests/importing/load-doc-with-pict.spec.ts b/tests/behavior/tests/importing/load-doc-with-pict.spec.ts new file mode 100644 index 000000000..9aa3bd63b --- /dev/null +++ b/tests/behavior/tests/importing/load-doc-with-pict.spec.ts @@ -0,0 +1,25 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady, getDocumentText } from '../../helpers/document-api.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, '../../test-data/fldchar/sd-1558-fld-char-issue.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); + +test.use({ config: { toolbar: 'full', comments: 'off' } }); + +test('loads document with w:pict nodes without schema errors (SD-1558)', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + await assertDocumentApiReady(superdoc.page); + + // If schema validation fails during import, the editor/doc API will not stabilize. + const text = await getDocumentText(superdoc.page); + expect(text.length).toBeGreaterThan(0); + + await expect(superdoc.page.locator('.superdoc-page').first()).toBeVisible(); + await expect(superdoc.page.locator('.superdoc-line').first()).toBeVisible(); +}); diff --git a/tests/behavior/tests/lists/empty-list-item-markers.spec.ts b/tests/behavior/tests/lists/empty-list-item-markers.spec.ts new file mode 100644 index 000000000..e3ac2b03a --- /dev/null +++ b/tests/behavior/tests/lists/empty-list-item-markers.spec.ts @@ -0,0 +1,49 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, '../../test-data/lists/sd-1543-empty-list-items.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); + +test('empty list items show markers and accept typed content', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + // List markers should be present in the loaded document + const markers = superdoc.page.locator('.superdoc-paragraph-marker'); + const markerCount = await markers.count(); + expect(markerCount).toBeGreaterThan(0); + + // Find the first empty list line (a .superdoc-line inside a list with no visible text). + const emptyLineIndex = await superdoc.page.evaluate(() => { + const lines = Array.from(document.querySelectorAll('.superdoc-line')); + return lines.findIndex((line) => { + const hasMarker = line.querySelector('.superdoc-paragraph-marker') !== null; + const textContent = (line.textContent ?? '').replace(/[\s\u200B]/g, ''); + // A line is "empty" if it has a list marker but the text portion is blank. + // Subtract the marker text to check only the content area. + const markerText = line.querySelector('.superdoc-paragraph-marker')?.textContent ?? ''; + const contentOnly = textContent.replace(markerText.replace(/\s/g, ''), ''); + return hasMarker && contentOnly.length === 0; + }); + }); + expect(emptyLineIndex).toBeGreaterThanOrEqual(0); + + // Click into that empty line to position cursor + await superdoc.clickOnLine(emptyLineIndex); + await superdoc.waitForStable(); + await superdoc.type('New content in empty list item'); + await superdoc.waitForStable(); + + // Typed text should appear in the document + await superdoc.assertTextContains('New content in empty list item'); + + // Markers should still be present + const markersAfter = await superdoc.page.locator('.superdoc-paragraph-marker').count(); + expect(markersAfter).toBeGreaterThan(0); + + await superdoc.snapshot('empty-list-item-markers'); +}); diff --git a/tests/behavior/tests/lists/indent-list-items.spec.ts b/tests/behavior/tests/lists/indent-list-items.spec.ts new file mode 100644 index 000000000..1a5423926 --- /dev/null +++ b/tests/behavior/tests/lists/indent-list-items.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test('list indentation with Tab and outdentation with Shift+Tab', async ({ superdoc }) => { + // Create a numbered list by typing "1. " + await superdoc.type('1. '); + await superdoc.type('item 1'); + await superdoc.waitForStable(); + + // Verify list item 1 exists with a marker + await superdoc.assertElementExists('.superdoc-paragraph-marker'); + + // Add second item + await superdoc.newLine(); + await superdoc.type('item 2'); + await superdoc.waitForStable(); + + // Indent third item with Tab + await superdoc.newLine(); + await superdoc.press('Tab'); + await superdoc.type('item a'); + await superdoc.waitForStable(); + + // Indented item should have a different marker style (nested list) + const markers = superdoc.page.locator('.superdoc-paragraph-marker'); + const markerCount = await markers.count(); + expect(markerCount).toBeGreaterThanOrEqual(3); + + // Outdent with Shift+Tab + await superdoc.newLine(); + await superdoc.press('Shift+Tab'); + await superdoc.type('item 3'); + await superdoc.waitForStable(); + + // Text content should contain all items + await superdoc.assertTextContains('item 1'); + await superdoc.assertTextContains('item 2'); + await superdoc.assertTextContains('item a'); + await superdoc.assertTextContains('item 3'); + + // Verify list markers exist for all items + const finalMarkerCount = await markers.count(); + expect(finalMarkerCount).toBeGreaterThanOrEqual(4); + + await superdoc.snapshot('indent-list-items'); +}); diff --git a/tests/behavior/tests/lists/same-level-same-indicator.spec.ts b/tests/behavior/tests/lists/same-level-same-indicator.spec.ts new file mode 100644 index 000000000..1354462f8 --- /dev/null +++ b/tests/behavior/tests/lists/same-level-same-indicator.spec.ts @@ -0,0 +1,35 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady, listItems } from '../../helpers/document-api.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, '../../test-data/lists/sd-1658-lists-same-level.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); + +test('same-level list indicators remain preserved instead of auto-sequencing (SD-1658)', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + await assertDocumentApiReady(superdoc.page); + + const result = await listItems(superdoc.page); + expect(result.total).toBeGreaterThan(0); + + const orderedItems = result.items.filter( + (item) => item.kind === 'ordered' && typeof item.marker === 'string' && item.level !== undefined, + ); + expect(orderedItems.length).toBeGreaterThan(0); + + // Count markers per level — duplicates prove indicators are preserved, not auto-sequenced. + const markerCounts = new Map(); + for (const item of orderedItems) { + const key = `${item.level}::${item.marker}`; + markerCounts.set(key, (markerCounts.get(key) ?? 0) + 1); + } + + const duplicateSameLevelMarkers = [...markerCounts.entries()].filter(([, count]) => count >= 2).map(([key]) => key); + + expect(duplicateSameLevelMarkers.length).toBeGreaterThan(0); +}); diff --git a/tests/behavior/tests/sdt/sdt-lock-modes.spec.ts b/tests/behavior/tests/sdt/sdt-lock-modes.spec.ts new file mode 100644 index 000000000..060ae21b3 --- /dev/null +++ b/tests/behavior/tests/sdt/sdt-lock-modes.spec.ts @@ -0,0 +1,145 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +/** + * SDT lock modes enforcement. + * Migrated from visual suite where it was test.fixme (known broken). + */ + +test.fixme('SDT lock modes enforcement', async ({ superdoc }) => { + // Insert unlocked inline SDT + await superdoc.type('Unlocked inline: '); + await superdoc.waitForStable(); + await superdoc.page.evaluate(() => { + (window as any).editor.commands.insertStructuredContentInline({ + attrs: { id: '100', alias: 'Unlocked Field', lockMode: 'unlocked' }, + text: 'editable value', + }); + }); + await superdoc.waitForStable(); + + // Insert sdtLocked inline SDT + await superdoc.press('End'); + await superdoc.press('Enter'); + await superdoc.type('SDT-locked inline: '); + await superdoc.waitForStable(); + await superdoc.page.evaluate(() => { + (window as any).editor.commands.insertStructuredContentInline({ + attrs: { id: '200', alias: 'SDT Locked', lockMode: 'sdtLocked' }, + text: 'cannot delete wrapper', + }); + }); + await superdoc.waitForStable(); + + // Insert contentLocked inline SDT + await superdoc.press('End'); + await superdoc.press('Enter'); + await superdoc.type('Content-locked inline: '); + await superdoc.waitForStable(); + await superdoc.page.evaluate(() => { + (window as any).editor.commands.insertStructuredContentInline({ + attrs: { id: '300', alias: 'Content Locked', lockMode: 'contentLocked' }, + text: 'read-only content', + }); + }); + await superdoc.waitForStable(); + + // All 3 inline SDTs should exist + await superdoc.assertElementExists('.superdoc-structured-content-inline'); + + // Insert block SDT with sdtContentLocked + await superdoc.press('End'); + await superdoc.press('Enter'); + await superdoc.press('Enter'); + await superdoc.waitForStable(); + await superdoc.page.evaluate(() => { + (window as any).editor.commands.insertStructuredContentBlock({ + attrs: { id: '400', alias: 'Fully Locked Block', lockMode: 'sdtContentLocked' }, + html: '

This block is fully locked (sdtContentLocked).

', + }); + }); + await superdoc.waitForStable(); + + await superdoc.assertElementExists('.superdoc-structured-content-block'); + + // Type inside sdtLocked (content should be editable) + const sdt200 = await superdoc.page.evaluate(() => { + let result: { pos: number; size: number } | null = null; + (window as any).editor.state.doc.descendants((node: any, pos: number) => { + if (result) return false; + if ( + (node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') && + String(node.attrs.id) === '200' + ) { + result = { pos, size: node.nodeSize }; + return false; + } + return true; + }); + return result; + }); + + if (sdt200) { + await superdoc.setTextSelection(sdt200.pos + 2); + await superdoc.waitForStable(); + await superdoc.type(' ADDED'); + await superdoc.waitForStable(); + + // Text should have been added inside sdtLocked + await superdoc.assertTextContains('ADDED'); + } + + // Try typing inside contentLocked (should be blocked) + const sdt300 = await superdoc.page.evaluate(() => { + let result: { pos: number; size: number } | null = null; + (window as any).editor.state.doc.descendants((node: any, pos: number) => { + if (result) return false; + if ( + (node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') && + String(node.attrs.id) === '300' + ) { + result = { pos, size: node.nodeSize }; + return false; + } + return true; + }); + return result; + }); + + if (sdt300) { + await superdoc.setTextSelection(sdt300.pos + 2); + await superdoc.waitForStable(); + await superdoc.type('BLOCKED'); + await superdoc.waitForStable(); + + // contentLocked should prevent typing — text should NOT appear + await superdoc.assertTextNotContains('BLOCKED'); + } + + // Update lock mode: unlocked → contentLocked + await superdoc.page.evaluate(() => { + (window as any).editor.commands.updateStructuredContentById('100', { + attrs: { lockMode: 'contentLocked' }, + }); + }); + await superdoc.waitForStable(); + + // Verify the update was applied by checking PM node attrs + const updatedAttrs = await superdoc.page.evaluate(() => { + let lockMode: string | null = null; + (window as any).editor.state.doc.descendants((node: any) => { + if (lockMode) return false; + if ( + (node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') && + String(node.attrs.id) === '100' + ) { + lockMode = node.attrs.lockMode; + return false; + } + return true; + }); + return lockMode; + }); + expect(updatedAttrs).toBe('contentLocked'); + + await superdoc.snapshot('sdt-lock-modes'); +}); diff --git a/tests/behavior/tests/sdt/structured-content.spec.ts b/tests/behavior/tests/sdt/structured-content.spec.ts new file mode 100644 index 000000000..b02fc3888 --- /dev/null +++ b/tests/behavior/tests/sdt/structured-content.spec.ts @@ -0,0 +1,274 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { + insertBlockSdt, + insertInlineSdt, + getCenter, + hasClass, + isSelectionOnBlockSdt, + deselectSdt, +} from '../../helpers/sdt.js'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +// --------------------------------------------------------------------------- +// Selectors +// --------------------------------------------------------------------------- + +const BLOCK_SDT = '.superdoc-structured-content-block'; +const BLOCK_LABEL = '.superdoc-structured-content__label'; +const INLINE_SDT = '.superdoc-structured-content-inline'; +const INLINE_LABEL = '.superdoc-structured-content-inline__label'; +const HOVER_CLASS = 'sdt-hover'; + +// ========================================================================== +// Block SDT Tests +// ========================================================================== + +test.describe('block structured content', () => { + test.beforeEach(async ({ superdoc }) => { + await superdoc.type('Before SDT'); + await superdoc.newLine(); + await superdoc.waitForStable(); + await insertBlockSdt(superdoc.page, 'Test Block', 'Block content here'); + await superdoc.waitForStable(); + }); + + test('block SDT container renders with correct class and label', async ({ superdoc }) => { + await superdoc.assertElementExists(BLOCK_SDT); + await superdoc.assertElementExists(BLOCK_LABEL); + + const labelText = await superdoc.page.evaluate((sel) => { + const label = document.querySelector(sel); + return label?.textContent?.trim() ?? ''; + }, BLOCK_LABEL); + expect(labelText).toBe('Test Block'); + + await superdoc.snapshot('block SDT rendered'); + }); + + test('block SDT shows hover state on mouse enter', async ({ superdoc }) => { + await deselectSdt(superdoc.page); + await superdoc.waitForStable(); + + const center = await getCenter(superdoc.page, BLOCK_SDT); + await superdoc.page.mouse.move(center.x, center.y); + await superdoc.waitForStable(); + + expect(await hasClass(superdoc.page, BLOCK_SDT, HOVER_CLASS)).toBe(true); + + const labelVisible = await superdoc.page.evaluate((sel) => { + const label = document.querySelector(sel); + if (!label) return false; + return getComputedStyle(label).display !== 'none'; + }, BLOCK_LABEL); + expect(labelVisible).toBe(true); + + await superdoc.snapshot('block SDT hovered'); + }); + + test('block SDT removes hover state on mouse leave', async ({ superdoc }) => { + await deselectSdt(superdoc.page); + await superdoc.waitForStable(); + + const center = await getCenter(superdoc.page, BLOCK_SDT); + await superdoc.page.mouse.move(center.x, center.y); + await superdoc.waitForStable(); + expect(await hasClass(superdoc.page, BLOCK_SDT, HOVER_CLASS)).toBe(true); + + await superdoc.page.mouse.move(0, 0); + await superdoc.waitForStable(); + expect(await hasClass(superdoc.page, BLOCK_SDT, HOVER_CLASS)).toBe(false); + + await superdoc.snapshot('block SDT hover removed'); + }); + + test('clicking inside block SDT places cursor within the block', async ({ superdoc }) => { + const center = await getCenter(superdoc.page, BLOCK_SDT); + await superdoc.page.mouse.click(center.x, center.y); + await superdoc.waitForStable(); + + expect(await isSelectionOnBlockSdt(superdoc.page)).toBe(true); + + await superdoc.snapshot('block SDT cursor placed'); + }); + + test('moving cursor outside block SDT leaves the block', async ({ superdoc }) => { + expect(await isSelectionOnBlockSdt(superdoc.page)).toBe(true); + + await deselectSdt(superdoc.page); + await superdoc.waitForStable(); + + expect(await isSelectionOnBlockSdt(superdoc.page)).toBe(false); + + await superdoc.snapshot('cursor outside block SDT'); + }); + + test('block SDT cursor persists through hover cycle', async ({ superdoc }) => { + const center = await getCenter(superdoc.page, BLOCK_SDT); + await superdoc.page.mouse.click(center.x, center.y); + await superdoc.waitForStable(); + expect(await isSelectionOnBlockSdt(superdoc.page)).toBe(true); + + await superdoc.page.mouse.move(0, 0); + await superdoc.waitForStable(); + expect(await isSelectionOnBlockSdt(superdoc.page)).toBe(true); + + await superdoc.snapshot('block SDT cursor after hover cycle'); + }); + + test('block SDT has correct boundary data attributes', async ({ superdoc }) => { + const attrs = await superdoc.page.evaluate((sel) => { + const el = document.querySelector(sel); + if (!el) throw new Error('No block SDT found'); + return { + start: (el as HTMLElement).dataset.sdtContainerStart, + end: (el as HTMLElement).dataset.sdtContainerEnd, + }; + }, BLOCK_SDT); + + expect(attrs.start).toBe('true'); + expect(attrs.end).toBe('true'); + + await superdoc.snapshot('block SDT boundary attributes'); + }); +}); + +// ========================================================================== +// Inline SDT Tests +// ========================================================================== + +test.describe('inline structured content', () => { + test.beforeEach(async ({ superdoc }) => { + await superdoc.type('Hello '); + await superdoc.waitForStable(); + await insertInlineSdt(superdoc.page, 'Test Inline', 'inline value'); + await superdoc.waitForStable(); + }); + + test('inline SDT container renders with correct class and label', async ({ superdoc }) => { + await superdoc.assertElementExists(INLINE_SDT); + await superdoc.assertElementExists(INLINE_LABEL); + + const labelText = await superdoc.page.evaluate((sel) => { + const label = document.querySelector(sel); + return label?.textContent?.trim() ?? ''; + }, INLINE_LABEL); + expect(labelText).toBe('Test Inline'); + + await superdoc.snapshot('inline SDT rendered'); + }); + + test('inline SDT shows hover highlight', async ({ superdoc }) => { + await deselectSdt(superdoc.page, 'Hello'); + await superdoc.waitForStable(); + + const center = await getCenter(superdoc.page, INLINE_SDT); + await superdoc.page.mouse.move(center.x, center.y); + await superdoc.waitForStable(); + + const hasBg = await superdoc.page.evaluate((sel) => { + const el = document.querySelector(sel); + if (!el) return false; + const bg = getComputedStyle(el).backgroundColor; + return bg !== '' && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent'; + }, INLINE_SDT); + expect(hasBg).toBe(true); + + const labelHidden = await superdoc.page.evaluate((sel) => { + const label = document.querySelector(sel); + if (!label) return true; + return getComputedStyle(label).display === 'none'; + }, INLINE_LABEL); + expect(labelHidden).toBe(true); + + await superdoc.snapshot('inline SDT hovered'); + }); + + test('first click inside inline SDT selects all content', async ({ superdoc }) => { + const center = await getCenter(superdoc.page, INLINE_SDT); + await superdoc.page.mouse.click(center.x, center.y); + await superdoc.waitForStable(); + + const selection = await superdoc.page.evaluate(() => { + const { state } = (window as any).editor; + const { from, to } = state.selection; + return state.doc.textBetween(from, to); + }); + + expect(selection).toBe('inline value'); + + await superdoc.snapshot('inline SDT content selected'); + }); + + test('second click inside inline SDT allows cursor placement', async ({ superdoc }) => { + const center = await getCenter(superdoc.page, INLINE_SDT); + + await superdoc.page.mouse.click(center.x, center.y); + await superdoc.waitForStable(); + + await superdoc.page.mouse.click(center.x, center.y); + await superdoc.waitForStable(); + + const selection = await superdoc.page.evaluate(() => { + const { state } = (window as any).editor; + return { from: state.selection.from, to: state.selection.to }; + }); + + expect(selection.to - selection.from).toBeLessThan('inline value'.length); + + await superdoc.snapshot('inline SDT cursor placed'); + }); +}); + +// ========================================================================== +// Viewing Mode Tests +// ========================================================================== + +test.describe('viewing mode hides SDT affordances', () => { + test('block SDT border and label are hidden in viewing mode', async ({ superdoc }) => { + await superdoc.type('Some text'); + await superdoc.newLine(); + await superdoc.waitForStable(); + await insertBlockSdt(superdoc.page, 'Hidden Block', 'Content'); + await superdoc.waitForStable(); + + await superdoc.setDocumentMode('viewing'); + await superdoc.waitForStable(); + + const styles = await superdoc.page.evaluate((sel) => { + const el = document.querySelector(sel); + if (!el) return null; + const cs = getComputedStyle(el); + return { border: cs.borderStyle, padding: cs.padding }; + }, BLOCK_SDT); + + expect(styles).not.toBeNull(); + expect(styles!.border).toBe('none'); + await superdoc.assertElementHidden(BLOCK_LABEL); + + await superdoc.snapshot('block SDT viewing mode'); + }); + + test('inline SDT border and label are hidden in viewing mode', async ({ superdoc }) => { + await superdoc.type('Hello '); + await superdoc.waitForStable(); + await insertInlineSdt(superdoc.page, 'Hidden Inline', 'value'); + await superdoc.waitForStable(); + + await superdoc.setDocumentMode('viewing'); + await superdoc.waitForStable(); + + const styles = await superdoc.page.evaluate((sel) => { + const el = document.querySelector(sel); + if (!el) return null; + const cs = getComputedStyle(el); + return { border: cs.borderStyle }; + }, INLINE_SDT); + + expect(styles).not.toBeNull(); + expect(styles!.border).toBe('none'); + await superdoc.assertElementHidden(INLINE_LABEL); + + await superdoc.snapshot('inline SDT viewing mode'); + }); +}); diff --git a/tests/behavior/tests/search/search-and-navigate.spec.ts b/tests/behavior/tests/search/search-and-navigate.spec.ts new file mode 100644 index 000000000..f890e746e --- /dev/null +++ b/tests/behavior/tests/search/search-and-navigate.spec.ts @@ -0,0 +1,58 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available — run pnpm corpus:pull'); + +test('search and navigate to results in document', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + // Search for text that spans across content + const query = 'works of the Licensed Material'; + const matches = await superdoc.page.evaluate((q: string) => { + return (window as any).editor?.commands?.search?.(q) ?? []; + }, query); + + expect(matches.length).toBeGreaterThan(0); + + // Navigate to first result — selection should move + const selBefore = await superdoc.getSelection(); + + await superdoc.page.evaluate((match: any) => { + (window as any).editor.commands.goToSearchResult(match); + }, matches[0]); + await superdoc.waitForStable(); + + const selAfter = await superdoc.getSelection(); + // Selection should have changed (cursor moved to the search result) + expect(selAfter.from).not.toBe(selBefore.from); + + // The selected range should span the search query length + expect(selAfter.to - selAfter.from).toBe(query.length); + + // Verify the text at the selection matches the query + await superdoc.assertTextContains(query); + + // Test a second search query + const query2 = 'Agreement'; + const matches2 = await superdoc.page.evaluate((q: string) => { + return (window as any).editor?.commands?.search?.(q) ?? []; + }, query2); + + expect(matches2.length).toBeGreaterThan(0); + + await superdoc.page.evaluate((match: any) => { + (window as any).editor.commands.goToSearchResult(match); + }, matches2[0]); + await superdoc.waitForStable(); + + const selAfter2 = await superdoc.getSelection(); + expect(selAfter2.to - selAfter2.from).toBe(query2.length); + + await superdoc.snapshot('search-and-navigate'); +}); diff --git a/tests/behavior/tests/selection/highlight-on-right-click.spec.ts b/tests/behavior/tests/selection/highlight-on-right-click.spec.ts new file mode 100644 index 000000000..3d4bd35b4 --- /dev/null +++ b/tests/behavior/tests/selection/highlight-on-right-click.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +// Firefox collapses PM selection on right-click at the browser level — not a SuperDoc bug +test('selection is preserved when right-clicking on selected text', async ({ superdoc, browserName }) => { + test.skip(browserName === 'firefox', 'Firefox collapses selection on right-click natively'); + await superdoc.type('Select this text and right-click'); + await superdoc.waitForStable(); + + // Select the full line + await superdoc.tripleClickLine(0); + await superdoc.waitForStable(); + + // Verify we have a non-collapsed selection + const selBefore = await superdoc.getSelection(); + expect(selBefore.to - selBefore.from).toBeGreaterThan(0); + + // Right-click on the selected text + const line = superdoc.page.locator('.superdoc-line').first(); + const box = await line.boundingBox(); + if (!box) throw new Error('Line not visible'); + await superdoc.page.mouse.click(box.x + box.width / 3, box.y + box.height / 2, { button: 'right' }); + await superdoc.waitForStable(); + + // Context menu should be open + await expect(superdoc.page.locator('.context-menu')).toBeVisible(); + + // Selection should still be non-collapsed (Firefox may adjust the exact range + // on right-click, but the selection must not collapse to a cursor) + const selAfter = await superdoc.getSelection(); + expect(selAfter.to - selAfter.from).toBeGreaterThan(0); + + await superdoc.snapshot('selection preserved after right-click'); +}); + +test('selection highlight preserved when focus moves to toolbar dropdown', async ({ superdoc }) => { + await superdoc.type('Select this text then open dropdown'); + await superdoc.waitForStable(); + + await superdoc.selectAll(); + await superdoc.waitForStable(); + + // Selection overlay should have visible rects + const overlayBefore = await superdoc.page.evaluate(() => { + const overlay = document.querySelector('.presentation-editor__selection-layer--local'); + return overlay ? overlay.children.length : -1; + }); + expect(overlayBefore).toBeGreaterThan(0); + + // Simulate focus moving to a toolbar UI surface (e.g. a dropdown) + await superdoc.page.evaluate(() => { + const btn = document.createElement('button'); + btn.setAttribute('data-editor-ui-surface', ''); + btn.textContent = 'Fake toolbar button'; + btn.id = 'test-ui-surface'; + document.body.appendChild(btn); + btn.focus(); + }); + await superdoc.waitForStable(); + + // Selection overlay should still be visible + const overlayAfter = await superdoc.page.evaluate(() => { + const overlay = document.querySelector('.presentation-editor__selection-layer--local'); + return overlay ? overlay.children.length : -1; + }); + expect(overlayAfter).toBeGreaterThan(0); + + // Clean up + await superdoc.page.evaluate(() => { + document.getElementById('test-ui-surface')?.remove(); + }); + + await superdoc.snapshot('selection preserved after toolbar focus'); +}); diff --git a/tests/behavior/tests/slash-menu/paste.spec.ts b/tests/behavior/tests/slash-menu/paste.spec.ts new file mode 100644 index 000000000..ad13ba6c6 --- /dev/null +++ b/tests/behavior/tests/slash-menu/paste.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full' } }); + +// WebKit blocks clipboard API reads even on localhost — skip it. +test.skip(({ browserName }) => browserName === 'webkit', 'WebKit does not support clipboard API in tests'); + +async function writeToClipboard(page: import('@playwright/test').Page, text: string) { + // Chromium needs explicit permission; Firefox/WebKit allow clipboard in + // secure contexts (localhost) when triggered from a user gesture. + try { + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + } catch { + // Firefox/WebKit don't support these permission names — that's fine. + } + await page.evaluate((t) => navigator.clipboard.writeText(t), text); +} + +test('right-click opens context menu and paste inserts clipboard text', async ({ superdoc }) => { + await superdoc.type('Hello world'); + await superdoc.newLine(); + await superdoc.waitForStable(); + + await writeToClipboard(superdoc.page, 'Pasted content'); + + // Right-click on the empty second line to open the context menu + await superdoc.clickOnLine(1); + await superdoc.waitForStable(); + + const line = superdoc.page.locator('.superdoc-line').nth(1); + const box = await line.boundingBox(); + if (!box) throw new Error('Line 1 not visible'); + await superdoc.page.mouse.click(box.x + 20, box.y + box.height / 2, { button: 'right' }); + await superdoc.waitForStable(); + + // Assert the context menu is visible + const menu = superdoc.page.locator('.context-menu'); + await expect(menu).toBeVisible(); + + // Click the Paste option + const pasteItem = menu.locator('.context-menu-item').filter({ hasText: 'Paste' }); + await expect(pasteItem).toBeVisible(); + await pasteItem.click(); + await superdoc.waitForStable(); + + // Assert the clipboard text was pasted into the document + await superdoc.assertTextContains('Pasted content'); +}); diff --git a/tests/behavior/tests/slash-menu/table-context-menu.spec.ts b/tests/behavior/tests/slash-menu/table-context-menu.spec.ts new file mode 100644 index 000000000..f82f715bb --- /dev/null +++ b/tests/behavior/tests/slash-menu/table-context-menu.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full' } }); + +test('context menu opens above table content when right-clicking inside a table', async ({ superdoc }) => { + await superdoc.type('Text above the table'); + await superdoc.newLine(); + await superdoc.executeCommand('insertTable', { rows: 3, cols: 3, withHeaderRow: false }); + await superdoc.waitForStable(); + + // Right-click inside the table + const table = superdoc.page.locator('.superdoc-table-fragment').first(); + const tableBox = await table.boundingBox(); + if (!tableBox) throw new Error('Table not visible'); + + await superdoc.page.mouse.click(tableBox.x + tableBox.width / 2, tableBox.y + tableBox.height / 2, { + button: 'right', + }); + await superdoc.waitForStable(); + + // Context menu should be visible + const menu = superdoc.page.locator('.context-menu'); + await expect(menu).toBeVisible(); + + // The menu's z-index should be higher than the table's z-index so it renders on top + const zIndices = await superdoc.page.evaluate(() => { + const menu = document.querySelector('.context-menu') as HTMLElement; + const table = document.querySelector('.superdoc-table-fragment') as HTMLElement; + if (!menu || !table) return null; + return { + menuZ: Number(getComputedStyle(menu).zIndex) || 0, + tableZ: Number(getComputedStyle(table).zIndex) || 0, + }; + }); + expect(zIndices).not.toBeNull(); + expect(zIndices!.menuZ).toBeGreaterThan(zIndices!.tableZ); + + // The menu should not be clipped behind the table — its bounding box should be fully visible + const menuBox = await menu.boundingBox(); + expect(menuBox).not.toBeNull(); + expect(menuBox!.width).toBeGreaterThan(0); + expect(menuBox!.height).toBeGreaterThan(0); + + // Menu should contain table-relevant actions + const menuItems = menu.locator('.context-menu-item'); + await expect(menuItems.first()).toBeVisible(); + + await superdoc.snapshot('table context menu on top of content'); +}); diff --git a/tests/behavior/tests/tables/add-row-formatting.spec.ts b/tests/behavior/tests/tables/add-row-formatting.spec.ts new file mode 100644 index 000000000..3ca0b8dc0 --- /dev/null +++ b/tests/behavior/tests/tables/add-row-formatting.spec.ts @@ -0,0 +1,30 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full' } }); + +test('adding a row after bold cell preserves formatting in new row', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false }); + await superdoc.waitForStable(); + + // Type bold text in the first cell + await superdoc.bold(); + await superdoc.type('Bold header'); + await superdoc.waitForStable(); + + await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/active/); + + // Add a row after the current one + await superdoc.executeCommand('addRowAfter'); + await superdoc.waitForStable(); + await superdoc.assertTableExists(3, 2); + + // Type in the new row — bold carries over from the source row + await superdoc.type('New row text'); + await superdoc.waitForStable(); + + await superdoc.assertTextContains('New row text'); + await superdoc.assertTextContains('Bold header'); + + // The new text inherits bold from the row it was cloned from. + await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/active/); +}); diff --git a/tests/behavior/tests/tables/column-selection-rowspan.spec.ts b/tests/behavior/tests/tables/column-selection-rowspan.spec.ts new file mode 100644 index 000000000..8b07e2532 --- /dev/null +++ b/tests/behavior/tests/tables/column-selection-rowspan.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +async function dragFromCellTextToCellText( + superdoc: { page: import('@playwright/test').Page; waitForStable: (ms?: number) => Promise }, + fromText: string, + toText: string, +): Promise { + const fromLine = superdoc.page.locator('.superdoc-line').filter({ hasText: fromText }).first(); + const toLine = superdoc.page.locator('.superdoc-line').filter({ hasText: toText }).first(); + + const fromBox = await fromLine.boundingBox(); + const toBox = await toLine.boundingBox(); + if (!fromBox || !toBox) throw new Error(`Could not resolve drag bounds from "${fromText}" to "${toText}"`); + + const startX = fromBox.x + fromBox.width / 2; + const startY = fromBox.y + fromBox.height / 2; + const endX = toBox.x + toBox.width / 2; + const endY = toBox.y + toBox.height / 2; + + await superdoc.page.mouse.move(startX, startY); + await superdoc.page.mouse.down(); + await superdoc.page.mouse.move(endX, endY); + await superdoc.page.mouse.up(); + await superdoc.waitForStable(); +} + +test('selecting a table column works in rows affected by rowspan (PR #1839)', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 5, cols: 3, withHeaderRow: false }); + await superdoc.waitForStable(); + + const labels = ['A1', 'B1', 'C1', 'A2', 'B2', 'C2', 'A3', 'B3', 'C3', 'A4', 'B4', 'C4', 'A5', 'B5', 'C5']; + + for (let i = 0; i < labels.length; i += 1) { + await superdoc.type(labels[i]); + if (i < labels.length - 1) await superdoc.press('Tab'); + } + await superdoc.waitForStable(); + + // Build rowspan in column A so rows 2-5 start at gridColumnStart=1. + await dragFromCellTextToCellText(superdoc, 'A1', 'A5'); + await superdoc.executeCommand('mergeCells'); + await superdoc.waitForStable(); + await superdoc.assertTableExists(); + await expect + .poll(() => superdoc.page.locator('[contenteditable="true"] table td, [contenteditable="true"] table th').count()) + .toBe(11); + + // Select middle column (B*) by pointer drag. This is the rowspan hit-testing path from PR #1839. + await dragFromCellTextToCellText(superdoc, 'B1', 'B5'); + + // Apply formatting to the selected column and assert only B cells changed. + await superdoc.bold(); + await superdoc.waitForStable(); + + for (const label of ['B1', 'B2', 'B3', 'B4', 'B5']) { + await superdoc.assertTextHasMarks(label, ['bold']); + } + // Merged A column and C column must remain unbold. + for (const label of ['A1', 'C1', 'C2', 'C3', 'C4', 'C5']) { + await superdoc.assertTextLacksMarks(label, ['bold']); + } +}); diff --git a/tests/behavior/tests/tables/resize.spec.ts b/tests/behavior/tests/tables/resize.spec.ts new file mode 100644 index 000000000..ea6a85af3 --- /dev/null +++ b/tests/behavior/tests/tables/resize.spec.ts @@ -0,0 +1,115 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import type { Page, Locator } from '@playwright/test'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +/** + * Hover near a column boundary on the table fragment to trigger the resize overlay. + * Pass the column index for inner boundaries, or 'right-edge' for the table's right edge. + */ +async function hoverColumnBoundary(page: Page, target: number | 'right-edge') { + const pos = await page.evaluate((t) => { + const frag = document.querySelector('.superdoc-table-fragment[data-table-boundaries]'); + if (!frag) throw new Error('No table fragment with boundaries found'); + const { columns } = JSON.parse(frag.getAttribute('data-table-boundaries')!); + const col = t === 'right-edge' ? columns[columns.length - 1] : columns[t]; + if (!col) throw new Error(`Column ${t} not found`); + const rect = frag.getBoundingClientRect(); + // Hover 2px inside the right edge so the cursor stays within the table element + const offset = t === 'right-edge' ? -2 : 0; + return { x: rect.left + col.x + col.w + offset, y: rect.top + rect.height / 2 }; + }, target); + + await page.mouse.move(pos.x, pos.y); +} + +/** + * Drag a resize handle horizontally by deltaX pixels. + * Uses incremental moves with 20ms gaps so the overlay's throttled handler (16ms) fires. + */ +async function dragHandle(page: Page, handle: Locator, deltaX: number) { + const box = await handle.boundingBox(); + if (!box) throw new Error('Resize handle not visible'); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + + await page.mouse.move(x, y); + await page.mouse.down(); + for (let i = 1; i <= 10; i++) { + await page.mouse.move(x + (deltaX * i) / 10, y); + await page.waitForTimeout(20); + } + await page.mouse.up(); +} + +async function getTableGrid(page: Page) { + return page.evaluate(() => { + const doc = (window as any).editor.state.doc; + let grid: any = null; + doc.descendants((node: any) => { + if (grid === null && node.type.name === 'table') { + grid = node.attrs.grid; + return false; + } + }); + return grid; + }); +} + +test('resize a column by dragging its boundary', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 3, cols: 3, withHeaderRow: false }); + await superdoc.waitForStable(); + + await superdoc.type('Hello'); + await superdoc.press('Tab'); + await superdoc.type('World'); + await superdoc.press('Tab'); + await superdoc.type('Test'); + await superdoc.waitForStable(); + await superdoc.snapshot('table with content'); + + // grid is null on a freshly inserted table + expect(await getTableGrid(superdoc.page)).toBeNull(); + + // Hover the first column boundary to make the resize overlay appear + await hoverColumnBoundary(superdoc.page, 0); + await superdoc.waitForStable(); + + const handle = superdoc.page.locator('.resize-handle[data-boundary-type="inner"]').first(); + await expect(handle).toBeAttached({ timeout: 5000 }); + await superdoc.snapshot('resize handle visible'); + + await dragHandle(superdoc.page, handle, 80); + await superdoc.waitForStable(); + await superdoc.snapshot('after column resize'); + + // After resize, grid becomes an array of {col: twips} — one entry per column + const grid = await getTableGrid(superdoc.page); + expect(grid).toHaveLength(3); +}); + +test('resize the table by dragging the right edge', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 3, cols: 3, withHeaderRow: false }); + await superdoc.waitForStable(); + + await superdoc.type('Content'); + await superdoc.waitForStable(); + await superdoc.snapshot('table before edge resize'); + + expect(await getTableGrid(superdoc.page)).toBeNull(); + + // Hover the right edge of the table to make the resize overlay appear + await hoverColumnBoundary(superdoc.page, 'right-edge'); + await superdoc.waitForStable(); + + const handle = superdoc.page.locator('.resize-handle[data-boundary-type="right-edge"]').first(); + await expect(handle).toBeAttached({ timeout: 5000 }); + await superdoc.snapshot('right edge handle visible'); + + await dragHandle(superdoc.page, handle, 100); + await superdoc.waitForStable(); + await superdoc.snapshot('after table edge resize'); + + const grid = await getTableGrid(superdoc.page); + expect(grid).toHaveLength(3); +}); diff --git a/tests/behavior/tests/tables/table-cell-click-positioning.spec.ts b/tests/behavior/tests/tables/table-cell-click-positioning.spec.ts new file mode 100644 index 000000000..5218f790c --- /dev/null +++ b/tests/behavior/tests/tables/table-cell-click-positioning.spec.ts @@ -0,0 +1,69 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady, getDocumentText } from '../../helpers/document-api.js'; + +test.use({ config: { toolbar: 'full', showCaret: true, showSelection: true } }); + +async function clickInsideLine( + superdoc: { page: import('@playwright/test').Page; waitForStable: (ms?: number) => Promise }, + text: string, +): Promise { + const line = superdoc.page.locator('.superdoc-line').filter({ hasText: text }).first(); + const lineBox = await line.boundingBox(); + if (!lineBox) throw new Error(`Unable to find line bounds for "${text}".`); + + await superdoc.page.mouse.click(lineBox.x + Math.min(lineBox.width - 6, 14), lineBox.y + lineBox.height / 2); + await superdoc.waitForStable(); +} + +test('clicking table cells places cursor in the intended cell (SD-1788)', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('Paragraph above the table'); + await superdoc.newLine(); + await superdoc.newLine(); + await superdoc.waitForStable(); + + await superdoc.executeCommand('insertTable', { rows: 3, cols: 3, withHeaderRow: false }); + await superdoc.waitForStable(); + + await superdoc.type('Cell A1'); + await superdoc.press('Tab'); + await superdoc.type('Cell B1'); + await superdoc.press('Tab'); + await superdoc.type('Cell C1'); + await superdoc.press('Tab'); + await superdoc.type('Cell A2'); + await superdoc.waitForStable(); + + // Re-click target cells and type a marker character. + // clickInsideLine clicks near the start of the line, so '!' lands near the + // beginning of cell text. Exact offset varies by browser. + await clickInsideLine(superdoc, 'Cell A1'); + await superdoc.type('!'); + await superdoc.waitForStable(); + + await clickInsideLine(superdoc, 'Cell B1'); + await superdoc.type('!'); + await superdoc.waitForStable(); + + await clickInsideLine(superdoc, 'Cell A2'); + await superdoc.type('!'); + await superdoc.waitForStable(); + + const text = await getDocumentText(superdoc.page); + + // Strip all '!' markers — base cell labels must still be intact. + const baseText = text.replace(/!/g, ''); + expect(baseText).toContain('Cell A1'); + expect(baseText).toContain('Cell B1'); + expect(baseText).toContain('Cell C1'); + expect(baseText).toContain('Cell A2'); + + // Exactly 3 '!' markers — one per clicked cell. + expect((text.match(/!/g) ?? []).length).toBe(3); + + // Paragraph above the table should be untouched. + expect(baseText).toContain('Paragraph above the table'); + expect(text).not.toContain('Paragraph above the table!'); + expect(text).not.toContain('!Paragraph'); +}); diff --git a/tests/behavior/tests/toolbar/alignment.spec.ts b/tests/behavior/tests/toolbar/alignment.spec.ts new file mode 100644 index 000000000..901c26756 --- /dev/null +++ b/tests/behavior/tests/toolbar/alignment.spec.ts @@ -0,0 +1,103 @@ +import { test, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +async function clickAlignment(superdoc: SuperDocFixture, ariaLabel: string): Promise { + // Open alignment dropdown + await superdoc.page.locator('[data-item="btn-textAlign"]').click(); + await superdoc.waitForStable(); + + // Click the alignment option + await superdoc.page.locator(`[data-item="btn-textAlign-option"][aria-label="${ariaLabel}"]`).click(); + await superdoc.waitForStable(); +} + +test('align text center', async ({ superdoc }) => { + await superdoc.type('Center this text'); + await superdoc.waitForStable(); + await superdoc.snapshot('typed text'); + + const pos = await superdoc.findTextPos('Center this text'); + await superdoc.setTextSelection(pos); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align center'); + await superdoc.snapshot('after align center'); + + await superdoc.assertTextAlignment('Center this text', 'center'); +}); + +test('align text right', async ({ superdoc }) => { + await superdoc.type('Right aligned text'); + await superdoc.waitForStable(); + await superdoc.snapshot('typed text'); + + const pos = await superdoc.findTextPos('Right aligned text'); + await superdoc.setTextSelection(pos); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align right'); + await superdoc.snapshot('after align right'); + + await superdoc.assertTextAlignment('Right aligned text', 'right'); +}); + +test('justify text', async ({ superdoc }) => { + await superdoc.type( + 'Justified text needs to be long enough to wrap across multiple lines so that the spacing between words is visually stretched to fill the full width of each line', + ); + await superdoc.waitForStable(); + await superdoc.snapshot('typed long text'); + + const pos = await superdoc.findTextPos('Justified text needs'); + await superdoc.setTextSelection(pos); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Justify'); + await superdoc.snapshot('after justify'); + + await superdoc.assertTextAlignment('Justified text needs', 'justify'); +}); + +test('cycle through alignments', async ({ superdoc }) => { + await superdoc.type('Cycling alignment'); + await superdoc.waitForStable(); + await superdoc.snapshot('typed text'); + + const pos = await superdoc.findTextPos('Cycling alignment'); + await superdoc.setTextSelection(pos); + await superdoc.waitForStable(); + + // Center + await clickAlignment(superdoc, 'Align center'); + await superdoc.snapshot('centered'); + await superdoc.assertTextAlignment('Cycling alignment', 'center'); + + // Right + await clickAlignment(superdoc, 'Align right'); + await superdoc.snapshot('right aligned'); + await superdoc.assertTextAlignment('Cycling alignment', 'right'); + + // Back to left + await clickAlignment(superdoc, 'Align left'); + await superdoc.snapshot('back to left'); + await superdoc.assertTextAlignment('Cycling alignment', 'left'); +}); + +test('alignment inside a table cell', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false }); + await superdoc.waitForStable(); + + await superdoc.type('Cell text'); + await superdoc.waitForStable(); + await superdoc.snapshot('table with text'); + + const pos = await superdoc.findTextPos('Cell text'); + await superdoc.setTextSelection(pos); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align center'); + await superdoc.snapshot('cell text centered'); + + await superdoc.assertTextAlignment('Cell text', 'center'); +}); diff --git a/tests/behavior/tests/toolbar/basic-styles.spec.ts b/tests/behavior/tests/toolbar/basic-styles.spec.ts new file mode 100644 index 000000000..938f394ff --- /dev/null +++ b/tests/behavior/tests/toolbar/basic-styles.spec.ts @@ -0,0 +1,168 @@ +import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +/** + * Select "is a sentence" from the typed text. + */ +async function typeAndSelect(superdoc: SuperDocFixture): Promise { + await superdoc.type('This is a sentence'); + await superdoc.newLine(); + await superdoc.type('Hello tests'); + await superdoc.waitForStable(); + + const pos = await superdoc.findTextPos('is a sentence'); + await superdoc.setTextSelection(pos, pos + 'is a sentence'.length); + await superdoc.waitForStable(); + + // Verify selection rectangles are visible + const selectionRect = superdoc.page.locator('.presentation-editor__selection-rect'); + await expect(selectionRect.first()).toBeVisible(); +} + +test('bold button applies bold', async ({ superdoc }) => { + await typeAndSelect(superdoc); + await superdoc.snapshot('text selected'); + + const boldButton = superdoc.page.locator('[data-item="btn-bold"]'); + await boldButton.click(); + await superdoc.waitForStable(); + + await expect(boldButton).toHaveClass(/active/); + await superdoc.snapshot('bold applied'); + + await superdoc.assertTextHasMarks('is a sentence', ['bold']); +}); + +test('italic button applies italic', async ({ superdoc }) => { + await typeAndSelect(superdoc); + await superdoc.snapshot('text selected'); + + const italicButton = superdoc.page.locator('[data-item="btn-italic"]'); + await italicButton.click(); + await superdoc.waitForStable(); + + await expect(italicButton).toHaveClass(/active/); + await superdoc.snapshot('italic applied'); + + await superdoc.assertTextHasMarks('is a sentence', ['italic']); +}); + +test('underline button applies underline', async ({ superdoc }) => { + await typeAndSelect(superdoc); + await superdoc.snapshot('text selected'); + + const underlineButton = superdoc.page.locator('[data-item="btn-underline"]'); + await underlineButton.click(); + await superdoc.waitForStable(); + + await expect(underlineButton).toHaveClass(/active/); + await superdoc.snapshot('underline applied'); + + await superdoc.assertTextHasMarks('is a sentence', ['underline']); +}); + +test('strikethrough button applies strike', async ({ superdoc }) => { + await typeAndSelect(superdoc); + await superdoc.snapshot('text selected'); + + const strikeButton = superdoc.page.locator('[data-item="btn-strike"]'); + await strikeButton.click(); + await superdoc.waitForStable(); + + await expect(strikeButton).toHaveClass(/active/); + await superdoc.snapshot('strikethrough applied'); + + await superdoc.assertTextHasMarks('is a sentence', ['strike']); +}); + +test('font family dropdown changes font', async ({ superdoc }) => { + await typeAndSelect(superdoc); + await superdoc.snapshot('text selected'); + + // Open the font family dropdown + const fontButton = superdoc.page.locator('[data-item="btn-fontFamily"]'); + await fontButton.click(); + await superdoc.waitForStable(); + await superdoc.snapshot('font family dropdown open'); + + // Select "Georgia" from the dropdown + const georgiaOption = superdoc.page.locator('[data-item="btn-fontFamily-option"]').filter({ hasText: 'Georgia' }); + await georgiaOption.click(); + await superdoc.waitForStable(); + + // Assert the toolbar displays "Georgia" + await expect(fontButton.locator('.button-label')).toHaveText('Georgia'); + await superdoc.snapshot('Georgia font applied'); + + await superdoc.assertTextMarkAttrs('is a sentence', 'textStyle', { fontFamily: 'Georgia' }); +}); + +test('font size dropdown changes size', async ({ superdoc }) => { + await typeAndSelect(superdoc); + await superdoc.snapshot('text selected'); + + // Open the font size dropdown + const sizeButton = superdoc.page.locator('[data-item="btn-fontSize"]'); + await sizeButton.click(); + await superdoc.waitForStable(); + await superdoc.snapshot('font size dropdown open'); + + // Select "18" from the dropdown + const sizeOption = superdoc.page.locator('[data-item="btn-fontSize-option"]').filter({ hasText: '18' }); + await sizeOption.click(); + await superdoc.waitForStable(); + + // Assert the toolbar displays "18" + const sizeInput = superdoc.page.locator('#inlineTextInput-fontSize'); + await expect(sizeInput).toHaveValue('18'); + await superdoc.snapshot('font size 18 applied'); + + await superdoc.assertTextMarkAttrs('is a sentence', 'textStyle', { fontSize: '18pt' }); +}); + +test('color dropdown changes text color', async ({ superdoc }) => { + await typeAndSelect(superdoc); + await superdoc.snapshot('text selected'); + + // Open the color dropdown + const colorButton = superdoc.page.locator('[data-item="btn-color"]'); + await colorButton.click(); + await superdoc.waitForStable(); + await superdoc.snapshot('color dropdown open'); + + // Click the red color swatch (#D2003F) + const redSwatch = superdoc.page.locator('.option[aria-label="red"]').first(); + await redSwatch.click(); + await superdoc.waitForStable(); + + // Assert the color bar on the toolbar icon changed to red + const colorBar = colorButton.locator('.color-bar'); + await expect(colorBar).toHaveCSS('background-color', 'rgb(210, 0, 63)'); + await superdoc.snapshot('red color applied'); + + await superdoc.assertTextMarkAttrs('is a sentence', 'textStyle', { color: '#D2003F' }); +}); + +test('highlight dropdown changes background color', async ({ superdoc }) => { + await typeAndSelect(superdoc); + await superdoc.snapshot('text selected'); + + // Open the highlight dropdown + const highlightButton = superdoc.page.locator('[data-item="btn-highlight"]'); + await highlightButton.click(); + await superdoc.waitForStable(); + await superdoc.snapshot('highlight dropdown open'); + + // Click a highlight color swatch (#ECCF35) + const yellowSwatch = superdoc.page.locator('.option[aria-label="yellow"]').first(); + await yellowSwatch.click(); + await superdoc.waitForStable(); + + // Assert the color bar on the toolbar icon changed to yellow + const highlightBar = highlightButton.locator('.color-bar'); + await expect(highlightBar).toHaveCSS('background-color', 'rgb(236, 207, 53)'); + await superdoc.snapshot('yellow highlight applied'); + + await superdoc.assertTextHasMarks('is a sentence', ['highlight']); +}); diff --git a/tests/behavior/tests/toolbar/bubble.spec.ts b/tests/behavior/tests/toolbar/bubble.spec.ts new file mode 100644 index 000000000..6d60060b3 --- /dev/null +++ b/tests/behavior/tests/toolbar/bubble.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full', showSelection: true, comments: 'on' } }); + +test('comment bubble appears on text selection', async ({ superdoc }) => { + await superdoc.type('Select some of this text'); + await superdoc.waitForStable(); + + const bubble = superdoc.page.locator('.superdoc__tools'); + + // No selection yet — bubble should not be visible + await expect(bubble).not.toBeVisible(); + + // Select "some" via PM positions + const pos = await superdoc.findTextPos('some'); + await superdoc.setTextSelection(pos, pos + 'some'.length); + await superdoc.waitForStable(); + + // Bubble should appear + await expect(bubble).toBeVisible(); + await expect(superdoc.page.locator('.superdoc__tools [data-id="is-tool"]').first()).toBeVisible(); + await superdoc.snapshot('bubble visible on selection'); +}); + +test('comment bubble disappears when selection is collapsed', async ({ superdoc }) => { + await superdoc.type('Select some of this text'); + await superdoc.waitForStable(); + + const bubble = superdoc.page.locator('.superdoc__tools'); + + // Select text + const pos = await superdoc.findTextPos('some'); + await superdoc.setTextSelection(pos, pos + 'some'.length); + await superdoc.waitForStable(); + await expect(bubble).toBeVisible(); + + // Collapse selection (cursor only) + await superdoc.setTextSelection(pos); + await superdoc.waitForStable(); + + // Bubble should disappear + await expect(bubble).not.toBeVisible(); + await superdoc.snapshot('bubble hidden after deselect'); +}); diff --git a/tests/behavior/tests/toolbar/composite-styles.spec.ts b/tests/behavior/tests/toolbar/composite-styles.spec.ts new file mode 100644 index 000000000..35a9397d7 --- /dev/null +++ b/tests/behavior/tests/toolbar/composite-styles.spec.ts @@ -0,0 +1,213 @@ +import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +async function typeAndSelect(superdoc: SuperDocFixture): Promise { + await superdoc.type('This is a sentence'); + await superdoc.newLine(); + await superdoc.type('Hello tests'); + await superdoc.waitForStable(); + + const pos = await superdoc.findTextPos('is a sentence'); + await superdoc.setTextSelection(pos, pos + 'is a sentence'.length); + await superdoc.waitForStable(); +} + +async function clickToolbarButton(superdoc: SuperDocFixture, dataItem: string): Promise { + await superdoc.page.locator(`[data-item="btn-${dataItem}"]`).click(); + await superdoc.waitForStable(); +} + +async function selectDropdownOption(superdoc: SuperDocFixture, dataItem: string, optionText: string): Promise { + await superdoc.page.locator(`[data-item="btn-${dataItem}"]`).click(); + await superdoc.waitForStable(); + await superdoc.page.locator(`[data-item="btn-${dataItem}-option"]`).filter({ hasText: optionText }).click(); + await superdoc.waitForStable(); +} + +async function selectColorSwatch(superdoc: SuperDocFixture, dataItem: string, label: string): Promise { + await superdoc.page.locator(`[data-item="btn-${dataItem}"]`).click(); + await superdoc.waitForStable(); + await superdoc.page.locator(`.option[aria-label="${label}"]`).first().click(); + await superdoc.waitForStable(); +} + +// --- Toggle pairs --- + +test('bold + italic', async ({ superdoc }) => { + await typeAndSelect(superdoc); + await superdoc.snapshot('text selected'); + + await clickToolbarButton(superdoc, 'bold'); + await clickToolbarButton(superdoc, 'italic'); + + await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/active/); + await superdoc.snapshot('bold + italic applied'); + + await superdoc.assertTextHasMarks('is a sentence', ['bold', 'italic']); +}); + +test('bold + underline', async ({ superdoc }) => { + await typeAndSelect(superdoc); + await superdoc.snapshot('text selected'); + + await clickToolbarButton(superdoc, 'bold'); + await clickToolbarButton(superdoc, 'underline'); + + await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-underline"]')).toHaveClass(/active/); + await superdoc.snapshot('bold + underline applied'); + + await superdoc.assertTextHasMarks('is a sentence', ['bold', 'underline']); +}); + +test('italic + strikethrough', async ({ superdoc }) => { + await typeAndSelect(superdoc); + await superdoc.snapshot('text selected'); + + await clickToolbarButton(superdoc, 'italic'); + await clickToolbarButton(superdoc, 'strike'); + + await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-strike"]')).toHaveClass(/active/); + await superdoc.snapshot('italic + strikethrough applied'); + + await superdoc.assertTextHasMarks('is a sentence', ['italic', 'strike']); +}); + +// --- All toggles stacked --- + +test('bold + italic + underline + strikethrough', async ({ superdoc }) => { + await typeAndSelect(superdoc); + await superdoc.snapshot('text selected'); + + await clickToolbarButton(superdoc, 'bold'); + await clickToolbarButton(superdoc, 'italic'); + await clickToolbarButton(superdoc, 'underline'); + await clickToolbarButton(superdoc, 'strike'); + + await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-underline"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-strike"]')).toHaveClass(/active/); + await superdoc.snapshot('all four toggles applied'); + + await superdoc.assertTextHasMarks('is a sentence', ['bold', 'italic', 'underline', 'strike']); +}); + +// --- Toggle + value styles --- + +test('bold + font family + font size', async ({ superdoc }) => { + await typeAndSelect(superdoc); + await superdoc.snapshot('text selected'); + + await clickToolbarButton(superdoc, 'bold'); + await selectDropdownOption(superdoc, 'fontFamily', 'Georgia'); + await selectDropdownOption(superdoc, 'fontSize', '24'); + + await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .button-label')).toHaveText('Georgia'); + await expect(superdoc.page.locator('#inlineTextInput-fontSize')).toHaveValue('24'); + await superdoc.snapshot('bold + Georgia 24pt applied'); + + await superdoc.assertTextHasMarks('is a sentence', ['bold']); + await superdoc.assertTextMarkAttrs('is a sentence', 'textStyle', { fontFamily: 'Georgia' }); + await superdoc.assertTextMarkAttrs('is a sentence', 'textStyle', { fontSize: '24pt' }); +}); + +test('italic + color', async ({ superdoc }) => { + await typeAndSelect(superdoc); + await superdoc.snapshot('text selected'); + + await clickToolbarButton(superdoc, 'italic'); + await selectColorSwatch(superdoc, 'color', 'red'); + + await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/active/); + const colorBar = superdoc.page.locator('[data-item="btn-color"] .color-bar'); + await expect(colorBar).toHaveCSS('background-color', 'rgb(210, 0, 63)'); + await superdoc.snapshot('italic + red color applied'); + + await superdoc.assertTextHasMarks('is a sentence', ['italic']); + await superdoc.assertTextMarkAttrs('is a sentence', 'textStyle', { color: '#D2003F' }); +}); + +// --- Multiple value styles --- + +test('font family + font size + color', async ({ superdoc }) => { + await typeAndSelect(superdoc); + await superdoc.snapshot('text selected'); + + await selectDropdownOption(superdoc, 'fontFamily', 'Georgia'); + await selectDropdownOption(superdoc, 'fontSize', '18'); + await selectColorSwatch(superdoc, 'color', 'dark red'); + + await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .button-label')).toHaveText('Georgia'); + await expect(superdoc.page.locator('#inlineTextInput-fontSize')).toHaveValue('18'); + const colorBar = superdoc.page.locator('[data-item="btn-color"] .color-bar'); + await expect(colorBar).toHaveCSS('background-color', 'rgb(134, 0, 40)'); + await superdoc.snapshot('Georgia 18pt dark red applied'); + + await superdoc.assertTextMarkAttrs('is a sentence', 'textStyle', { fontFamily: 'Georgia' }); + await superdoc.assertTextMarkAttrs('is a sentence', 'textStyle', { fontSize: '18pt' }); + await superdoc.assertTextMarkAttrs('is a sentence', 'textStyle', { color: '#860028' }); +}); + +// --- Kitchen sink --- + +test('all styles combined', async ({ superdoc }) => { + await typeAndSelect(superdoc); + await superdoc.snapshot('text selected'); + + // Apply all toggle styles + await clickToolbarButton(superdoc, 'bold'); + await clickToolbarButton(superdoc, 'italic'); + await clickToolbarButton(superdoc, 'underline'); + await clickToolbarButton(superdoc, 'strike'); + await superdoc.snapshot('all toggles applied'); + + // Apply all value styles + await selectDropdownOption(superdoc, 'fontFamily', 'Courier New'); + await selectDropdownOption(superdoc, 'fontSize', '24'); + await selectColorSwatch(superdoc, 'color', 'red'); + await selectColorSwatch(superdoc, 'highlight', 'yellow'); + + // Assert all toolbar button states + await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-underline"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-strike"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .button-label')).toHaveText('Courier New'); + await expect(superdoc.page.locator('#inlineTextInput-fontSize')).toHaveValue('24'); + const colorBar = superdoc.page.locator('[data-item="btn-color"] .color-bar'); + await expect(colorBar).toHaveCSS('background-color', 'rgb(210, 0, 63)'); + const highlightBar = superdoc.page.locator('[data-item="btn-highlight"] .color-bar'); + await expect(highlightBar).toHaveCSS('background-color', 'rgb(236, 207, 53)'); + await superdoc.snapshot('all styles applied'); + + // Assert all PM marks + await superdoc.assertTextHasMarks('is a sentence', ['bold', 'italic', 'underline', 'strike', 'highlight']); + await superdoc.assertTextMarkAttrs('is a sentence', 'textStyle', { fontFamily: 'Courier New' }); + await superdoc.assertTextMarkAttrs('is a sentence', 'textStyle', { fontSize: '24pt' }); + await superdoc.assertTextMarkAttrs('is a sentence', 'textStyle', { color: '#D2003F' }); +}); + +test('textStyle attr checks require one run to satisfy all attrs', async ({ superdoc }) => { + await superdoc.type('Split attrs'); + await superdoc.waitForStable(); + + const splitPos = await superdoc.findTextPos('Split'); + await superdoc.setTextSelection(splitPos, splitPos + 'Split'.length); + await selectDropdownOption(superdoc, 'fontFamily', 'Georgia'); + + const attrsPos = await superdoc.findTextPos('attrs'); + await superdoc.setTextSelection(attrsPos, attrsPos + 'attrs'.length); + await selectColorSwatch(superdoc, 'color', 'red'); + + await superdoc.assertTextMarkAttrs('Split', 'textStyle', { fontFamily: 'Georgia' }); + await superdoc.assertTextMarkAttrs('attrs', 'textStyle', { color: '#D2003F' }); + + await expect( + superdoc.assertTextMarkAttrs('Split attrs', 'textStyle', { fontFamily: 'Georgia', color: '#D2003F' }), + ).rejects.toThrow(); +}); diff --git a/tests/behavior/tests/toolbar/document-mode-dropdown-sync.spec.ts b/tests/behavior/tests/toolbar/document-mode-dropdown-sync.spec.ts new file mode 100644 index 000000000..1e56d2968 --- /dev/null +++ b/tests/behavior/tests/toolbar/document-mode-dropdown-sync.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full' } }); + +test('document mode dropdown shows Editing by default', async ({ superdoc }) => { + const modeButton = superdoc.page.locator('[data-item="btn-documentMode"]'); + await expect(modeButton).toContainText('Editing'); +}); + +test('switching to suggesting updates the dropdown label', async ({ superdoc }) => { + const modeButton = superdoc.page.locator('[data-item="btn-documentMode"]'); + + await modeButton.click(); + await superdoc.waitForStable(); + + await superdoc.page.locator('[data-item="btn-documentMode-option"]').filter({ hasText: 'Suggesting' }).click(); + await superdoc.waitForStable(); + + await expect(modeButton).toContainText('Suggesting'); + await superdoc.assertDocumentMode('suggesting'); +}); + +test('switching to viewing updates the dropdown label', async ({ superdoc }) => { + const modeButton = superdoc.page.locator('[data-item="btn-documentMode"]'); + + await modeButton.click(); + await superdoc.waitForStable(); + + await superdoc.page.locator('[data-item="btn-documentMode-option"]').filter({ hasText: 'Viewing' }).click(); + await superdoc.waitForStable(); + + await expect(modeButton).toContainText('Viewing'); + await superdoc.assertDocumentMode('viewing'); +}); + +test('programmatic mode change syncs the dropdown', async ({ superdoc }) => { + const modeButton = superdoc.page.locator('[data-item="btn-documentMode"]'); + + await superdoc.setDocumentMode('suggesting'); + await superdoc.waitForStable(); + await expect(modeButton).toContainText('Suggesting'); + + await superdoc.setDocumentMode('viewing'); + await superdoc.waitForStable(); + await expect(modeButton).toContainText('Viewing'); + + await superdoc.setDocumentMode('editing'); + await superdoc.waitForStable(); + await expect(modeButton).toContainText('Editing'); +}); + +test('cycling through all modes via dropdown', async ({ superdoc }) => { + const modeButton = superdoc.page.locator('[data-item="btn-documentMode"]'); + const modes = ['Suggesting', 'Viewing', 'Editing'] as const; + const modeValues = ['suggesting', 'viewing', 'editing'] as const; + + for (let i = 0; i < modes.length; i++) { + await modeButton.click(); + await superdoc.waitForStable(); + + await superdoc.page.locator('[data-item="btn-documentMode-option"]').filter({ hasText: modes[i] }).click(); + await superdoc.waitForStable(); + + await expect(modeButton).toContainText(modes[i]); + await superdoc.assertDocumentMode(modeValues[i]); + } +}); diff --git a/tests/behavior/tests/toolbar/link.spec.ts b/tests/behavior/tests/toolbar/link.spec.ts new file mode 100644 index 000000000..3b2c14c48 --- /dev/null +++ b/tests/behavior/tests/toolbar/link.spec.ts @@ -0,0 +1,162 @@ +import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +const LINK_DROPDOWN = '.link-input-ctn'; + +/** + * Apply a link to the current selection and wait for the dropdown to fully close. + * The dropdown animates closed after apply — waitForStable() alone isn't enough. + */ +async function applyLink(superdoc: SuperDocFixture, href: string): Promise { + const page = superdoc.page; + + const linkButton = page.locator('[data-item="btn-link"]'); + await linkButton.click(); + await superdoc.waitForStable(); + + const urlInput = page.locator(`${LINK_DROPDOWN} input[name="link"]`); + await urlInput.fill(href); + await page.locator('[data-item="btn-link-apply"]').click(); + + // Wait for the dropdown to close — it animates away over ~300ms + await page.locator(LINK_DROPDOWN).waitFor({ state: 'hidden', timeout: 5000 }); + await superdoc.waitForStable(); +} + +test('insert link on selected text', async ({ superdoc }) => { + await superdoc.type('Visit our website for details'); + await superdoc.waitForStable(); + await superdoc.snapshot('typed text'); + + // Select "website" + const pos = await superdoc.findTextPos('website'); + await superdoc.setTextSelection(pos, pos + 'website'.length); + await superdoc.waitForStable(); + await superdoc.snapshot('website selected'); + + await applyLink(superdoc, 'https://example.com'); + await superdoc.snapshot('link applied'); + + // Assert link mark exists + await superdoc.assertTextHasMarks('website', ['link']); + await superdoc.assertTextMarkAttrs('website', 'link', { href: 'https://example.com' }); +}); + +test('edit existing link', async ({ superdoc }) => { + await superdoc.type('Visit our website for details'); + await superdoc.waitForStable(); + + // Select "website" and add a link + const pos = await superdoc.findTextPos('website'); + await superdoc.setTextSelection(pos, pos + 'website'.length); + await superdoc.waitForStable(); + + await applyLink(superdoc, 'https://example.com'); + await superdoc.snapshot('link created'); + + // Re-select the linked text and open the link dropdown to edit + const linkPos = await superdoc.findTextPos('website'); + await superdoc.setTextSelection(linkPos, linkPos + 'website'.length); + await superdoc.waitForStable(); + + const linkButton = superdoc.page.locator('[data-item="btn-link"]'); + await linkButton.click(); + await superdoc.waitForStable(); + + // Should show "Edit link" title + await expect(superdoc.page.locator('.link-title')).toHaveText('Edit link'); + await superdoc.snapshot('edit link dropdown open'); + + // Clear and type new URL + const editUrlInput = superdoc.page.locator(`${LINK_DROPDOWN} input[name="link"]`); + await editUrlInput.fill('https://updated.com'); + await superdoc.page.locator('[data-item="btn-link-apply"]').click(); + await superdoc.page.locator(LINK_DROPDOWN).waitFor({ state: 'hidden', timeout: 5000 }); + await superdoc.waitForStable(); + await superdoc.snapshot('link updated'); + + // Assert updated href + await superdoc.assertTextMarkAttrs('website', 'link', { href: 'https://updated.com' }); +}); + +test('remove link', async ({ superdoc }) => { + await superdoc.type('Visit our website for details'); + await superdoc.waitForStable(); + + // Select "website" and add a link + const pos = await superdoc.findTextPos('website'); + await superdoc.setTextSelection(pos, pos + 'website'.length); + await superdoc.waitForStable(); + + await applyLink(superdoc, 'https://example.com'); + + // Verify link exists + const linkPos = await superdoc.findTextPos('website'); + await superdoc.assertTextHasMarks('website', ['link']); + await superdoc.snapshot('link exists'); + + // Re-select and open link dropdown, click Remove + await superdoc.setTextSelection(linkPos, linkPos + 'website'.length); + await superdoc.waitForStable(); + + const linkButton = superdoc.page.locator('[data-item="btn-link"]'); + await linkButton.click(); + await superdoc.waitForStable(); + + // Wait for the "Edit link" dropdown to fully render before clicking Remove + await expect(superdoc.page.locator('.link-title')).toHaveText('Edit link'); + await superdoc.snapshot('link dropdown before remove'); + + await superdoc.page.locator('[data-item="btn-link-remove"]').click(); + await superdoc.page.locator(LINK_DROPDOWN).waitFor({ state: 'hidden', timeout: 5000 }); + await superdoc.waitForStable(); + await superdoc.snapshot('after link removed'); + + // Assert link mark is gone — re-find position after removal + await superdoc.assertTextLacksMarks('website', ['link']); + + // Assert the text itself is still there + await superdoc.assertTextContains('website'); +}); + +test('link is not editable in viewing mode', async ({ superdoc }) => { + await superdoc.type('Visit our website for details'); + await superdoc.waitForStable(); + + // Add a link first + const pos = await superdoc.findTextPos('website'); + await superdoc.setTextSelection(pos, pos + 'website'.length); + await superdoc.waitForStable(); + + await applyLink(superdoc, 'https://example.com'); + await superdoc.snapshot('link created in editing mode'); + + // Switch to viewing mode via toolbar dropdown + const modeButton = superdoc.page.locator('[data-item="btn-documentMode"]'); + await modeButton.click(); + await superdoc.waitForStable(); + + const viewingOption = superdoc.page.locator('[data-item="btn-documentMode-option"]').filter({ hasText: 'Viewing' }); + await viewingOption.click(); + await superdoc.waitForStable(); + await superdoc.snapshot('switched to viewing mode'); + + // Link toolbar button should be disabled in viewing mode + const linkButton = superdoc.page.locator('[data-item="btn-link"]'); + await expect(linkButton).toHaveClass(/disabled/); + + // Click on the linked text in the document to trigger link details popup + const linkElement = superdoc.page.locator('.superdoc-link:has-text("website")'); + await linkElement.click(); + await superdoc.waitForStable(); + + // Should show "Link details" (not "Edit link") — use .first() since there may be + // a stale toolbar dropdown element in addition to the link details popup + await expect(superdoc.page.locator('.link-title').first()).toHaveText('Link details'); + await superdoc.snapshot('link details popup in viewing mode'); + + // Remove and Apply buttons should not be visible + await expect(superdoc.page.locator('[data-item="btn-link-remove"]')).toHaveCount(0); + await expect(superdoc.page.locator('[data-item="btn-link-apply"]')).toHaveCount(0); +}); diff --git a/tests/behavior/tests/toolbar/table-styles.spec.ts b/tests/behavior/tests/toolbar/table-styles.spec.ts new file mode 100644 index 000000000..ba8f47af3 --- /dev/null +++ b/tests/behavior/tests/toolbar/table-styles.spec.ts @@ -0,0 +1,158 @@ +import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +/** + * Insert a 2x2 table, type text in the first cell, and select it. + */ +async function insertTableAndTypeInCell(superdoc: SuperDocFixture, text: string): Promise { + await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false }); + await superdoc.waitForStable(); + + await superdoc.type(text); + await superdoc.waitForStable(); + + const pos = await superdoc.findTextPos(text); + await superdoc.setTextSelection(pos, pos + text.length); + await superdoc.waitForStable(); +} + +test('bold inside a table cell', async ({ superdoc }) => { + await insertTableAndTypeInCell(superdoc, 'table text'); + await superdoc.snapshot('table text selected'); + + const boldButton = superdoc.page.locator('[data-item="btn-bold"]'); + await boldButton.click(); + await superdoc.waitForStable(); + + await expect(boldButton).toHaveClass(/active/); + await superdoc.snapshot('bold applied in cell'); + + await superdoc.assertTextHasMarks('table text', ['bold']); +}); + +test('multiple styles in one cell', async ({ superdoc }) => { + await insertTableAndTypeInCell(superdoc, 'styled cell'); + await superdoc.snapshot('text selected in cell'); + + // Apply bold + italic + color + await superdoc.page.locator('[data-item="btn-bold"]').click(); + await superdoc.waitForStable(); + + await superdoc.page.locator('[data-item="btn-italic"]').click(); + await superdoc.waitForStable(); + await superdoc.snapshot('bold + italic applied'); + + // Open color dropdown and pick red + await superdoc.page.locator('[data-item="btn-color"]').click(); + await superdoc.waitForStable(); + await superdoc.page.locator('.option[aria-label="red"]').first().click(); + await superdoc.waitForStable(); + + // Assert all toolbar states + await expect(superdoc.page.locator('[data-item="btn-bold"]')).toHaveClass(/active/); + await expect(superdoc.page.locator('[data-item="btn-italic"]')).toHaveClass(/active/); + const colorBar = superdoc.page.locator('[data-item="btn-color"] .color-bar'); + await expect(colorBar).toHaveCSS('background-color', 'rgb(210, 0, 63)'); + await superdoc.snapshot('bold + italic + red color applied'); + + // Assert all marks + await superdoc.assertTextHasMarks('styled cell', ['bold', 'italic']); + await superdoc.assertTextMarkAttrs('styled cell', 'textStyle', { color: '#D2003F' }); +}); + +test('different styles in different cells', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false }); + await superdoc.waitForStable(); + + // Type and bold in first cell + await superdoc.type('bold cell'); + await superdoc.waitForStable(); + + let pos1 = await superdoc.findTextPos('bold cell'); + await superdoc.setTextSelection(pos1, pos1 + 'bold cell'.length); + await superdoc.waitForStable(); + + await superdoc.page.locator('[data-item="btn-bold"]').click(); + await superdoc.waitForStable(); + await superdoc.snapshot('first cell bolded'); + + // Tab to second cell, type and apply italic + await superdoc.press('Tab'); + await superdoc.type('italic cell'); + await superdoc.waitForStable(); + + let pos2 = await superdoc.findTextPos('italic cell'); + await superdoc.setTextSelection(pos2, pos2 + 'italic cell'.length); + await superdoc.waitForStable(); + + await superdoc.page.locator('[data-item="btn-italic"]').click(); + await superdoc.waitForStable(); + await superdoc.snapshot('second cell italicized'); + + // Assert first cell is bold (not italic) + await superdoc.assertTextHasMarks('bold cell', ['bold']); + await superdoc.assertTextLacksMarks('bold cell', ['italic']); + + // Assert second cell is italic (not bold) + await superdoc.assertTextHasMarks('italic cell', ['italic']); + await superdoc.assertTextLacksMarks('italic cell', ['bold']); +}); + +test('font family and size in a table cell', async ({ superdoc }) => { + await insertTableAndTypeInCell(superdoc, 'fancy text'); + await superdoc.snapshot('text selected in cell'); + + // Change font family + await superdoc.page.locator('[data-item="btn-fontFamily"]').click(); + await superdoc.waitForStable(); + await superdoc.page.locator('[data-item="btn-fontFamily-option"]').filter({ hasText: 'Georgia' }).click(); + await superdoc.waitForStable(); + await superdoc.snapshot('Georgia font applied'); + + // Change font size + await superdoc.page.locator('[data-item="btn-fontSize"]').click(); + await superdoc.waitForStable(); + await superdoc.page.locator('[data-item="btn-fontSize-option"]').filter({ hasText: '24' }).click(); + await superdoc.waitForStable(); + + // Assert toolbar + await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .button-label')).toHaveText('Georgia'); + await expect(superdoc.page.locator('#inlineTextInput-fontSize')).toHaveValue('24'); + await superdoc.snapshot('Georgia 24pt applied in cell'); + + // Assert text style + await superdoc.assertTextMarkAttrs('fancy text', 'textStyle', { fontFamily: 'Georgia' }); + await superdoc.assertTextMarkAttrs('fancy text', 'textStyle', { fontSize: '24pt' }); +}); + +test('styles survive cell navigation', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false }); + await superdoc.waitForStable(); + + // Type, select, and bold in first cell + await superdoc.type('persist me'); + await superdoc.waitForStable(); + + let pos = await superdoc.findTextPos('persist me'); + await superdoc.setTextSelection(pos, pos + 'persist me'.length); + await superdoc.waitForStable(); + + await superdoc.page.locator('[data-item="btn-bold"]').click(); + await superdoc.waitForStable(); + await superdoc.snapshot('bold applied in first cell'); + + // Navigate away to second cell and back + await superdoc.press('Tab'); + await superdoc.type('other cell'); + await superdoc.waitForStable(); + await superdoc.snapshot('navigated to second cell'); + + // Navigate back (Shift+Tab) + await superdoc.press('Shift+Tab'); + await superdoc.waitForStable(); + await superdoc.snapshot('navigated back to first cell'); + + // Assert bold still present on first cell text + await superdoc.assertTextHasMarks('persist me', ['bold']); +}); diff --git a/tests/behavior/tests/toolbar/table.spec.ts b/tests/behavior/tests/toolbar/table.spec.ts new file mode 100644 index 000000000..8e8e3f715 --- /dev/null +++ b/tests/behavior/tests/toolbar/table.spec.ts @@ -0,0 +1,163 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { countTableCells } from '../../helpers/table.js'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +test('insert table via toolbar grid', async ({ superdoc }) => { + await superdoc.type('Text before table'); + await superdoc.newLine(); + await superdoc.waitForStable(); + await superdoc.snapshot('text typed'); + + // Open the table dropdown + const tableButton = superdoc.page.locator('[data-item="btn-table"]'); + await tableButton.click(); + await superdoc.waitForStable(); + await superdoc.snapshot('table grid open'); + + // Click the 3x3 cell in the grid (data-cols="3" data-rows="3") + const cell = superdoc.page.locator('.toolbar-table-grid__item[data-cols="3"][data-rows="3"]'); + await cell.click(); + await superdoc.waitForStable(); + await superdoc.snapshot('3x3 table inserted'); + + // Assert table exists with 3 rows and 3 columns + await superdoc.assertTableExists(3, 3); +}); + +test('header-row tables count headers as cells', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 2, cols: 3, withHeaderRow: true }); + await superdoc.waitForStable(); + await superdoc.snapshot('2x3 header-row table inserted'); + + await superdoc.assertTableExists(2, 3); + await expect.poll(() => countTableCells(superdoc.page)).toBe(6); +}); + +test('type and navigate between cells with Tab', async ({ superdoc }) => { + // Insert a 2x2 table + await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false }); + await superdoc.waitForStable(); + await superdoc.snapshot('empty 2x2 table'); + + // Type in first cell + await superdoc.type('Cell A1'); + + // Tab to next cell and type + await superdoc.press('Tab'); + await superdoc.type('Cell B1'); + + // Tab to next row + await superdoc.press('Tab'); + await superdoc.type('Cell A2'); + + await superdoc.press('Tab'); + await superdoc.type('Cell B2'); + await superdoc.waitForStable(); + await superdoc.snapshot('all cells filled'); + + // Assert all cell text exists + await superdoc.assertTextContains('Cell A1'); + await superdoc.assertTextContains('Cell B1'); + await superdoc.assertTextContains('Cell A2'); + await superdoc.assertTextContains('Cell B2'); +}); + +test('add and delete rows via table actions toolbar', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false }); + await superdoc.waitForStable(); + await superdoc.assertTableExists(2, 2); + + // Type in first cell so cursor is inside table + await superdoc.type('Hello'); + await superdoc.waitForStable(); + await superdoc.snapshot('initial 2x2 table'); + + // Open table actions dropdown and add row after + const tableActionsButton = superdoc.page.locator('[data-item="btn-tableActions"]'); + await tableActionsButton.click(); + await superdoc.waitForStable(); + + await superdoc.page.locator('[aria-label="Add row after"]').click(); + await superdoc.waitForStable(); + await superdoc.assertTableExists(3, 2); + await superdoc.snapshot('after add row (3x2)'); + + // Add column after + await tableActionsButton.click(); + await superdoc.waitForStable(); + + await superdoc.page.locator('[aria-label="Add column after"]').click(); + await superdoc.waitForStable(); + await superdoc.assertTableExists(3, 3); + await superdoc.snapshot('after add column (3x3)'); + + // Delete the row we added + await tableActionsButton.click(); + await superdoc.waitForStable(); + + await superdoc.page.locator('[aria-label="Delete row"]').click(); + await superdoc.waitForStable(); + await superdoc.assertTableExists(2, 3); + await superdoc.snapshot('after delete row (2x3)'); + + // Delete the column we added + await tableActionsButton.click(); + await superdoc.waitForStable(); + + await superdoc.page.locator('[aria-label="Delete column"]').click(); + await superdoc.waitForStable(); + await superdoc.assertTableExists(2, 2); + await superdoc.snapshot('after delete column (2x2)'); +}); + +test('merge and split cells', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 2, cols: 2, withHeaderRow: false }); + await superdoc.waitForStable(); + + // Type in first cell + await superdoc.type('Merge me'); + await superdoc.waitForStable(); + await superdoc.snapshot('table with text'); + + // Select the first two cells in the first row using CellSelection + await superdoc.page.evaluate(() => { + const editor = (window as any).editor; + const { state } = editor; + let firstCellPos = -1; + let secondCellPos = -1; + let cellCount = 0; + + state.doc.descendants((node: any, pos: number) => { + if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') { + cellCount++; + if (cellCount === 1) firstCellPos = pos; + if (cellCount === 2) secondCellPos = pos; + } + }); + + if (firstCellPos !== -1 && secondCellPos !== -1) { + editor.commands.setCellSelection({ anchorCell: firstCellPos, headCell: secondCellPos }); + } + }); + await superdoc.waitForStable(); + await superdoc.snapshot('two cells selected'); + + // Merge the selected cells + await superdoc.executeCommand('mergeCells'); + await superdoc.waitForStable(); + await superdoc.snapshot('cells merged'); + + // Count cells — first row should have 1 cell instead of 2 + const cellCount = await countTableCells(superdoc.page); + // 2x2 table with first row merged = 3 cells (1 merged + 2 in second row) + expect(cellCount).toBe(3); + + // Split the merged cell back + await superdoc.executeCommand('splitCell'); + await superdoc.waitForStable(); + await superdoc.snapshot('cells split back'); + + const cellCountAfterSplit = await countTableCells(superdoc.page); + expect(cellCountAfterSplit).toBe(4); +}); diff --git a/tests/behavior/tests/toolbar/undo-redo.spec.ts b/tests/behavior/tests/toolbar/undo-redo.spec.ts new file mode 100644 index 000000000..73dea67df --- /dev/null +++ b/tests/behavior/tests/toolbar/undo-redo.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test.use({ config: { toolbar: 'full' } }); + +test('undo button removes last typed text', async ({ superdoc }) => { + const undoButton = superdoc.page.locator('[data-item="btn-undo"]'); + + await superdoc.type('First paragraph.'); + await superdoc.newLine(); + await superdoc.type('Second paragraph.'); + await superdoc.waitForStable(); + + await superdoc.assertTextContains('Second paragraph.'); + + await undoButton.click(); + await superdoc.waitForStable(); + + await superdoc.assertTextNotContains('Second paragraph.'); + await superdoc.assertTextContains('First paragraph.'); +}); + +test('redo button restores undone text', async ({ superdoc }) => { + const undoButton = superdoc.page.locator('[data-item="btn-undo"]'); + const redoButton = superdoc.page.locator('[data-item="btn-redo"]'); + + await superdoc.type('First paragraph.'); + await superdoc.newLine(); + await superdoc.type('Second paragraph.'); + await superdoc.waitForStable(); + + await undoButton.click(); + await superdoc.waitForStable(); + await superdoc.assertTextNotContains('Second paragraph.'); + + await redoButton.click(); + await superdoc.waitForStable(); + + await superdoc.assertTextContains('First paragraph.'); + await superdoc.assertTextContains('Second paragraph.'); +}); diff --git a/tests/behavior/tsconfig.json b/tests/behavior/tsconfig.json new file mode 100644 index 000000000..faf7f3e43 --- /dev/null +++ b/tests/behavior/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["tests/**/*.ts", "fixtures/**/*.ts", "helpers/**/*.ts", "playwright.config.ts"] +} diff --git a/tests/visual/harness/custom-extensions.ts b/tests/visual/harness/custom-extensions.ts new file mode 100644 index 000000000..bc36d67bd --- /dev/null +++ b/tests/visual/harness/custom-extensions.ts @@ -0,0 +1,112 @@ +import { Extensions } from 'superdoc'; + +const { Extension, Plugin, PluginKey, Decoration, DecorationSet } = Extensions; + +export const CUSTOMER_FOCUS_EXTENSION_NAME = 'customer-focus-highlight'; + +const focusPluginKey = new PluginKey('customer-focus'); + +/** + * Ported from `packages/superdoc/src/dev/components/SuperdocDev.vue` in `../superdoc4`. + * Exposes `editor.commands.setFocus(from, to)` and `editor.commands.clearFocus()`. + */ +const CustomerFocusHighlight = Extension.create({ + name: CUSTOMER_FOCUS_EXTENSION_NAME, + + addCommands() { + return { + setFocus: + (from: number, to: number) => + ({ state, dispatch }: any) => { + if (dispatch) { + const tr = state.tr.setMeta(focusPluginKey, { from, to }); + dispatch(tr); + } + return true; + }, + + clearFocus: + () => + ({ state, dispatch }: any) => { + if (dispatch) { + const tr = state.tr.setMeta(focusPluginKey, { from: 0, to: 0 }); + dispatch(tr); + } + return true; + }, + }; + }, + + addPmPlugins() { + return [ + new Plugin({ + key: focusPluginKey, + state: { + init() { + return DecorationSet.empty; + }, + + apply(tr: any, pluginState: any) { + const meta = tr.getMeta(focusPluginKey); + if (!meta) { + return pluginState.map(tr.mapping, tr.doc); + } + + const { from, to } = meta; + if (from === to) { + return DecorationSet.empty; + } + + return DecorationSet.create(tr.doc, [ + Decoration.inline(from, to, { + class: 'highlight-selection', + }), + ]); + }, + }, + props: { + decorations(state: any) { + return focusPluginKey.getState(state); + }, + }, + }), + ]; + }, +}); + +const CUSTOM_EXTENSION_REGISTRY: Record = { + [CUSTOMER_FOCUS_EXTENSION_NAME]: CustomerFocusHighlight, +}; + +function parseExtensionsParam(value: string | null): string[] { + if (!value) return []; + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +export function getRequestedCustomExtensionNames(searchParams: URLSearchParams): string[] { + return parseExtensionsParam(searchParams.get('extensions')); +} + +export function resolveCustomExtensions(names: string[]): any[] { + if (!names.length) return []; + + const resolved: any[] = []; + const seen = new Set(); + + for (const name of names) { + if (seen.has(name)) continue; + seen.add(name); + + const extension = CUSTOM_EXTENSION_REGISTRY[name]; + if (!extension) { + console.warn(`[visual-harness] Unknown custom extension requested: ${name}`); + continue; + } + resolved.push(extension); + } + + return resolved; +} diff --git a/tests/visual/harness/index.html b/tests/visual/harness/index.html index 2ae45f617..06fd418a0 100644 --- a/tests/visual/harness/index.html +++ b/tests/visual/harness/index.html @@ -4,6 +4,12 @@ SuperDoc Test Harness + diff --git a/tests/visual/harness/main.ts b/tests/visual/harness/main.ts index 0b13c96d7..4badb31d3 100644 --- a/tests/visual/harness/main.ts +++ b/tests/visual/harness/main.ts @@ -1,5 +1,6 @@ import 'superdoc/style.css'; import { SuperDoc } from 'superdoc'; +import { getRequestedCustomExtensionNames, resolveCustomExtensions } from './custom-extensions.js'; const params = new URLSearchParams(location.search); const layout = params.get('layout') !== '0'; @@ -8,6 +9,7 @@ const hideSelection = params.get('hideSelection') !== '0'; const toolbar = params.get('toolbar'); const comments = params.get('comments'); const trackChanges = params.get('trackChanges') === '1'; +const customExtensionNames = getRequestedCustomExtensionNames(params); if (hideCaret) { document.documentElement.style.setProperty('caret-color', 'transparent', 'important'); @@ -57,6 +59,11 @@ function init(file?: File) { config.trackChanges = { visible: true }; } + const customExtensions = resolveCustomExtensions(customExtensionNames); + if (customExtensions.length > 0) { + config.editorExtensions = customExtensions; + } + instance = new SuperDoc(config); if (hideSelection) { diff --git a/tests/visual/tests/behavior/custom-extension/customer-focus-highlight.spec.ts b/tests/visual/tests/behavior/custom-extension/customer-focus-highlight.spec.ts new file mode 100644 index 000000000..c173617e8 --- /dev/null +++ b/tests/visual/tests/behavior/custom-extension/customer-focus-highlight.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from '../../fixtures/superdoc.js'; + +test.use({ + config: { + extensions: ['customer-focus-highlight'], + hideSelection: false, + }, +}); + +test('@behavior custom extension scaffold wires customer focus highlight', async ({ superdoc }) => { + await superdoc.type('Customer focus extension smoke test'); + await superdoc.waitForStable(); + + const extensionState = await superdoc.page.evaluate(() => { + const superdocConfig = (window as any).superdoc?.config; + const editor = (window as any).editor; + + return { + locationSearch: window.location.search, + configuredExtensions: (superdocConfig?.editorExtensions || []).map((ext: any) => ext?.name), + externalExtensions: (editor?.options?.externalExtensions || []).map((ext: any) => ext?.name), + }; + }); + + expect(extensionState.locationSearch).toContain('extensions=customer-focus-highlight'); + expect(extensionState.configuredExtensions).toContain('customer-focus-highlight'); + expect(extensionState.externalExtensions).toContain('customer-focus-highlight'); + + const commands = await superdoc.page.evaluate(() => { + const editor = (window as any).editor; + return { + setFocus: typeof editor?.commands?.setFocus === 'function', + clearFocus: typeof editor?.commands?.clearFocus === 'function', + }; + }); + + expect(commands.setFocus).toBe(true); + expect(commands.clearFocus).toBe(true); + + await superdoc.page.evaluate(() => { + (window as any).editor.commands.setFocus(1, 9); + }); + await superdoc.waitForStable(); + + const highlightCountsAfterSet = await superdoc.page.evaluate(() => { + return { + total: document.querySelectorAll('.highlight-selection').length, + painted: document.querySelectorAll('.superdoc-page .highlight-selection').length, + hiddenPm: document.querySelectorAll('[contenteditable="true"] .highlight-selection').length, + }; + }); + expect(highlightCountsAfterSet.total).toBeGreaterThan(0); + expect(highlightCountsAfterSet.painted).toBeGreaterThan(0); + + await superdoc.page.evaluate(() => { + (window as any).editor.commands.clearFocus(); + }); + await superdoc.waitForStable(); + + const highlightCountAfterClear = await superdoc.page.locator('.highlight-selection').count(); + expect(highlightCountAfterClear).toBe(0); +}); diff --git a/tests/visual/tests/fixtures/superdoc.ts b/tests/visual/tests/fixtures/superdoc.ts index 49eb61efe..44b7e0f8f 100644 --- a/tests/visual/tests/fixtures/superdoc.ts +++ b/tests/visual/tests/fixtures/superdoc.ts @@ -12,6 +12,7 @@ interface HarnessConfig { toolbar?: 'none' | 'minimal' | 'full'; comments?: 'off' | 'on' | 'panel' | 'readonly'; trackChanges?: boolean; + extensions?: string[]; hideCaret?: boolean; hideSelection?: boolean; width?: number; @@ -24,6 +25,7 @@ function buildHarnessUrl(config: HarnessConfig = {}): string { if (config.toolbar) params.set('toolbar', config.toolbar); if (config.comments) params.set('comments', config.comments); if (config.trackChanges) params.set('trackChanges', '1'); + if (config.extensions?.length) params.set('extensions', config.extensions.join(',')); if (config.hideCaret !== undefined) params.set('hideCaret', config.hideCaret ? '1' : '0'); if (config.hideSelection !== undefined) params.set('hideSelection', config.hideSelection ? '1' : '0'); if (config.width) params.set('width', String(config.width));