fix(test): make Android double-tap e2e pass on default-config CI devices (#4921)

This commit is contained in:
Huang Xin
2026-07-04 19:46:02 +09:00
committed by GitHub
parent 1d3dfd395f
commit c86decc2c1
4 changed files with 101 additions and 22 deletions
@@ -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);