From 0c1447db91eca55fea87b7409a0f41af260d8294 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:17:53 +0200 Subject: [PATCH] test(app): replace PressHighlight JSDOM test with an Android mouse-click check The JSDOM test mocked gesture-handler and reanimated and asserted that the gesture failed, not that a click reached onPress. The new check drives a virtual USB mouse through Android's uinput and asserts that a primary click selects a sidebar workspace row. It fails with main's press-highlight and passes with this fix. Co-authored-by: Ilya Volnistov --- .../app/e2e/mobile/mouse-click/android.mjs | 117 +++++++++ .../ui/press-highlight.native.test.tsx | 230 ------------------ 2 files changed, 117 insertions(+), 230 deletions(-) create mode 100644 packages/app/e2e/mobile/mouse-click/android.mjs delete mode 100644 packages/app/src/components/ui/press-highlight.native.test.tsx diff --git a/packages/app/e2e/mobile/mouse-click/android.mjs b/packages/app/e2e/mobile/mouse-click/android.mjs new file mode 100644 index 0000000000..68012ce741 --- /dev/null +++ b/packages/app/e2e/mobile/mouse-click/android.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { setTimeout } from "node:timers/promises"; + +// A primary mouse click on a sidebar workspace row selects it (#3996). +// Run with the app on a wide Android layout, sidebar open, against a real daemon +// with at least two workspaces. The app must already run the checkout being verified. +// The click comes from a virtual USB mouse, so Android sends the same DOWN, +// BUTTON_PRESS, BUTTON_RELEASE, UP sequence as a physical touchpad click. +const serial = process.env.ANDROID_SERIAL ?? "emulator-5554"; +const CLICK_HOLD_MS = 110; + +function adb(...args) { + return execFileSync("adb", ["-s", serial, ...args], { + encoding: "utf8", + timeout: 30_000, + }); +} + +function workspaceRows() { + adb("shell", "uiautomator", "dump", "/sdcard/paseo-mouse-click.xml"); + const xml = adb("shell", "cat", "/sdcard/paseo-mouse-click.xml"); + return [...xml.matchAll(/]*resource-id="sidebar-workspace-row-[^"]*"[^>]*>/g)].map( + ([node]) => { + const [, left, top, right, bottom] = node.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/); + return { + id: node.match(/resource-id="([^"]*)"/)[1], + selected: node.includes('selected="true"'), + // The title line, clear of the PR and check badges on the second line. + title: { x: +left + (right - left) / 4, y: +top + (bottom - top) / 3 }, + }; + }, + ); +} + +function cursorPosition() { + const events = [ + ...adb("shell", "dumpsys", "input").matchAll( + /source=MOUSE.*xCursorPosition=([\d.]+), yCursorPosition=([\d.]+).*age=(\d+)ms/g, + ), + ]; + assert.ok(events.length > 0, "Android reported no mouse events"); + const [, x, y] = events.reduce((latest, event) => (+event[3] < +latest[3] ? event : latest)); + return { x: +x, y: +y }; +} + +function openVirtualMouse() { + const uinput = spawn("adb", ["-s", serial, "shell", "uinput", "-"], { + stdio: ["pipe", "ignore", "inherit"], + }); + const send = (command) => uinput.stdin.write(`${JSON.stringify({ id: 1, ...command })}\n`); + const inject = (...events) => + send({ command: "inject", events: [...events, "EV_SYN", "SYN_REPORT", 0] }); + send({ + command: "register", + name: "Paseo E2E Mouse", + vid: 0x18d1, + pid: 0x0001, + bus: "usb", + configuration: [ + { type: "UI_SET_EVBIT", data: ["EV_KEY", "EV_REL"] }, + { type: "UI_SET_KEYBIT", data: ["BTN_LEFT", "BTN_RIGHT", "BTN_MIDDLE"] }, + { type: "UI_SET_RELBIT", data: ["REL_X", "REL_Y"] }, + ], + }); + return { + // Pointer speed and acceleration scale relative motion, so steer by the + // cursor position Android reports until it lands on the target. + async moveTo(target) { + inject("EV_REL", "REL_X", 1, "EV_REL", "REL_Y", 1); + await setTimeout(150); + for (let step = 0; step < 20; step += 1) { + const cursor = cursorPosition(); + const dx = target.x - cursor.x; + const dy = target.y - cursor.y; + if (Math.abs(dx) <= 4 && Math.abs(dy) <= 4) return; + const clamp = (delta) => Math.max(-60, Math.min(60, Math.round(delta / 3))); + inject("EV_REL", "REL_X", clamp(dx), "EV_REL", "REL_Y", clamp(dy)); + await setTimeout(150); + } + throw new Error(`Cursor did not reach ${JSON.stringify(target)}`); + }, + async click() { + inject("EV_KEY", "BTN_LEFT", 1); + await setTimeout(CLICK_HOLD_MS); + inject("EV_KEY", "BTN_LEFT", 0); + }, + close() { + uinput.stdin.end(); + uinput.kill(); + }, + }; +} + +async function waitForSelected(id) { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + if (workspaceRows().find((row) => row.id === id)?.selected) return true; + await setTimeout(250); + } + return false; +} + +const target = workspaceRows().find((row) => !row.selected); +assert.ok(target, "Open the sidebar with at least one unselected workspace row first"); + +const mouse = openVirtualMouse(); +try { + // Give Android time to add the input device before the first event. + await setTimeout(1_000); + await mouse.moveTo(target.title); + await mouse.click(); + assert.ok(await waitForSelected(target.id), `Primary mouse click did not select ${target.id}`); + console.log(`PASS: primary mouse click selected ${target.id}`); +} finally { + mouse.close(); +} diff --git a/packages/app/src/components/ui/press-highlight.native.test.tsx b/packages/app/src/components/ui/press-highlight.native.test.tsx deleted file mode 100644 index 0915857a82..0000000000 --- a/packages/app/src/components/ui/press-highlight.native.test.tsx +++ /dev/null @@ -1,230 +0,0 @@ -// @vitest-environment jsdom -import React, { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { - PointerType, - State, - type GestureStateChangeEvent, - type GestureStateManager, - type GestureTouchEvent, - type TapGesture, - type TapGestureHandlerEventPayload, -} from "react-native-gesture-handler"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { PressHighlight } from "./press-highlight.native"; - -const { detectedGestures, highlightValues } = vi.hoisted(() => ({ - detectedGestures: new Array(), - highlightValues: new Array<{ value: number }>(), -})); - -// The native gesture runtime does not exist under jsdom. Keep the real gesture builders and -// capture what PressHighlight hands to the detector, so the test drives its callbacks directly. -vi.mock("react-native-gesture-handler", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - GestureDetector: ({ - gesture, - children, - }: { - gesture: TapGesture; - children: React.ReactNode; - }) => { - detectedGestures.push(gesture); - return children; - }, - }; -}); - -vi.mock("react-native-reanimated", async () => { - const { View } = await import("react-native"); - const { useState } = await import("react"); - return { - default: { View }, - useAnimatedStyle: () => ({}), - useSharedValue: (initial: number) => { - const [shared] = useState(() => { - const created = { value: initial }; - highlightValues.push(created); - return created; - }); - return shared; - }, - }; -}); - -let container: HTMLDivElement | null = null; -let root: Root | null = null; - -beforeEach(() => { - vi.stubGlobal("React", React); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - // react-native-web delays press-in by 50 ms. - vi.useFakeTimers(); - detectedGestures.length = 0; - highlightValues.length = 0; - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); -}); - -afterEach(() => { - if (root && container) { - act(() => { - root?.unmount(); - }); - container.remove(); - } - root = null; - container = null; - vi.useRealTimers(); - vi.unstubAllGlobals(); -}); - -function renderPressHighlight(props: React.ComponentProps) { - act(() => { - root?.render(); - }); - const element = container?.querySelector('[data-testid="row"]'); - if (!element) { - throw new Error("PressHighlight did not render its pressable"); - } - return element; -} - -function tapGesture() { - const gesture = detectedGestures.at(-1); - if (!gesture) { - throw new Error("PressHighlight did not attach a gesture"); - } - return gesture; -} - -function highlight() { - const value = highlightValues.at(-1); - if (!value) { - throw new Error("PressHighlight did not create its highlight value"); - } - return value.value; -} - -function touchesDown(pointerType: PointerType) { - const stateManager = { - activate: vi.fn(), - begin: vi.fn(), - end: vi.fn(), - fail: vi.fn(), - }; - const touch = { id: 0, x: 12, y: 8, absoluteX: 112, absoluteY: 208 }; - // The first touch-down reaches the handler before it begins, so its state is UNDETERMINED. - const event: GestureTouchEvent = { - handlerTag: 1, - numberOfTouches: 1, - state: State.UNDETERMINED, - // TouchEventType.TOUCHES_DOWN; the package does not export TouchEventType. - eventType: 1, - allTouches: [touch], - changedTouches: [touch], - pointerType, - }; - tapGesture().handlers.onTouchesDown?.(event, stateManager); - return stateManager; -} - -function tapStateChange( - oldState: State, - state: State, -): GestureStateChangeEvent { - return { - handlerTag: 1, - numberOfPointers: 1, - oldState, - state, - pointerType: PointerType.TOUCH, - x: 12, - y: 8, - absoluteX: 112, - absoluteY: 208, - }; -} - -function click(element: HTMLElement) { - const init = { bubbles: true, cancelable: true, button: 0, buttons: 1 }; - act(() => { - element.dispatchEvent(new MouseEvent("mousedown", init)); - vi.advanceTimersByTime(100); - element.dispatchEvent(new MouseEvent("mouseup", init)); - element.click(); - vi.advanceTimersByTime(100); - }); -} - -const highlightStyle = { backgroundColor: "red" }; - -describe("PressHighlight (native)", () => { - it("fails the highlight tap as soon as a mouse touches down", () => { - renderPressHighlight({ highlightStyle }); - - expect(touchesDown(PointerType.MOUSE).fail).toHaveBeenCalledOnce(); - }); - - it("leaves the highlight tap running for touch and stylus", () => { - renderPressHighlight({ highlightStyle }); - - for (const pointerType of [PointerType.TOUCH, PointerType.STYLUS]) { - const stateManager = touchesDown(pointerType); - expect(stateManager.fail).not.toHaveBeenCalled(); - expect(stateManager.activate).not.toHaveBeenCalled(); - expect(stateManager.end).not.toHaveBeenCalled(); - } - }); - - it("glows from tap begin until the tap finalizes", () => { - renderPressHighlight({ highlightStyle }); - const gesture = tapGesture(); - - expect(gesture.config).toMatchObject({ - enabled: true, - maxDist: 8, - shouldCancelWhenOutside: true, - }); - gesture.handlers.onBegin?.(tapStateChange(State.UNDETERMINED, State.BEGAN)); - expect(highlight()).toBe(1); - gesture.handlers.onFinalize?.(tapStateChange(State.ACTIVE, State.END), true); - expect(highlight()).toBe(0); - }); - - it("delivers the press callbacks to the caller", () => { - const onPress = vi.fn(); - const onPressIn = vi.fn(); - const onPressOut = vi.fn(); - const element = renderPressHighlight({ highlightStyle, onPress, onPressIn, onPressOut }); - - click(element); - - expect(onPressIn).toHaveBeenCalledOnce(); - expect(onPressOut).toHaveBeenCalledOnce(); - expect(onPress).toHaveBeenCalledOnce(); - }); - - it("disables the highlight tap and the press while disabled", () => { - const onPress = vi.fn(); - const element = renderPressHighlight({ disabled: true, highlightStyle, onPress }); - - click(element); - - expect(tapGesture().config.enabled).toBe(false); - expect(onPress).not.toHaveBeenCalled(); - }); - - it("presses without a gesture when no highlight is requested", () => { - const onPress = vi.fn(); - const element = renderPressHighlight({ onPress }); - - click(element); - - expect(detectedGestures).toHaveLength(0); - expect(onPress).toHaveBeenCalledOnce(); - }); -});