From 914f926c3c3c90f73b0ac0519e660d9c757fc7b3 Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 1 Sep 2026 07:57:32 +0200 Subject: [PATCH 1/2] test: run the mobile e2e suites on an emulated-android browser instance A fourth vitest browser instance emulates a Pixel (touch, viewport, UA, DPR) and runs the mobile-specific suites stub-free. ensureTouchEmulation asserts the context's touch emulation instead of silently patching it; imeComposition drives faithful IME composition through CDP. --- tests/src/utils/ensureTouchEmulation.ts | 28 ++++++++++ tests/src/utils/imeComposition.ts | 69 +++++++++++++++++++++++++ tests/vite.config.browser.ts | 45 +++++++++++++++- 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 tests/src/utils/ensureTouchEmulation.ts create mode 100644 tests/src/utils/imeComposition.ts diff --git a/tests/src/utils/ensureTouchEmulation.ts b/tests/src/utils/ensureTouchEmulation.ts new file mode 100644 index 0000000000..eeeddaf920 --- /dev/null +++ b/tests/src/utils/ensureTouchEmulation.ts @@ -0,0 +1,28 @@ +/** + * Asserts that the android instance's touch emulation is still in effect. + * + * The emulation itself is configured per instance in vite.config.browser.ts + * (the playwright provider's contextOptions) — this cannot re-create it, only + * detect its loss. Loss has one known cause: Playwright's element-screenshot + * path for **iframe elements** (what `screenshotFull` captures for export + * previews) rewrites the device-metrics override and permanently drops the + * context's touch emulation — `navigator.maxTouchPoints` becomes 0 for every + * later test file. The android instance therefore keeps such suites out of + * its include; touch-dependent tests call this in `beforeEach` so that if the + * include ever regresses, the run fails naming the cause instead of silently + * testing a desktop context that merely claims to be mobile. + */ +export function ensureTouchEmulation() { + if ( + navigator.maxTouchPoints === 0 || + !window.matchMedia("(pointer: coarse)").matches + ) { + throw new Error( + "Touch emulation has been dropped for this browser context. A " + + "previously run test file took an iframe-element screenshot " + + "(screenshotFull), which permanently disables the context's touch " + + "emulation — keep such suites out of the android instance's include " + + "in vite.config.browser.ts.", + ); + } +} diff --git a/tests/src/utils/imeComposition.ts b/tests/src/utils/imeComposition.ts new file mode 100644 index 0000000000..6579a10239 --- /dev/null +++ b/tests/src/utils/imeComposition.ts @@ -0,0 +1,69 @@ +import type { BrowserCommand } from "vite-plus/test/node"; + +/** + * One step of an emulated IME session. `setComposition` updates the active + * composition (starting one if none is active); `commit` finalizes it with + * the given text — pass different text than the last composition update to + * emulate an autocorrect-style replacement. + */ +export type ImeStep = + | { + type: "setComposition"; + text: string; + selectionStart?: number; + selectionEnd?: number; + /** + * With `replacementEnd`, the composition replaces this range of + * already-committed text instead of inserting at the caret — the shape + * of retroactive autocorrect (e.g. Gboard fixing the previous word when + * space is typed). Offsets are in the focused editable's text. + */ + replacementStart?: number; + replacementEnd?: number; + } + | { type: "commit"; text: string }; + +/** + * Browser-side signature of the {@link imeComposition} command (Vitest strips + * the Node-only context parameter — see positionalMouse.ts for the pattern). + */ +export type ImeCompositionCommand = (steps: ImeStep[]) => Promise; + +/** + * Drives Chromium's real IME composition pipeline over CDP + * (`Input.imeSetComposition` / `Input.insertText`): the browser produces the + * genuine `compositionstart/update/end` + `beforeinput: + * insertCompositionText` sequence with actual DOM mutation, targeting the + * focused element — the same events a mobile IME (Gboard, Samsung Keyboard) + * generates, which no synthetic `CompositionEvent` dispatch can reproduce + * (those are untrusted and never touch the DOM). Chromium-only. + */ +export const imeComposition: BrowserCommand<[steps: ImeStep[]]> = async ( + ctx, + steps, +) => { + const cdp = await ctx.context.newCDPSession(ctx.page); + try { + for (const step of steps) { + if (step.type === "setComposition") { + await cdp.send("Input.imeSetComposition", { + text: step.text, + selectionStart: step.selectionStart ?? step.text.length, + selectionEnd: step.selectionEnd ?? step.text.length, + ...(step.replacementEnd !== undefined + ? { + replacementStart: step.replacementStart ?? 0, + replacementEnd: step.replacementEnd, + } + : {}), + }); + } else { + await cdp.send("Input.insertText", { text: step.text }); + } + } + } finally { + await cdp.detach().catch(() => { + // Session already gone (e.g. page navigated) — nothing to clean up. + }); + } +}; diff --git a/tests/vite.config.browser.ts b/tests/vite.config.browser.ts index 21fb2a1e1b..8d81f0698d 100644 --- a/tests/vite.config.browser.ts +++ b/tests/vite.config.browser.ts @@ -4,6 +4,7 @@ import * as path from "path"; import { defineConfig, type UserConfig } from "vite-plus"; import { playwright } from "vite-plus/test/browser/providers/playwright"; import { positionalMouse } from "./src/utils/positionalMouse.js"; +import { imeComposition } from "./src/utils/imeComposition.js"; // 1280x720 matches the old Playwright defaults so visual baselines have room. // Used as the playwright context viewport for every browser instance. @@ -88,6 +89,7 @@ export default defineConfig( "./src/end-to-end/**/*.test.tsx", "../packages/*/src/**/*.browser.test.{ts,tsx}", ], + setupFiles: ["./vitestSetup.browser.ts"], // Running three browsers concurrently inside one Docker container already // saturates CPU; layering per-browser file parallelism on top causes @@ -139,7 +141,7 @@ export default defineConfig( // still show in the HTML report (errors + stack traces don't depend // on these shots), so disable them. See `e2e:report` to view. screenshotFailures: false, - commands: { positionalMouse }, + commands: { positionalMouse, imeComposition }, instances: [ { browser: "chromium", @@ -151,12 +153,53 @@ export default defineConfig( "--disable-dev-shm-usage", ], }, + // end-to-end/mobile runs only in the "android" instance below. + exclude: ["**/end-to-end/mobile/**"], }, { browser: "firefox", + exclude: ["**/end-to-end/mobile/**"], }, { browser: "webkit", + exclude: ["**/end-to-end/mobile/**"], + }, + { + // Android-emulated chromium: mobile-specific end-to-end tests. + // The context makes `isTouchDevice()` genuinely true and puts + // prosemirror-view on its Android code paths (it samples the + // user agent at module load), so the mobile tests need no + // platform stubs. See tests/src/end-to-end/mobile/. + browser: "chromium", + name: "android", + launchOptions: { + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + ], + }, + provider: playwright({ + contextOptions: { + viewport: { width: 393, height: 727 }, + userAgent: + "Mozilla/5.0 (Linux; Android 12; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Mobile Safari/537.36", + isMobile: true, + hasTouch: true, + }, + }), + // Only the mobile-specific tests for now. The behavioural + // suites where Android genuinely differs (IME key handling, + // suggestion menus) are added alongside the fix that makes them + // pass under this emulation — running them here first would + // just be reporting a known editor bug as a test failure. + // + // Keep iframe-screenshotting suites (the exporters' + // `screenshotFull` previews) out permanently: Playwright's + // element-screenshot path for iframe elements drops the + // context's touch emulation for later files (see + // utils/ensureTouchEmulation.ts). + include: ["./src/end-to-end/mobile/**/*.test.tsx"], }, ], }, From f55aa02055144efe64a61ed80c4c1026edbde89a Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 1 Sep 2026 09:26:51 +0200 Subject: [PATCH 2/2] docs: codify the test-layer ladder in the testing skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six layers ordered by cost; placement decided by one rule — red-first at the highest rung where the test can fail. Duplicate coverage below is out, except a thin pin that an emulation matches reality. --- .claude/skills/testing-skill/SKILL.md | 33 +++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/.claude/skills/testing-skill/SKILL.md b/.claude/skills/testing-skill/SKILL.md index 982d9ea03b..dffb4379fc 100644 --- a/.claude/skills/testing-skill/SKILL.md +++ b/.claude/skills/testing-skill/SKILL.md @@ -7,6 +7,33 @@ description: Instructions for writing, running, and updating unit/end-to-end tes In most cases, once a feature, bug fix, or other modification has been written, it will need to have tests added, or existing tests updated. +## The test-layer ladder + +Six layers, ordered by cost (speed, determinism, machinery). Prefer the highest workable rung: + +1. **Unit tests** — node, colocated in `packages/*` (`vp run test`) +2. **Browser unit tests** — colocated `*.browser.test.{ts,tsx}`: one unit that genuinely needs real DOM/rendering +3. **Cross-package integration tests** — node, `tests/src/unit`: full documents through real pipelines (parsing, format conversion, export) +4. **Browser e2e** — `tests/src/end-to-end`: UI flows in real browsers (Docker) +5. **Mobile-emulated e2e** — `end-to-end/mobile/**` and the suites on the `android` instance: only for behavior that differs under mobile conditions (touch, viewport, UA, emulated IME). One restriction: suites here must not take iframe-element screenshots (`screenshotFull`) — that path permanently drops the context's touch emulation for every later test file (see `utils/ensureTouchEmulation.ts`), so such suites stay out of the android instance's include +6. **Device suite** — _parked_: a real emulator/simulator suite (one session interface, Android via Playwright `_android` + adb, iOS via Appium/XCUITest) lives on the `mobile/emulator-layer` branch (PR #3034). It left the active stack because every fix it guarded is red-first provable on the emulated instance; revive it only for a bug class that emulation demonstrably cannot observe (the IME's own action-key choice, real-keyboard viewport resize, real iOS Safari focus/zoom) — until then those are the manual checklist below. + +**One rule decides placement: red-first.** Every test must fail without the change it guards — a test that passes either way proves nothing, and for regression fixes this means actually running it against the pre-fix code. Red-first also _places_ the test: write it at the highest rung where it goes red. If the failure isn't observable there (the rung's environment fakes away the very thing that breaks), move down one rung and try again. Once it goes red, stop — rungs below add cost, not proof. + +Corollaries: + +- **No duplicate coverage below.** Behavior proven red at rung N is not re-tested at N+1. One narrow exception: when a rung works by faking something (CDP-emulated IME, touch emulation), a single thin test one rung down may pin that the fake matches reality — it guards the _fake_, not the feature. +- **Bulk lives high, lower rungs stay thin.** Layers 1–4 hold the breadth; layer 5 holds only mobile-conditional behavior. +- **When a rung can't observe the OS's half of a bug, say so in the test.** Example: `mobile/linkSubmit.test.tsx` proves submission works with no key event, and its header documents the un-emulatable half (which action the IME chooses) — that half lives on the manual checklist, visible where the coverage stops. +- Rungs 2 and 3 order by colocation, not machinery cost — a browser unit test needs heavier machinery than a node integration test, but it lives next to the unit it covers, and colocation wins. They are rarely substitutes anyway: if the code needs no browser, use 1 or 3; if one unit needs a browser, use 2. + +## Mobile release checklist (manual) + +The device-only behaviors above, checked on real hardware before a release: + +- **Android phone** (ideally with an OEM keyboard, e.g. Samsung Keyboard): create a link from an editor that is _not_ the last on the page — the keyboard's action key must submit the popover (not jump focus to the next editor), with the keyboard and toolbar staying up. Then type in the editor and press the on-screen Enter: a new block, no stray space or table corruption. +- **iOS Safari**: open the link popover — focusing the URL input must not zoom the page; submit with the return key. + ## Test File Locations ### Unit Tests @@ -27,11 +54,7 @@ In most cases, once a feature, bug fix, or other modification has been written, ## When & How to Add Tests -In general, we expect a change in code to result in failing test cases. If this does not happen, tests should be added and checked to ensure they pass with the code changes while failing without them. - -However, this may not be true when adding edge case handling or a new feature, where existing tests may all continue to pass. In this case, tests should be added as necessary to cover all of the new functionality. We should still ensure that the new tests pass with the new code changes while failing without them. - -We want to avoid adding end-to-end tests where it's possible to use unit tests instead. +Placement and proof obligations are governed by the ladder above: pick the rung red-first, and verify every new test fails without the code change it covers (for edge cases and new features too — existing tests continuing to pass is exactly the situation that demands new red-first ones). **Don't use jsdom** (`@vitest-environment jsdom`) in new tests. It's a murky middle ground — `document` exists but rendering doesn't — which makes browser-capability checks pass while the capability itself is broken. Use the default node environment with pluggable seams for logic, and the browser suite (`tests/src/end-to-end`, vitest browser mode in Docker) for anything that needs real rendering.