mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
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 <i.volnistov@gaijin.team>
This commit is contained in:
co-authored by
Ilya Volnistov
parent
1ec55a7e29
commit
0c1447db91
@@ -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(/<node [^>]*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();
|
||||
}
|
||||
@@ -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<TapGesture>(),
|
||||
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<typeof import("react-native-gesture-handler")>();
|
||||
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<typeof PressHighlight>) {
|
||||
act(() => {
|
||||
root?.render(<PressHighlight testID="row" {...props} />);
|
||||
});
|
||||
const element = container?.querySelector<HTMLElement>('[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<GestureStateManager["activate"]>(),
|
||||
begin: vi.fn<GestureStateManager["begin"]>(),
|
||||
end: vi.fn<GestureStateManager["end"]>(),
|
||||
fail: vi.fn<GestureStateManager["fail"]>(),
|
||||
};
|
||||
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<TapGestureHandlerEventPayload> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user