diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 4d1758094a..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 { TextSelection } from "prosemirror-state"; +import { Plugin, PluginKey, TextSelection } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; import { getBottomNestedBlockInfo, @@ -22,15 +23,98 @@ 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"; +/** + * 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"; }>({ 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: { + // 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) { + return false; + } + if ( + event.inputType !== "insertParagraph" && + event.inputType !== "insertLineBreak" + ) { + return false; + } + event.preventDefault(); + dispatchSynthesizedEnter( + view, + 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/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 new file mode 100644 index 0000000000..fff0542835 --- /dev/null +++ b/tests/src/end-to-end/mobile/androidEnter.test.tsx @@ -0,0 +1,206 @@ +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 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). 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("keyboard-delivered Enter (keydown + keypress) 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"); + } + }); + }); + + // 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); + }, + ); +}); 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 8d81f0698d..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,18 +189,30 @@ export default defineConfig( 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"], + // 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 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 = {}; });