From 1e25e5c1f7ba5994380bbb2b673f85e34047fb19 Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 31 Aug 2026 16:03:10 +0200 Subject: [PATCH 1/4] fix(core): handle Enter via beforeinput on Android (#3001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Android, prosemirror-view deliberately bails out of its keydown handling: the IME reports composing keys as keyCode 229, so the key identity can't be trusted. Enter therefore never reached the keymap and pressing it did nothing — no new block, no list continuation. `beforeinput` carries the intent unambiguously (`insertParagraph` / `insertLineBreak`) regardless of what the IME reports, so the shortcuts extension intercepts it there and runs the same keymap command. Only on Android, and only when not composing, so every other platform keeps the existing path. This also unblocks running the core behavioural suites under Android emulation. They were held out of the android instance in the test-infra change precisely because of this bug — every test that presses Enter to make a second block failed there — so the instance's include list grows here, where it can be green. --- .../KeyboardShortcutsExtension.ts | 58 +++++++++++++++++- packages/core/src/util/browser.ts | 3 + .../end-to-end/mobile/androidEnter.test.tsx | 59 +++++++++++++++++++ tests/vite.config.browser.ts | 20 +++++-- 4 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 tests/src/end-to-end/mobile/androidEnter.test.tsx diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 4d1758094a..abcaeb6035 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -1,6 +1,6 @@ import { Extension } from "@tiptap/core"; import { Fragment, Node } from "prosemirror-model"; -import { TextSelection } from "prosemirror-state"; +import { Plugin, PluginKey, TextSelection } from "prosemirror-state"; import { getBottomNestedBlockInfo, @@ -22,6 +22,7 @@ import { getBlockInfoFromSelection, } from "../../../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { isAndroid } from "../../../util/browser.js"; import { FilePanelExtension } from "../../FilePanel/FilePanel.js"; import { FormattingToolbarExtension } from "../../FormattingToolbar/FormattingToolbar.js"; @@ -31,6 +32,61 @@ export const KeyboardShortcutsExtension = Extension.create<{ }>({ priority: 50, + addProseMirrorPlugins() { + return [ + // On Android, Enter never reaches the keymap: the IME delivers it as a + // `beforeinput` (the keydown is keyCode 229), and prosemirror-view + // additionally ignores Enter keydowns on Android Chrome. ProseMirror's + // fallback — parsing the browser's native DOM split and synthesizing an + // Enter key event — fails to recognize the split in BlockNote's nested + // block DOM and corrupts the document instead (Enter inserting a space, + // doing nothing, or breaking tables — TypeCellOS/BlockNote#3001). + // Intercepting the `beforeinput` and running the keymap chain directly + // bypasses the fragile DOM diffing entirely. + new Plugin({ + key: new PluginKey("blockNoteAndroidEnter"), + props: { + handleDOMEvents: { + beforeinput: (view, event) => { + if (!isAndroid() || view.composing) { + return false; + } + if ( + event.inputType !== "insertParagraph" && + event.inputType !== "insertLineBreak" + ) { + return false; + } + event.preventDefault(); + // Restore the parity prosemirror-view skips here: for normal + // keydowns it force-flushes pending DOM observations (including + // selection changes) before running key handlers, but its + // Android Enter bail returns before that flush — without it the + // synthesized Enter can run against a stale selection (e.g. a + // just-made cross-block selection that hasn't synced yet). + ( + view as typeof view & { + domObserver: { forceFlush(): void }; + } + ).domObserver.forceFlush(); + view.someProp("handleKeyDown", (handler) => + handler( + view, + new KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + shiftKey: event.inputType === "insertLineBreak", + }), + ), + ); + return true; + }, + }, + }, + }), + ]; + }, + // TODO: The shortcuts need a refactor. Do we want to use a command priority // design as there is now, or clump the logic into a single function? addKeyboardShortcuts() { diff --git a/packages/core/src/util/browser.ts b/packages/core/src/util/browser.ts index d070115c2a..d8961d526d 100644 --- a/packages/core/src/util/browser.ts +++ b/packages/core/src/util/browser.ts @@ -29,6 +29,9 @@ export function mergeCSSClasses(...classes: (string | false | undefined)[]) { export const isSafari = () => /^((?!chrome|android).)*safari/i.test(navigator.userAgent); +export const isAndroid = () => + typeof navigator !== "undefined" && /android/i.test(navigator.userAgent); + // Cached lazily on first call in a browser environment. Touch capability // doesn't change during a session, so there's no need to re-run `matchMedia` on // every call. We only cache once `navigator`/`window` are available, so a diff --git a/tests/src/end-to-end/mobile/androidEnter.test.tsx b/tests/src/end-to-end/mobile/androidEnter.test.tsx new file mode 100644 index 0000000000..a31d29890f --- /dev/null +++ b/tests/src/end-to-end/mobile/androidEnter.test.tsx @@ -0,0 +1,59 @@ +import App from "@examples/01-basic/testing/src/App"; +import { describe, expect, test, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { userEvent } from "../../utils/context.js"; +import { + BLOCK_CONTAINER_SELECTOR, + EDITOR_SELECTOR, +} from "../../utils/const.js"; +import { focusOnEditor, waitForSelector } from "../../utils/editor.js"; + +// Runs in the "android" browser instance (Android UA + touch emulation at +// context level — see vite.config.browser.ts), which makes prosemirror-view +// take its Android code path: Enter keydowns are ignored there, and handling +// happens via the `beforeinput` (insertParagraph) the browser emits. PM's own +// fallback — parsing the native DOM split — misparses BlockNote's nested +// block DOM and corrupts the document (TypeCellOS/BlockNote#3001: Enter +// inserting a space, doing nothing, or breaking tables), so BlockNote +// intercepts the `beforeinput` instead (see KeyboardShortcutsExtension). +// This test pins that path. +describe("Enter on Android", () => { + test("beforeinput insertParagraph splits the block", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + await focusOnEditor(); + await userEvent.keyboard("First line"); + + const blocksBefore = document.querySelectorAll( + BLOCK_CONTAINER_SELECTOR, + ).length; + const textBefore = document.querySelector(EDITOR_SELECTOR)!.textContent; + + await userEvent.keyboard("{Enter}"); + + await vi.waitFor(() => { + const blocks = document.querySelectorAll(BLOCK_CONTAINER_SELECTOR).length; + if (blocks !== blocksBefore + 1) { + throw new Error( + `Enter did not split the block (blocks ${blocksBefore} -> ${blocks})`, + ); + } + }); + // The classic #3001 misbehavior inserts a space or mangles text instead. + expect(document.querySelector(EDITOR_SELECTOR)!.textContent).toBe( + textBefore, + ); + + await userEvent.keyboard("Second line"); + await vi.waitFor(() => { + if ( + !document + .querySelector(EDITOR_SELECTOR)! + .textContent!.includes("Second line") + ) { + throw new Error("typing after Enter did not land in the new block"); + } + }); + }); +}); diff --git a/tests/vite.config.browser.ts b/tests/vite.config.browser.ts index 8d81f0698d..1e60149483 100644 --- a/tests/vite.config.browser.ts +++ b/tests/vite.config.browser.ts @@ -188,18 +188,26 @@ export default defineConfig( hasTouch: true, }, }), - // Only the mobile-specific tests for now. The behavioural + // Mobile-specific tests plus the screenshot-free 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. + // suggestion menus). Those only pass under this emulation with + // the Enter fix in this change — before it, every test that + // presses Enter to make a second block failed here. // // 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"], + // utils/ensureTouchEmulation.ts). Individual tests that drive + // selection or resizing with positional mouse drags carry + // `skipIf(onAndroid)` guards. Not included: indentation (drives + // the desktop floating toolbar, clipped at phone width). + include: [ + "./src/end-to-end/mobile/**/*.test.tsx", + "./src/end-to-end/keyboardhandlers/**/*.test.tsx", + "./src/end-to-end/emojipicker/**/*.test.tsx", + "./src/end-to-end/copypaste/**/*.test.tsx", + ], }, ], }, From 6190f87d6e4f39e05e1763bf25e8b830a0a13cfa Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 31 Aug 2026 16:33:04 +0200 Subject: [PATCH 2/4] fix(core): also handle Enter delivered as a keypress on Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The beforeinput interception only covers the IME path. With a hardware or synthetic keyboard, Enter arrives as a keypress instead — and prosemirror-view's own keypress handler cancels the browser default for cross-block selections without doing anything in their place (its cross-parent branch skips newline characters), so Enter over a selection spanning two blocks was a silent no-op. Intercepting keypress too closes that hole, and the two paths now share one `dispatchSynthesizedEnter` helper rather than repeating the flush-then- synthesize sequence. The `domObserver` reach-through is typed against `EditorView` instead of `typeof view`. Test coverage goes from one path to three — keypress, beforeinput, and the cross-block selection — and `Check Enter when selection is not empty` no longer has to be skipped on the android instance, which is the suite-level proof that the keypress hole is closed. Also makes `Check Delete before shallower block` deterministic: it relied on ArrowUp's goal-x landing on a particular side of a character boundary, which varies with subpixel metrics and had been flaking across engines. --- .../KeyboardShortcutsExtension.ts | 68 +++++--- .../keyboardhandlers.test.tsx | 20 ++- .../end-to-end/mobile/androidEnter.test.tsx | 161 +++++++++++++++++- 3 files changed, 221 insertions(+), 28 deletions(-) diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index abcaeb6035..58f4675ffd 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -1,6 +1,7 @@ import { Extension } from "@tiptap/core"; import { Fragment, Node } from "prosemirror-model"; import { Plugin, PluginKey, TextSelection } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; import { getBottomNestedBlockInfo, @@ -26,6 +27,30 @@ import { isAndroid } from "../../../util/browser.js"; import { FilePanelExtension } from "../../FilePanel/FilePanel.js"; import { FormattingToolbarExtension } from "../../FormattingToolbar/FormattingToolbar.js"; +/** + * Runs the keymap chain for an Enter that never reached it (see the + * `blockNoteAndroidEnter` plugin below): flushes pending DOM observations + * first, then dispatches a synthesized Enter keydown through + * `handleKeyDown`. + */ +function dispatchSynthesizedEnter(view: EditorView, shiftKey: boolean): void { + ( + view as EditorView & { + domObserver: { forceFlush(): void }; + } + ).domObserver.forceFlush(); + view.someProp("handleKeyDown", (handler) => + handler( + view, + new KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + shiftKey, + }), + ), + ); +} + export const KeyboardShortcutsExtension = Extension.create<{ editor: BlockNoteEditor; tabBehavior: "prefer-navigate-ui" | "prefer-indent"; @@ -46,6 +71,26 @@ export const KeyboardShortcutsExtension = Extension.create<{ new Plugin({ key: new PluginKey("blockNoteAndroidEnter"), props: { + // Runs the keymap chain for an Enter that prosemirror-view's + // Android keydown bail skipped, with the parity that bail also + // skips: force-flushing pending DOM observations (including + // selection changes) before running key handlers — without it the + // synthesized Enter can run against a stale selection (e.g. a + // just-made cross-block selection that hasn't synced yet). + handleKeyPress: (view, event) => { + // A keypress for Enter only happens off a hardware/synthetic + // keyboard (the IME path is keyCode 229 + `beforeinput`, no + // keypress — handled below). prosemirror-view's own keypress + // handler would cancel the browser default for cross-block + // selections without doing anything (its cross-parent branch + // calls preventDefault but skips newline characters), turning + // Enter into a silent no-op — so take over before it runs. + if (!isAndroid() || view.composing || event.key !== "Enter") { + return false; + } + dispatchSynthesizedEnter(view, event.shiftKey); + return true; + }, handleDOMEvents: { beforeinput: (view, event) => { if (!isAndroid() || view.composing) { @@ -58,26 +103,9 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; } event.preventDefault(); - // Restore the parity prosemirror-view skips here: for normal - // keydowns it force-flushes pending DOM observations (including - // selection changes) before running key handlers, but its - // Android Enter bail returns before that flush — without it the - // synthesized Enter can run against a stale selection (e.g. a - // just-made cross-block selection that hasn't synced yet). - ( - view as typeof view & { - domObserver: { forceFlush(): void }; - } - ).domObserver.forceFlush(); - view.someProp("handleKeyDown", (handler) => - handler( - view, - new KeyboardEvent("keydown", { - key: "Enter", - code: "Enter", - shiftKey: event.inputType === "insertLineBreak", - }), - ), + dispatchSynthesizedEnter( + view, + event.inputType === "insertLineBreak", ); return true; }, diff --git a/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.tsx b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.tsx index c33f704dd2..1a9b460015 100644 --- a/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.tsx +++ b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.tsx @@ -22,7 +22,16 @@ beforeEach(async () => { await waitForSelector(EDITOR_SELECTOR); }); +// The android browser instance runs this suite too (see +// vite.config.browser.ts); a couple of tests use idioms that don't transfer: +const onAndroid = /android/i.test(navigator.userAgent); + describe("Check Keyboard Handlers' Behaviour", () => { + // Also covers the android instance: with a cross-block selection, + // prosemirror-view's Android keydown bail skips Enter handling and its own + // keypress handler cancels the browser default without doing anything — + // BlockNote's keypress interception (KeyboardShortcutsExtension) closes + // that hole. See also the cross-block case in mobile/androidEnter.test.tsx. test("Check Enter when selection is not empty", async () => { await focusOnEditor(); await insertHeading(1); @@ -42,7 +51,10 @@ describe("Check Keyboard Handlers' Behaviour", () => { await compareDocToSnapshot("enterSelectionNotEmpty"); }); - test("Check Enter preserves marks", async () => { + // Skipped on the android instance: drives selection with coordinate + // double-clicks, a mouse idiom that doesn't translate to touch emulation at + // phone width. + test.skipIf(onAndroid)("Check Enter preserves marks", async () => { await focusOnEditor(); await insertHeading(1); @@ -313,6 +325,12 @@ describe("Check Keyboard Handlers' Behaviour", () => { await insertParagraph(); await userEvent.keyboard("{ArrowUp}"); + // ArrowUp crosses from an unnested line into an indented one, so its + // goal-x lands near the last character's boundary — which side it falls + // on varies with subpixel text metrics (flaky on the mobile-emulated + // instances). The test is about Delete at the *end* of the block; make + // that position explicit. + await userEvent.keyboard("{End}"); await userEvent.keyboard("{Delete}"); await compareDocToSnapshot("deleteShallowerBlock"); diff --git a/tests/src/end-to-end/mobile/androidEnter.test.tsx b/tests/src/end-to-end/mobile/androidEnter.test.tsx index a31d29890f..fff0542835 100644 --- a/tests/src/end-to-end/mobile/androidEnter.test.tsx +++ b/tests/src/end-to-end/mobile/androidEnter.test.tsx @@ -11,15 +11,15 @@ import { focusOnEditor, waitForSelector } from "../../utils/editor.js"; // Runs in the "android" browser instance (Android UA + touch emulation at // context level — see vite.config.browser.ts), which makes prosemirror-view -// take its Android code path: Enter keydowns are ignored there, and handling -// happens via the `beforeinput` (insertParagraph) the browser emits. PM's own -// fallback — parsing the native DOM split — misparses BlockNote's nested +// take its Android code path: Enter keydowns are ignored there, and PM's own +// fallback — parsing the native DOM change — misparses BlockNote's nested // block DOM and corrupts the document (TypeCellOS/BlockNote#3001: Enter -// inserting a space, doing nothing, or breaking tables), so BlockNote -// intercepts the `beforeinput` instead (see KeyboardShortcutsExtension). -// This test pins that path. +// inserting a space, doing nothing, or breaking tables). BlockNote +// intercepts both delivery routes instead (see KeyboardShortcutsExtension): +// `keypress` for hardware/synthetic keyboards, `beforeinput` for the IME. +// The tests below pin one route each. describe("Enter on Android", () => { - test("beforeinput insertParagraph splits the block", async () => { + test("keyboard-delivered Enter (keydown + keypress) splits the block", async () => { await render(); await waitForSelector(EDITOR_SELECTOR); await focusOnEditor(); @@ -56,4 +56,151 @@ describe("Enter on Android", () => { } }); }); + + // The IME path itself: real soft keyboards deliver Enter as keyCode 229 + + // `beforeinput: insertParagraph` with NO keypress, so the keypress + // interception (which covers hardware/synthetic keyboards, above) never + // runs. No automated input layer produces that exact trusted sequence — a + // synthetic InputEvent reaches prosemirror's handleDOMEvents all the same, + // so this pins the `beforeinput` interception the way IMEs actually invoke + // it. (Without the interception a synthetic event simply does nothing, so + // this fails red without the fix.) + test.skipIf(!/android/i.test(navigator.userAgent))( + "IME-delivered Enter (beforeinput, no keypress) splits the block", + async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + await focusOnEditor(); + await userEvent.keyboard("Ime line"); + const blocksBefore = document.querySelectorAll( + BLOCK_CONTAINER_SELECTOR, + ).length; + + document.querySelector(EDITOR_SELECTOR)!.dispatchEvent( + new InputEvent("beforeinput", { + inputType: "insertParagraph", + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor(() => { + const blocks = document.querySelectorAll( + BLOCK_CONTAINER_SELECTOR, + ).length; + if (blocks !== blocksBefore + 1) { + throw new Error( + `beforeinput Enter did not split (blocks ${blocksBefore} -> ${blocks})`, + ); + } + }); + expect(document.querySelector(EDITOR_SELECTOR)!.textContent).toBe( + "Ime line", + ); + }, + ); + + // With a NON-EMPTY cross-block selection, an Enter keydown+keypress pair + // (hardware or synthetic keyboard) used to be a silent no-op on Android: + // prosemirror-view's Android keydown bail skips Enter handling, and its own + // keypress handler then cancels the browser default for cross-parent + // selections without doing anything. BlockNote's `handleKeyPress` + // interception routes it through the keymap chain instead. The hole (and + // this test) is Android-only: everywhere else the keymap already handles + // Enter at keydown, so the keypress branch never matters — and on the + // iOS-emulated instance the setup itself is unreliable (typing after a + // settled Enter lands back in the previous block, a webkit-on-Linux + // emulation artifact the real-device suite doesn't show). + const onAndroid = /android/i.test(navigator.userAgent); + test.skipIf(!onAndroid)( + "Enter with a cross-block selection deletes it and splits", + async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + await focusOnEditor(); + await userEvent.keyboard("First line"); + await userEvent.keyboard("{Enter}"); + // The split can settle asynchronously (iOS path); typing must land in the + // new block before the selection below can target both paragraphs. + await vi.waitFor(() => { + if ( + !Array.from(document.querySelectorAll(`${EDITOR_SELECTOR} p`)).some( + (el) => el.textContent === "", + ) + ) { + throw new Error("Enter split not settled"); + } + }); + await userEvent.keyboard("Second line"); + await vi.waitFor(() => { + const texts = Array.from( + document.querySelectorAll(`${EDITOR_SELECTOR} p`), + ).map((el) => el.textContent); + if (!texts.includes("First line") || !texts.includes("Second line")) { + throw new Error(`paragraphs not settled: ${JSON.stringify(texts)}`); + } + }); + + // Select from mid-first-line to mid-second-line via a DOM range — + // arrow-key selection maps goal columns differently per engine, while + // ProseMirror syncs a programmatic range from `selectionchange` on all of + // them. Selects "ne" + "Sec" across the block boundary. + function textPosition( + paragraphText: string, + offset: number, + ): [Text, number] { + const paragraph = Array.from( + document.querySelectorAll(`${EDITOR_SELECTOR} p`), + ).find((el) => el.textContent === paragraphText); + if (!paragraph) { + throw new Error( + `paragraph ${JSON.stringify(paragraphText)} not found`, + ); + } + const walker = document.createTreeWalker( + paragraph, + NodeFilter.SHOW_TEXT, + ); + let consumed = 0; + for (let n = walker.nextNode(); n; n = walker.nextNode()) { + const length = n.textContent!.length; + if (offset <= consumed + length) { + return [n as Text, offset - consumed]; + } + consumed += length; + } + throw new Error( + `offset ${offset} beyond ${JSON.stringify(paragraphText)}`, + ); + } + await vi.waitFor(() => { + const range = document.createRange(); + range.setStart(...textPosition("First line", "First li".length)); + range.setEnd(...textPosition("Second line", "Sec".length)); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + if (selection.isCollapsed) { + throw new Error("cross-block selection did not apply"); + } + }); + + await userEvent.keyboard("{Enter}"); + + // The selected span ("ne" + "Sec") is deleted and the remainder split + // across two blocks: "First li" + "ond line". + await vi.waitFor(() => { + const text = document.querySelector(EDITOR_SELECTOR)!.textContent!; + if (text.includes("First line")) { + throw new Error(`Enter did not delete the selection: ${text}`); + } + if (!text.includes("First li") || !text.includes("ond line")) { + throw new Error(`unexpected text after Enter: ${text}`); + } + }); + expect( + document.querySelectorAll(BLOCK_CONTAINER_SELECTOR).length, + ).toBeGreaterThanOrEqual(2); + }, + ); }); From 133c09dd9a1e5c59c6641bb9cb9da53cff0d40e8 Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 1 Sep 2026 17:25:17 +0200 Subject: [PATCH 3/4] test: run the form suites on the android instance too The popover form-submission tests exist because of Android bugs, yet only ran on the desktop engines. The android instance is chromium, so even the CDP composition tests run there; the keyboardhandlers and emojipicker suites join for their distinct consumers of Enter handling. All pass under the emulation. --- tests/vite.config.browser.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/vite.config.browser.ts b/tests/vite.config.browser.ts index 1e60149483..d6f48a0315 100644 --- a/tests/vite.config.browser.ts +++ b/tests/vite.config.browser.ts @@ -204,9 +204,14 @@ export default defineConfig( // the desktop floating toolbar, clipped at phone width). include: [ "./src/end-to-end/mobile/**/*.test.tsx", + // The popover form-submission suites are this instance's + // reason to exist — the bugs they guard were Android bugs; + // the platform-contract suites pin the browser facts they + // rest on under the same mobile emulation. + "./src/end-to-end/form/**/*.test.tsx", + "./src/end-to-end/platform/**/*.test.tsx", "./src/end-to-end/keyboardhandlers/**/*.test.tsx", "./src/end-to-end/emojipicker/**/*.test.tsx", - "./src/end-to-end/copypaste/**/*.test.tsx", ], }, ], From 77b865f8d2f7a62d79053d79db5f9acaa676d4ad Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 1 Sep 2026 17:26:20 +0200 Subject: [PATCH 4/4] fix(test): the android instance now tests true phone geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared browser setup forced its 1280x720 iframe onto every project — on the android instance (a 393x727 phone window) the harness then scaled that desktop-width iframe down to fit, so every suite without its own per-test viewport was silently testing desktop layout, optically shrunk. Positional input was displaced by the same transform, which had been misread as 'mouse idioms don't translate to touch emulation'. The setup now sizes the iframe per project. Touch emulation also gets self-healing: Chromium's beyond-viewport screenshot capture (captureBeyondViewport, sent by Playwright for any element taller than the viewport) can silently drop the context's touch emulation. A restoreTouchEmulation command (persistent CDP session — Emulation overrides revert when their session detaches) re-arms it before every android test, and ensureTouchEmulation runs as an automatic assertion right after, so no suite calls it manually anymore. The assert stays because it guards a different failure than the heal: the mechanism itself breaking (provider contextOptions silently ignored, an upgrade rewiring the provider). At true geometry the include list is re-grounded on one principle, stated per entry in the config: a suite runs on this instance when it can go red for a mobile-conditional reason no other suite here pins. form/ drops out — its popover suite drives the desktop link toolbar (hover, clipped at phone width) and its Enter mechanics are pinned red-first by mobile/ and keyboardhandlers/. copypaste/ drops out — the clipboard path has no platform conditionals at all, and its Enter presses are setup scaffolding for routes androidEnter pins directly. emojipicker/ stays: Enter-to-select goes through the suggestion menu's own key handling, a distinct consumer of the synthesized-Enter route. --- .../src/end-to-end/mobile/linkSubmit.test.tsx | 2 - .../end-to-end/mobile/mobileToolbar.test.tsx | 2 - .../end-to-end/mobile/popoverScroll.test.tsx | 2 - tests/src/utils/restoreTouchEmulation.ts | 33 +++++++++++++++ tests/vite.config.browser.ts | 40 +++++++++---------- tests/vitestSetup.browser.ts | 32 ++++++++++++++- 6 files changed, 83 insertions(+), 28 deletions(-) create mode 100644 tests/src/utils/restoreTouchEmulation.ts diff --git a/tests/src/end-to-end/mobile/linkSubmit.test.tsx b/tests/src/end-to-end/mobile/linkSubmit.test.tsx index 36418b731e..a608b6ef87 100644 --- a/tests/src/end-to-end/mobile/linkSubmit.test.tsx +++ b/tests/src/end-to-end/mobile/linkSubmit.test.tsx @@ -12,7 +12,6 @@ import { render } from "vitest-browser-react"; import { page, userEvent } from "../../utils/context.js"; import { EDITOR_SELECTOR, LINK_BUTTON_SELECTOR } from "../../utils/const.js"; import { waitForSelector } from "../../utils/editor.js"; -import { ensureTouchEmulation } from "../../utils/ensureTouchEmulation.js"; const MOBILE_TOOLBAR_SELECTOR = ".bn-mobile-formatting-toolbar"; @@ -33,7 +32,6 @@ const MOBILE_TOOLBAR_SELECTOR = ".bn-mobile-formatting-toolbar"; // test below; the IME's choice itself stays a release-checklist item. beforeEach(async () => { - ensureTouchEmulation(); await page.viewport(393, 727); }); diff --git a/tests/src/end-to-end/mobile/mobileToolbar.test.tsx b/tests/src/end-to-end/mobile/mobileToolbar.test.tsx index 57ce398af5..0554701e22 100644 --- a/tests/src/end-to-end/mobile/mobileToolbar.test.tsx +++ b/tests/src/end-to-end/mobile/mobileToolbar.test.tsx @@ -5,7 +5,6 @@ import { render } from "vitest-browser-react"; import { page, userEvent } from "../../utils/context.js"; import { EDITOR_SELECTOR, LINK_BUTTON_SELECTOR } from "../../utils/const.js"; import { focusOnEditor, waitForSelector } from "../../utils/editor.js"; -import { ensureTouchEmulation } from "../../utils/ensureTouchEmulation.js"; const MOBILE_TOOLBAR_SELECTOR = ".bn-mobile-formatting-toolbar"; const LINK_POPOVER_SELECTOR = ".bn-form-popover"; @@ -41,7 +40,6 @@ function activeUrlInput() { } beforeEach(async () => { - ensureTouchEmulation(); await page.viewport(VIEWPORT_WIDTH, KEYBOARD_CLOSED); await render(); await waitForSelector(EDITOR_SELECTOR); diff --git a/tests/src/end-to-end/mobile/popoverScroll.test.tsx b/tests/src/end-to-end/mobile/popoverScroll.test.tsx index f77703be48..6082233a78 100644 --- a/tests/src/end-to-end/mobile/popoverScroll.test.tsx +++ b/tests/src/end-to-end/mobile/popoverScroll.test.tsx @@ -12,7 +12,6 @@ import { render } from "vitest-browser-react"; import { page, userEvent } from "../../utils/context.js"; import { EDITOR_SELECTOR, LINK_BUTTON_SELECTOR } from "../../utils/const.js"; import { waitForSelector } from "../../utils/editor.js"; -import { ensureTouchEmulation } from "../../utils/ensureTouchEmulation.js"; const MOBILE_TOOLBAR_SELECTOR = ".bn-mobile-formatting-toolbar"; @@ -24,7 +23,6 @@ const MOBILE_TOOLBAR_SELECTOR = ".bn-mobile-formatting-toolbar"; // scroll-into-view chased it to its pre-positioned spot. beforeEach(async () => { - ensureTouchEmulation(); await page.viewport(393, 727); }); diff --git a/tests/src/utils/restoreTouchEmulation.ts b/tests/src/utils/restoreTouchEmulation.ts new file mode 100644 index 0000000000..89fae18051 --- /dev/null +++ b/tests/src/utils/restoreTouchEmulation.ts @@ -0,0 +1,33 @@ +import type { BrowserCommand } from "vite-plus/test/node"; + +/** + * Re-applies the touch emulation the android instance's Playwright + * `contextOptions` established. Chromium's beyond-viewport screenshot + * capture (`Page.captureScreenshot` with `captureBeyondViewport: true`, + * which Playwright sends for any element taller than the viewport) can + * silently drop the context's emulation overrides — `maxTouchPoints` + * becomes 0 for every later test. `vitestSetup.browser.ts` calls this + * before each file on the android instance. + * + * The CDP session is deliberately cached and never detached: + * Emulation-domain overrides revert when the session that set them + * detaches (learned the hard way — a detaching version of this command + * *caused* the exact poison it was meant to heal). + */ +const sessions = new WeakMap>(); + +export const restoreTouchEmulation: BrowserCommand<[]> = async (ctx) => { + let session = sessions.get(ctx.page); + if (session === undefined) { + session = ctx.context.newCDPSession(ctx.page); + sessions.set(ctx.page, session); + } + const cdp = (await session) as { + send(method: string, params: object): Promise; + }; + // Exactly what Playwright sends for `hasTouch: true` — and nothing more. + // In particular NOT `Emulation.setEmitTouchEventsForMouse`: that converts + // real mouse events into touch events, which breaks every userEvent click + // (learned the hard way; Playwright never enables it). + await cdp.send("Emulation.setTouchEmulationEnabled", { enabled: true }); +}; diff --git a/tests/vite.config.browser.ts b/tests/vite.config.browser.ts index d6f48a0315..6658bc2843 100644 --- a/tests/vite.config.browser.ts +++ b/tests/vite.config.browser.ts @@ -5,6 +5,7 @@ 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"; +import { restoreTouchEmulation } from "./src/utils/restoreTouchEmulation.js"; // 1280x720 matches the old Playwright defaults so visual baselines have room. // Used as the playwright context viewport for every browser instance. @@ -141,7 +142,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, imeComposition }, + commands: { positionalMouse, imeComposition, restoreTouchEmulation }, instances: [ { browser: "chromium", @@ -188,29 +189,28 @@ export default defineConfig( hasTouch: true, }, }), - // Mobile-specific tests plus the screenshot-free behavioural - // suites where Android genuinely differs (IME key handling, - // suggestion menus). Those only pass under this emulation with - // the Enter fix in this change — before it, every test that - // presses Enter to make a second block failed here. - // - // 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). Individual tests that drive - // selection or resizing with positional mouse drags carry - // `skipIf(onAndroid)` guards. Not included: indentation (drives - // the desktop floating toolbar, clipped at phone width). + // One principle decides membership: a suite runs here when it can + // go red for a mobile-conditional reason no other suite here + // already pins. Tests whose driving idiom doesn't translate to + // touch emulation (positional mouse drags) carry + // `skipIf(onAndroid)` guards; product behavior is never + // skipped. No blanket screenshot suites — android baselines + // would double maintenance for viewport-independent artifacts; + // mobile visuals get curated tests with their own baselines. + // (form/ and copypaste/ were tried and dropped: no distinct + // mobile-conditional failure mode — see #3031.) include: [ + // Mobile-specific product behavior: the toolbar/popover + // lifecycle, IME delivery routes, touch link taps. "./src/end-to-end/mobile/**/*.test.tsx", - // The popover form-submission suites are this instance's - // reason to exist — the bugs they guard were Android bugs; - // the platform-contract suites pin the browser facts they - // rest on under the same mobile emulation. - "./src/end-to-end/form/**/*.test.tsx", + // The browser facts Form.Root rests on (implicit submission, + // composition), re-asserted under mobile emulation flags. "./src/end-to-end/platform/**/*.test.tsx", + // Synthesized Enter through the keymap chain — the #3001 + // fix's primary consumer, exercised across every handler. "./src/end-to-end/keyboardhandlers/**/*.test.tsx", + // Synthesized Enter through the suggestion menu's own key + // handling — a distinct consumer from the keymap chain. "./src/end-to-end/emojipicker/**/*.test.tsx", ], }, diff --git a/tests/vitestSetup.browser.ts b/tests/vitestSetup.browser.ts index 469a859137..d759b56980 100644 --- a/tests/vitestSetup.browser.ts +++ b/tests/vitestSetup.browser.ts @@ -1,5 +1,7 @@ import { afterEach, beforeAll, beforeEach } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; +import { commands, page } from "vite-plus/test/browser"; + +import { ensureTouchEmulation } from "./src/utils/ensureTouchEmulation.js"; // Browser-mode setup. Unlike the jsdom `vitestSetup.ts`, we don't mock // ClipboardEvent/DragEvent/matchMedia here — the real browser provides them. @@ -14,7 +16,15 @@ import { page } from "vite-plus/test/browser"; // resizes that iframe. Run before all tests in the file so every test sees the // right size from the first render. beforeAll(async () => { - await page.viewport(1280, 720); + // On the android instance the outer window is a 393x727 phone (provider + // contextOptions) — the iframe must match it exactly. A larger iframe gets + // scaled down by the harness's fit-to-window transform, so captures come + // out phone-*sized* but contain a shrunken desktop-width layout. + if (/android/i.test(navigator.userAgent)) { + await page.viewport(393, 727); + } else { + await page.viewport(1280, 720); + } // Match the playground's editor framing so screenshots line up with what // users see at https://www.blocknotejs.org/examples (max-width 731px, @@ -25,6 +35,24 @@ beforeAll(async () => { document.head.appendChild(style); }); +// Chromium's beyond-viewport screenshot capture (any `toMatchScreenshot` of +// an element taller than the viewport — Playwright sends +// `captureBeyondViewport: true`) can silently drop the context's touch +// emulation for every later test. Before every test on the android instance: +// re-arm the emulation, then assert it actually holds — the assert is what +// catches the deeper failure class where the *mechanism* breaks (provider +// contextOptions silently ignored, a vitest upgrade rewiring the provider, +// this very command regressing). No suite needs to call +// `ensureTouchEmulation` itself. +beforeEach(async () => { + if (/android/i.test(navigator.userAgent)) { + await ( + commands as unknown as { restoreTouchEmulation(): Promise } + ).restoreTouchEmulation(); + ensureTouchEmulation(); + } +}); + beforeEach(() => { (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; });