Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node

- name: 🌐 Install Playwright Chromium
run: pnpm --filter muya-e2e exec playwright install --with-deps chromium
- name: 🌐 Install Playwright browsers (chromium + firefox + webkit)
run: pnpm --filter muya-e2e exec playwright install --with-deps chromium firefox webkit

- name: 🎭 Run Playwright tests
run: pnpm e2e
Expand Down
53 changes: 37 additions & 16 deletions e2e/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
| Phase | Theme | Tests | CI delta | Status |
| --- | --- | --- | --- | --- |
| 1 | P0 smoke + key interaction skeleton (infra) | 28 (1 fixme) | ~3-4 min | ✅ landed |
| 2 | Cross-browser matrix + drag/IME | +20 → 48 | +4-6 min | ⏳ pending |
| 2 | Cross-browser matrix + drag/IME | 180 (11 skipped) | +4-6 min | ✅ landed |
| 3 | Render depth + remaining blocks + security | +23 → 78 (1 fixme) | +2-3 min | ✅ landed |
| 4 | Stability / performance / a11y guardrails | +28 → 106 | +3-5 min | ✅ landed |

Expand All @@ -13,41 +13,62 @@ Phase 1 baseline (PR landing snapshot):
- **Local runtime ~8 s** (Chromium only, parallel workers)
- **CI target: ~3-4 min** (bundled Chromium install + tests + artifact upload)

Phase 2 landing snapshot:

