fix(test): make Android double-tap e2e pass on default-config CI devices (#4921)
This commit is contained in:
@@ -1,38 +1,45 @@
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { doubleTap } from './helpers/adb';
|
||||
import { CdpPage } from './helpers/cdp';
|
||||
import {
|
||||
detectAndroidEnv,
|
||||
dismissSelection,
|
||||
getSelectionState,
|
||||
openFixtureBook,
|
||||
patchGlobalViewSettings,
|
||||
waitFor,
|
||||
} from './helpers/reader';
|
||||
|
||||
// End-to-end coverage for the double-click / touch double-tap gesture: tapping a
|
||||
// word twice quickly selects that word — as if long-press-selecting it — and
|
||||
// raises the annotation toolbar. With the default config no quick action is set
|
||||
// raises the annotation toolbar. No quick action is set by default
|
||||
// (annotationQuickAction === null), so the toolbar (.selection-popup) appears.
|
||||
//
|
||||
// Runs against any adb device/emulator with Readest installed; soft-skips
|
||||
// otherwise.
|
||||
// The feature is opt-in on mobile: DEFAULT_MOBILE_VIEW_SETTINGS ships
|
||||
// disableDoubleClick: true (double-click detection delays single-tap page
|
||||
// turns by the 250ms disambiguation window), so the lane seeds
|
||||
// disableDoubleClick: false — the setting a user who wants the gesture turns
|
||||
// on — and restores the previous value afterwards.
|
||||
//
|
||||
// Runs against any adb device/emulator with a debug Readest build installed;
|
||||
// soft-skips otherwise.
|
||||
|
||||
const FIXTURE = path.resolve(__dirname, '../fixtures/data/sample-alice.epub');
|
||||
|
||||
interface WordHit {
|
||||
word: string;
|
||||
deviceX: number;
|
||||
deviceY: number;
|
||||
cssX: number;
|
||||
cssY: number;
|
||||
}
|
||||
|
||||
// Find a comfortably on-screen word (>= 4 latin letters, no adjacent apostrophe
|
||||
// so the native word matches the segmenter word), away from the page edges so
|
||||
// the tap lands on text rather than a margin.
|
||||
// the tap lands on text rather than a margin. The word must render as a single
|
||||
// line box: a hyphenated/wrapped word's bounding rect spans both lines, so its
|
||||
// center would tap the text between them and select a neighboring word.
|
||||
const locateAnyWord = (page: CdpPage) =>
|
||||
page.evaluate<WordHit | null>(`
|
||||
const view = document.querySelector('foliate-view');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = window.innerWidth, H = window.innerHeight;
|
||||
for (const c of view.renderer.getContents()) {
|
||||
if (!c.doc) continue;
|
||||
@@ -48,14 +55,16 @@ const locateAnyWord = (page: CdpPage) =>
|
||||
const range = c.doc.createRange();
|
||||
range.setStart(node, m.index);
|
||||
range.setEnd(node, m.index + m[0].length);
|
||||
const rect = range.getBoundingClientRect();
|
||||
const rects = range.getClientRects();
|
||||
if (rects.length !== 1) continue;
|
||||
const rect = rects[0];
|
||||
if (!rect.width || !rect.height) continue;
|
||||
const frame = c.doc.defaultView.frameElement.getBoundingClientRect();
|
||||
const cssX = frame.left + rect.left + rect.width / 2;
|
||||
const cssY = frame.top + rect.top + rect.height / 2;
|
||||
if (cssX < W * 0.15 || cssX > W * 0.85) continue;
|
||||
if (cssY < H * 0.2 || cssY > H * 0.8) continue;
|
||||
return { word: m[0], deviceX: Math.round(cssX * dpr), deviceY: Math.round(cssY * dpr) };
|
||||
return { word: m[0], cssX, cssY };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,14 +81,17 @@ if (!env) {
|
||||
|
||||
describe.runIf(env)('Android double-tap word selection + toolbar', () => {
|
||||
let page: CdpPage;
|
||||
let savedViewSettings: Record<string, unknown>;
|
||||
|
||||
beforeAll(async () => {
|
||||
savedViewSettings = await patchGlobalViewSettings({ disableDoubleClick: false });
|
||||
page = await openFixtureBook(FIXTURE);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
page?.close();
|
||||
});
|
||||
if (savedViewSettings) await patchGlobalViewSettings(savedViewSettings);
|
||||
}, 60_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
const sel = await getSelectionState(page);
|
||||
@@ -89,7 +101,7 @@ describe.runIf(env)('Android double-tap word selection + toolbar', () => {
|
||||
it('selects the double-tapped word and shows the annotation toolbar', async () => {
|
||||
const hit = await waitFor(() => locateAnyWord(page), { label: 'on-screen word' });
|
||||
|
||||
await doubleTap(hit.deviceX, hit.deviceY);
|
||||
await page.doubleTap(hit.cssX, hit.cssY);
|
||||
|
||||
const sel = await waitFor(
|
||||
async () => {
|
||||
|
||||
@@ -53,14 +53,6 @@ export const tap = (x: number, y: number): Promise<string> =>
|
||||
export const longPress = (x: number, y: number, ms = 700): Promise<string> =>
|
||||
adbShell(`input swipe ${Math.round(x)} ${Math.round(y)} ${Math.round(x)} ${Math.round(y)} ${ms}`);
|
||||
|
||||
// Two taps in a single shell invocation so both `click` events reach the WebView
|
||||
// inside the double-click window (DOUBLE_CLICK_INTERVAL_THRESHOLD_MS = 250ms).
|
||||
export const doubleTap = (x: number, y: number): Promise<string> => {
|
||||
const rx = Math.round(x);
|
||||
const ry = Math.round(y);
|
||||
return adbShell(`input tap ${rx} ${ry} && input tap ${rx} ${ry}`);
|
||||
};
|
||||
|
||||
export interface MotionStep {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -89,6 +81,16 @@ export const motionGesture = async (steps: MotionStep[]): Promise<string> => {
|
||||
export const pushFile = (local: string, remote: string): Promise<string> =>
|
||||
adb(['push', local, remote]);
|
||||
|
||||
// App-private data files, via `run-as` — debug builds only (the package must
|
||||
// be debuggable). Paths are relative to the app's data dir root.
|
||||
export const readAppFile = (pkg: string, path: string): Promise<string> =>
|
||||
adbShell(`run-as ${pkg} cat ${path}`);
|
||||
|
||||
export const writeAppFile = async (pkg: string, path: string, content: string): Promise<void> => {
|
||||
const b64 = Buffer.from(content, 'utf8').toString('base64');
|
||||
await adbShell(`echo ${b64} | base64 -d | run-as ${pkg} sh -c 'cat > ${path}'`);
|
||||
};
|
||||
|
||||
export const forwardTcpToLocalAbstract = async (port: number, socket: string): Promise<void> => {
|
||||
try {
|
||||
await adb(['forward', '--remove', `tcp:${port}`]);
|
||||
|
||||
@@ -116,6 +116,26 @@ export class CdpPage {
|
||||
return res.result?.result?.value as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch double-tap through the WebView's own input pipeline (gesture
|
||||
* recognition and click synthesis included). adb `input tap` spawns a whole
|
||||
* Java process per tap — 300ms to 1s on a loaded CI emulator — so two adb
|
||||
* taps cannot reliably land inside the app's 250ms double-click window.
|
||||
* A single tapCount:2 gesture is timed inside the renderer (measured click
|
||||
* gap ~200ms on a busy emulator); two separate synthesizeTapGesture
|
||||
* commands are too slow, since each resolves long after its gesture.
|
||||
* Coordinates are CSS pixels.
|
||||
*/
|
||||
async doubleTap(cssX: number, cssY: number): Promise<void> {
|
||||
await this.send('Input.synthesizeTapGesture', {
|
||||
x: cssX,
|
||||
y: cssY,
|
||||
tapCount: 2,
|
||||
duration: 20,
|
||||
gestureSourceType: 'touch',
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.ws.close();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
listDeviceSerials,
|
||||
longPress,
|
||||
pushFile,
|
||||
readAppFile,
|
||||
tap,
|
||||
writeAppFile,
|
||||
} from './adb';
|
||||
import { CdpPage, forwardWebViewDevtools, listPages } from './cdp';
|
||||
|
||||
@@ -49,6 +51,36 @@ export const waitFor = async <T>(
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Patch `globalViewSettings` in the app's persisted settings.json and return
|
||||
* the previous values of the patched keys (pass them back to restore). The app
|
||||
* is force-stopped first so it cannot overwrite the patch on exit; the next
|
||||
* launch reads the seeded settings. A missing settings.json (fresh install) is
|
||||
* fine: loadSettings deep-merges the partial file over the platform defaults.
|
||||
* Uses `run-as`, so it works on debug builds only — like the rest of the lane.
|
||||
*/
|
||||
export const patchGlobalViewSettings = async (
|
||||
patch: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> => {
|
||||
await adbShell(`am force-stop ${APP_PKG}`);
|
||||
let settings: { globalViewSettings?: Record<string, unknown> } = {};
|
||||
try {
|
||||
settings = JSON.parse(await readAppFile(APP_PKG, 'settings.json'));
|
||||
} catch {
|
||||
// fresh install — seed a minimal settings file
|
||||
}
|
||||
settings.globalViewSettings ??= {};
|
||||
const viewSettings = settings.globalViewSettings;
|
||||
const previous: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
previous[key] = viewSettings[key];
|
||||
if (value === undefined) delete viewSettings[key];
|
||||
else viewSettings[key] = value;
|
||||
}
|
||||
await writeAppFile(APP_PKG, 'settings.json', JSON.stringify(settings));
|
||||
return previous;
|
||||
};
|
||||
|
||||
/**
|
||||
* Open a fixture EPUB in the reader via a VIEW intent (the "Open with" flow,
|
||||
* which opens the book transiently without touching the user's library) and
|
||||
@@ -471,7 +503,20 @@ export const longPressWord = async (
|
||||
/** Tap an empty-ish text spot away from the popup to dismiss the selection. */
|
||||
export const dismissSelection = async (page: CdpPage): Promise<void> => {
|
||||
const { dpr, viewWidth, viewHeight } = await getReaderMetrics(page);
|
||||
await tap(viewWidth * 0.5 * dpr, viewHeight * 0.78 * dpr);
|
||||
// The annotation toolbar (.selection-popup) renders near the selection —
|
||||
// wherever that is. A tap that lands on the toolbar presses a button instead
|
||||
// of dismissing, so pick whichever mid-column spot the popup doesn't cover.
|
||||
const dismissY = await page.evaluate<number>(`
|
||||
const popup = document.querySelector('.selection-popup');
|
||||
const candidates = [0.78, 0.25];
|
||||
if (!popup) return candidates[0];
|
||||
const rect = popup.getBoundingClientRect();
|
||||
const clear = candidates.find(
|
||||
(f) => ${viewHeight} * f < rect.top - 24 || ${viewHeight} * f > rect.bottom + 24,
|
||||
);
|
||||
return clear ?? candidates[0];
|
||||
`);
|
||||
await tap(viewWidth * 0.5 * dpr, viewHeight * dismissY * dpr);
|
||||
await waitFor(
|
||||
async () => {
|
||||
const handles = await getCustomHandles(page);
|
||||
|
||||
Reference in New Issue
Block a user