- **169 passed, 11 skipped** across the Chromium + Firefox + WebKit
matrix. The skipped count is the sum of engine-specific gaps:
- WebKit IME (3 specs) — synthetic CompositionEvent unreliable
under parallel matrix load
- Firefox + WebKit `editing/search-replace.spec.ts` (#all) — toolbar
driver fires synchronously and the engines swallow mid-flight
selection changes
- Firefox + WebKit `inline/format-toolbar.spec.ts` +
`inline/shortcuts.spec.ts` — Phase 1 specs rely on Chromium's
triple-click select-paragraph behaviour
- Firefox + WebKit synthetic-DataTransfer clipboard tests
(4 specs each) — engines null `clipboardData` on
`new ClipboardEvent('paste', { clipboardData })`
- **Local runtime ~30 s** for the full matrix; ~3 s Chromium-only.

---

## Phase 2 — Cross-browser matrix + drag / IME
## Phase 2 — Cross-browser matrix + drag / IME ✅ landed

Unlocks Firefox + WebKit, and the input/drag flows that don't survive cross-engine differences.

### Cross-browser

- [ ] Uncomment `firefox` + `webkit` projects in `playwright.config.ts`.
- [ ] Drop the `--project=chromium` filter from `pnpm e2e`; add `pnpm e2e:firefox` / `pnpm e2e:webkit` aliases for targeted runs.
- [ ] `ci-e2e.yml`: install all three browsers (`playwright install --with-deps`), bump runner concurrency.
- [x] Uncomment `firefox` + `webkit` projects in `playwright.config.ts`.
- [x] Drop the `--project=chromium` filter from `pnpm e2e`; add `pnpm e2e:firefox` / `pnpm e2e:webkit` aliases for targeted runs.
- [x] `ci-e2e.yml`: install all three browsers (`playwright install --with-deps`), bump runner concurrency.

### IME composition

- [ ] CJK candidate flow: `compositionstart` → multiple `input` events with `isComposing=true` → `compositionend`. Assert that `selection-change` only fires *after* `compositionend` and that the committed text lands as a single state mutation.
- [ ] CJK in lists / table cells (different parent contexts).
- [x] CJK candidate flow: `compositionstart` → multiple `input` events with `isComposing=true` → `compositionend`. Assert that the committed text lands AFTER compositionend (mid-burst, state stays at pre-composition text).
- [x] CJK in lists / table cells (different parent contexts).

### Drag and drop

- [ ] **TableDragBar row/column resize.** Hover table edge → bar appears → mousedown + mousemove (delta) + mouseup → assert row height / column width changed in state meta.
- [ ] **ParagraphFrontButton block reorder.** Drag the front handle from paragraph A to position above paragraph B → assert `getMarkdown` order swapped.
- [ ] **ImageResizeBar.** Click block-aligned image → drag a corner handle → assert width meta updated.
- [x] **TableDragBar column reorder.** Hover just below a header cell → bar appears (asserted via the wrapper's opacity:1 transition) → mousedown + 300 ms hold + mousemove past next column + mouseup → assert `getMarkdown()` returns the columns in swapped order. NOTE: the bar is a *reorder* tool, not a *resize* tool — cells don't carry a width meta, so the original BACKLOG framing ("assert column meta width changed") was based on a misreading.
- [x] **ParagraphFrontButton block reorder.** Drag the front handle from paragraph A to position below paragraph B → assert `getMarkdown` order swapped.
- [x] **ImageResizeBar.** Click block-aligned image (data URI to avoid network) → drag the right handle → assert `<img width="…">` lands in the final markdown.

### E2E TypeScript typecheck

- [ ] Add `lint:types` script to `e2e/package.json` (currently absent because the imported `@muyajs/core` source pulls in `__MUYA_BLOCK__` / module-augmentation globals that aren't re-declared in `e2e/types.d.ts`).
- [ ] Add `lint:types` script to `e2e/package.json` (currently absent because the imported `@muyajs/core` source pulls in `__MUYA_BLOCK__` / module-augmentation globals that aren't re-declared in `e2e/types.d.ts`). Carried to Phase 3.
- [ ] Either re-declare the needed globals in `e2e/types.d.ts`, or include `packages/core/src/types/global.d.ts` from the e2e tsconfig.

### Real HTML clipboard

- [ ] Replace the `test.fixme` in `tests/editing/clipboard.spec.ts` with a real paste via:
- `context.grantPermissions(['clipboard-read', 'clipboard-write'])` +
- `navigator.clipboard.write([new ClipboardItem({ 'text/html': new Blob([html], { type: 'text/html' }) })])` +
- `keyboard.press('Cmd/Ctrl+V')`.
- [ ] Cover: paste `<b>` → `**…**`, paste `<a href>` → `[…](url)`, paste `<table>` → GFM table, paste plain text fallback.
- [x] Replace the `test.fixme` in `tests/editing/clipboard.spec.ts` with a real paste via synthetic `ClipboardEvent` + populated `DataTransfer`. Chromium-only — Firefox nulls `clipboardData` on synthetic ClipboardEvents (bug 1456493) and WebKit denies the permission in headless mode. The keyboard `Cmd/Ctrl+V` path doesn't work either: headless Chromium has an empty OS clipboard so the keystroke fires `clipboardData: null`. The synthetic-DataTransfer path mirrors what the editor sees from a real paste end-to-end (same `pasteHandler` code path), at the cost of Chromium-only coverage. Tracked alongside Firefox/WebKit clipboard parity in Phase 3.
- [x] Cover: paste `<b>` → `**…**`, paste `<a href>` → `[…](url)`, paste `<table>` → GFM table, paste plain text fallback.

### Phase 2 carryovers to Phase 3

- Cross-engine `editing/search-replace.spec.ts` rewrite (Firefox + WebKit both gated). Root cause: the host toolbar fires `replace()` synchronously and both engines swallow mid-flight DOM selection changes.
- Cross-engine `inline/format-toolbar.spec.ts` + `inline/shortcuts.spec.ts` rewrite — both rely on Chromium's triple-click select-paragraph behaviour.
- WebKit IME — synthetic CompositionEvent + InputEvent path reads stale block state under parallel-matrix load. Unit tests in `packages/core/src/block/base/__tests__/autoPair.spec.ts` cover the composeHandler branches.
- Firefox + WebKit clipboard parity (above).

---

Expand Down
21 changes: 18 additions & 3 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,30 @@ From the repo root:

```sh
pnpm install # one-time, picks up @playwright/test
pnpm e2e # full suiteuses system Chrome locally
pnpm e2e # full matrixChromium + Firefox + WebKit
pnpm e2e:ui # Playwright UI mode (recommended for debugging)
pnpm e2e:headed # headed Chrome with normal page UI
```

On CI (`CI=1`), Playwright uses the bundled Chromium downloaded by `pnpm e2e:install`. Locally, the config falls back to the OS-installed Chrome so you don't need the 170 MB Chromium-for-Testing download. To force bundled Chromium locally:
Targeted runs (Phase 2 added the cross-browser matrix):

```sh
pnpm --filter muya-e2e e2e:chromium # Chromium only (system Chrome locally)
pnpm --filter muya-e2e e2e:firefox # Firefox only (bundled binary)
pnpm --filter muya-e2e e2e:webkit # WebKit only (bundled binary)
```

On CI (`CI=1`), Playwright uses the bundled Chromium / Firefox / WebKit downloaded by the `playwright install --with-deps chromium firefox webkit` step in `ci-e2e.yml`. Locally, the Chromium project falls back to the OS-installed Chrome so you don't need the 170 MB Chromium-for-Testing download; Firefox and WebKit have no system equivalent, so you must download them once:

```sh
pnpm --filter muya-e2e exec playwright install firefox webkit
# or, to install all three at once:
pnpm e2e:install
```

To force bundled Chromium locally:

```sh
pnpm e2e:install # one-time, downloads bundled Chromium
PLAYWRIGHT_USE_BUNDLED_CHROMIUM=1 pnpm e2e
```

Expand Down
16 changes: 12 additions & 4 deletions e2e/host/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,20 @@ import {
import './style.css';

// Intl.Segmenter polyfill — required on Firefox; harmless on Chromium.
// eslint-disable-next-line no-restricted-syntax -- forced cast: `Intl` is augmented globally in types.d.ts to declare `Segmenter`, but TS still types `globalThis.Intl` as `{}` for the runtime side. We narrow via a private alias rather than poking the global namespace from the polyfill site.
const intlNs = Intl as unknown as { Segmenter?: typeof Intl.Segmenter };
if (!intlNs.Segmenter) {
// The DOM lib types `Intl` as a const namespace, so a structural cast is
// unavoidable for the existence check + assignment. Pull the unsafe
// boundary into one tightly-scoped helper so the rest of host/main.ts
// stays clean.
async function ensureIntlSegmenter(): Promise<void> {
interface ISegmenterHolder { Segmenter?: typeof Intl.Segmenter }
// eslint-disable-next-line no-restricted-syntax -- structural widening over the const Intl namespace; alternative is augmenting global Intl which leaks polyfill semantics into every consumer
const holder = Intl as unknown as ISegmenterHolder;
if (holder.Segmenter)
return;
const polyfill = await import('intl-segmenter-polyfill/dist/bundled');
intlNs.Segmenter = await polyfill.createIntlSegmenterPolyfill() as typeof Intl.Segmenter;
holder.Segmenter = await polyfill.createIntlSegmenterPolyfill() as typeof Intl.Segmenter;
}
await ensureIntlSegmenter();

// Deterministic mocks: specs assert these exact URLs / delays.
const PICKED_IMAGE_URL = 'https://example.test/picked-image.png';
Expand Down
5 changes: 4 additions & 1 deletion e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@
"license": "MIT",
"scripts": {
"e2e": "playwright test",
"e2e:chromium": "playwright test --project=chromium",
"e2e:firefox": "playwright test --project=firefox",
"e2e:webkit": "playwright test --project=webkit",
"e2e:ui": "playwright test --ui",
"e2e:headed": "playwright test --headed",
"e2e:report": "playwright show-report",
"e2e:install": "playwright install --with-deps chromium"
"e2e:install": "playwright install --with-deps chromium firefox webkit"
},
"dependencies": {
"@muyajs/core": "workspace:*",
Expand Down
47 changes: 44 additions & 3 deletions e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,50 @@ export default defineConfig({
: 'chrome',
},
},
// Phase 2 unlocks:
// { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
// { name: 'webkit', use: { ...devices['Desktop Safari'] } },
// Phase 2: cross-browser matrix. Firefox + WebKit use the bundled
// Playwright builds (no system-channel fallback — Firefox isn't
// commonly preinstalled, and WebKit has no system equivalent on
// macOS). Install once via `pnpm --filter muya-e2e exec playwright
// install firefox webkit` (CI does this automatically through the
// `--with-deps` step in ci-e2e.yml).
//
// `triple-click select-paragraph` semantics differ between engines:
// Chromium selects the full paragraph, Firefox and WebKit select
// only the clicked word/character. The Phase 1 IFT-trigger specs
// (`inline/format-toolbar.spec.ts` and `inline/shortcuts.spec.ts`)
// were authored against Chromium's behaviour and the engine-
// independent rewrite (use `selectAll()` or a `setBaseAndExtent()`
// helper) is tracked in BACKLOG Phase 3. Until then, exclude those
// two files on Firefox + WebKit so the rest of the matrix stays
// green — every other spec works cross-engine unchanged.
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
testIgnore: [
'inline/format-toolbar.spec.ts',
'inline/shortcuts.spec.ts',
// Firefox's #all-replace path emits at most one mutation
// before the search highlight is removed, leaving the
// remaining occurrences in place. Same root cause as the
// WebKit gap below — the toolbar driver fires replace()
// synchronously and Firefox swallows mid-flight DOM
// selection changes. Tracked in BACKLOG Phase 3.
'editing/search-replace.spec.ts',
],
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
testIgnore: [
'inline/format-toolbar.spec.ts',
'inline/shortcuts.spec.ts',
// WebKit doesn't wire the host's #search/#replace toolbar
// sequence the same way Chromium does — `replace()` runs
// but emits an empty selection. Tracked alongside the
// triple-click rewrite in BACKLOG Phase 3.
'editing/search-replace.spec.ts',
],
},
],
webServer: {
command: 'pnpm exec vite --port 5174 --strictPort',
Expand Down
102 changes: 102 additions & 0 deletions e2e/tests/drag/image-resize.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { expect, test } from '../fixtures/muya';
import { getMarkdown } from '../helpers/api';
import { editor, floats } from '../helpers/selectors';

/**
* ImageResizeBar drag.
*
* The bar is a bespoke (non-baseFloat) plugin. On image click, the
* selection layer emits `muya-transformer` with the image container as
* the reference; the plugin appends two `.bar.left` / `.bar.right`
* handles to `.mu-transformer`. Mousedown on a handle wires document
* mousemove + mouseup; mousemove rewrites `image.width` attribute
* directly; mouseup calls `format.updateImage(info, 'width', String(N))`
* which rewrites the surrounding markdown to embed an `<img …>` with the
* new width.
*
* Plain markdown `![alt](src)` (no data-align attr) defaults to block
* alignment (see `packages/core/src/selection/imageDisplay.ts`), so
* `shouldShowImageResizeBar` is true and the handles materialise on
* click.
*
* Contract under test: drag the right handle outwards → final markdown
* contains `<img …>` with a `width="…"` larger than the natural width.
*/

test.describe('ImageResizeBar', () => {
test('right handle drag updates image width in markdown', async ({ page }) => {
// Use a tiny inline data-URI image so the network never sees a
// request (the host's `__e2e.PICKED_IMAGE_URL` points at a fake
// example.test URL — it would fail to load and Selection's
// click handler would skip the `target.tagName === 'IMG'` branch
// we need to fire `muya-transformer`).
//
// 1×1 transparent PNG, base64.
const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=';
await page.evaluate(uri => window.muya!.setContent(`![alt](${uri})`), dataUri);

const image = page.locator(editor.image).first();
await expect(image).toBeVisible();

// The bar listens for `muya-transformer` emitted from Selection's
// click handler — we need a *user* click on the <img> itself, not
// its wrapper, so that the selection layer takes the
// `target.tagName === 'IMG'` branch. Wait for the wrapper to flip
// to `.mu-image-success` so the <img> child is present.
await expect.poll(async () => image.evaluate(el =>
el.classList.contains('mu-image-success')), { timeout: 5_000 }).toBe(true);
const innerImg = image.locator('img').first();
await expect(innerImg).toBeVisible();
await innerImg.click();

// Two .bar elements (left + right) get appended to .mu-transformer.
const handles = page.locator(floats.imageTransformerHandle);
await expect(handles).toHaveCount(2);
const rightHandle = page.locator(`${floats.imageTransformer} .bar.right`);
await expect(rightHandle).toBeVisible();

// Record the natural width so we can assert growth.
const startBox = await innerImg.boundingBox();
if (!startBox)
throw new Error('image has no bounding box');
const startWidth = Math.round(startBox.width);

const handleBox = await rightHandle.boundingBox();
if (!handleBox)
throw new Error('right handle has no bounding box');
const handleCx = handleBox.x + handleBox.width / 2;
const handleCy = handleBox.y + handleBox.height / 2;

await page.mouse.move(handleCx, handleCy);
await page.mouse.down();
// Drag 80 px to the right. mouseMove uses `event.clientX - leftHandleRect.left`
// directly (no 300 ms timer here, unlike the other drag plugins),
// so a few intermediate steps + a final move is enough.
await page.mouse.move(handleCx + 40, handleCy, { steps: 4 });
await page.mouse.move(handleCx + 80, handleCy, { steps: 4 });
await page.mouse.up();

// After mouseup → updateImage rewrites the surrounding text and
// re-renders; the new markdown will embed an explicit width on
// the <img> tag.
await expect.poll(async () => getMarkdown(page), {
timeout: 5_000,
intervals: [50, 100, 250, 500],
}).toMatch(/<img\s[^>]*width="(\d+)"/i);

const md = await getMarkdown(page);
const match = md.match(/<img\s[^>]*width="(\d+)"/i);
expect(match).not.toBeNull();
const recordedWidth = Number.parseInt(match![1]);
// Two independent guarantees, both must hold:
// (1) the bar clamps to a 50-px floor, so the recorded width
// can never drop below that regardless of the drag.
// (2) we dragged ~80 px outward from the right handle, so the
// recorded width should *exceed* the pre-drag natural
// width (startWidth) — this is the real "drag worked"
// assertion. A regression that pins width to start would
// fail here but pass (1).
expect(recordedWidth).toBeGreaterThanOrEqual(50);
expect(recordedWidth).toBeGreaterThan(startWidth);
});
});
Loading
Loading