diff --git a/examples/bare/App.tsx b/examples/bare/App.tsx index 90e23218..37f24afc 100644 --- a/examples/bare/App.tsx +++ b/examples/bare/App.tsx @@ -22,7 +22,7 @@ * `breadcrumb-filter` is scenarios/breadcrumb-filter.ts, * `network-filter` is scenarios/network-filter.ts, * `add-network-event` is scenarios/add-network-event.ts, - * `feedback` is scenarios/feedback.ts, `apm` is scenarios/apm.ts, and + * `touch-capture` is scenarios/touches.ts, `feedback` is scenarios/feedback.ts, `apm` is scenarios/apm.ts, and * `cov-*` / `opt-` are scenarios/coverage.ts. `early-report` is * scenarios/early-report.ts (it owns the launch). */ @@ -105,6 +105,7 @@ import { isLogFilterScenario, runLogFilterScenario, } from './scenarios/log-filter'; +import { isTouchesScenario, runTouchesScenario } from './scenarios/touches'; import { isBreadcrumbFilterScenario, runBreadcrumbFilterScenario, @@ -537,6 +538,11 @@ export default function App() { return; } + if (isTouchesScenario(choice.scenario)) { + runTouchesScenario(choice.nonce); + return; + } + if (isLogFilterScenario(choice.scenario)) { runLogFilterScenario(choice.nonce); return; diff --git a/examples/bare/e2e/screen.ts b/examples/bare/e2e/screen.ts index 17f1b252..c55a4b1e 100644 --- a/examples/bare/e2e/screen.ts +++ b/examples/bare/e2e/screen.ts @@ -100,6 +100,19 @@ export async function displaySize(): Promise<{ width: number; height: number }> return { width: Number(physical[1]), height: Number(physical[2]) }; } +/** + * The display size apps and `input touchscreen` use (`wm size`), in pixels: + * the override when one is set, else the physical size. + */ +export async function effectiveDisplaySize(): Promise<{ width: number; height: number }> { + const out = await adb('shell', 'wm', 'size'); + const size = /Override size: (\d+)x(\d+)/.exec(out) ?? /Physical size: (\d+)x(\d+)/.exec(out); + if (size === null) { + throw new Error(`could not read the display size: ${out}`); + } + return { width: Number(size[1]), height: Number(size[2]) }; +} + /** * `rect` (display pixels) on an image `imageWidth` wide, shrunk to its * centred `fraction` (1 = the whole rectangle), rounded inwards. diff --git a/examples/bare/e2e/touches.test.ts b/examples/bare/e2e/touches.test.ts new file mode 100644 index 00000000..fcd4599e --- /dev/null +++ b/examples/bare/e2e/touches.test.ts @@ -0,0 +1,210 @@ +/** + * A report carries the touches the person made: tap the example app, swipe + * it, take a report, and read the `input` capture out of the bundle. + * + * Scenario `touch-capture` (scenarios/touches.ts) logs `ready`, waits 20 s, + * uploads `touch-capture-` and logs `uploaded`. In that window this + * file drives `adb shell input` with three taps and one swipe at known pixels, + * and the bundle's `input` capture (spec: sdk/reporting/bundle/input.md) must + * show them: a `begin` and an `end` per tap, `move`s for the swipe, and + * coordinates in dp (the pixel divided by the display density). + * + * Android: a plain `it`. Before 7.4.1 the SDK decided its touch overlay was + * already installed with `findViewById(View.generateViewId())`, and React + * Native's root view has the id 1 as well, so the overlay was never added and + * the bundle had no `input` capture at all + * (https://github.com/bugsee/bugsee-android/issues/267, fixed in 7.4.1). The + * preconditions (the bundle exists, the video was recorded) are their own + * test, so a broken run shows as such rather than as missing touches. + * + * iOS: skipped. Nothing in this harness can drive a tap on the simulator (no + * idb, no XCUITest runner; operator.ts says the harness never taps), and + * taking over the Simulator window is not an option. + * + * Retention is airplane mode (bundles.ts) on top of the placeholder token's + * dead endpoint. Preconditions as for the other Android suites. + */ +import { type PulledBundle, airplane, captureEvents, removePulledBundles } from './bundles'; +import { + ON_ANDROID, + ON_IOS, + type Run, + TARGET_NAME, + awaitBundles, + clearBundles, + describeDevice, + escape, + must, + report, + startDeviceLog, + startRun, + stopApp, + stopDeviceLog, +} from './harness'; +import { type DeviceLog, adb, resetScenario } from './scenario'; +import { bundleBySummary, effectiveDisplaySize } from './screen'; + +jest.setTimeout(6 * 60_000); + +type Entry = Record; + +/** Where the taps land, as fractions of the display, and the swipe's ends. */ +const TAPS: ReadonlyArray = [ + [0.25, 0.4], + [0.5, 0.5], + [0.75, 0.6], +]; +const SWIPE = { from: [0.5, 0.75], to: [0.5, 0.3], ms: 400 } as const; + +/** How much of the scenario's window must be left when the last touch is sent. */ +const INPUT_MARGIN_MS = 5_000; + +/** How far a recorded coordinate may be from tap pixel / density, in dp. */ +const TOLERANCE_DP = 1; + +/** The display density as a scale factor (`wm density` / 160). */ +async function densityScale(): Promise { + const out = await adb('shell', 'wm', 'density'); + const override = /Override density: (\d+)/.exec(out); + const physical = /Physical density: (\d+)/.exec(out); + const dpi = Number((override ?? physical)?.[1]); + if (!Number.isFinite(dpi) || dpi <= 0) { + throw new Error(`could not read the display density: ${out}`); + } + return dpi / 160; +} + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +// iOS cannot be driven here at all: skip, do not pin. +const itTouches = ON_IOS ? it.skip : it; + +describeDevice(`touch capture on ${TARGET_NAME}`, () => { + let log: DeviceLog; + let run: Run; + let nonce: string; + let bundle: PulledBundle; + let taps: Array<{ px: number; py: number }> = []; + let density = 1; + + beforeAll(async () => { + if (!ON_ANDROID) { + return; + } + log = await startDeviceLog('touches', 'touches'); + await airplane(true); + await clearBundles(); + run = await startRun('touch-capture'); + nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); + + const ready = must( + await log.waitFor(new RegExp(`BUGSEE_E2E touches ready .*nonce=${nonce}`), 20_000, run.launched.index), + 'the touch-capture scenario being ready', + run.start, + ); + + // The override, when set, is the space injected touches and the app use. + const { width, height } = await effectiveDisplaySize(); + density = await densityScale(); + taps = TAPS.map(([fx, fy]) => ({ px: Math.round(fx * width), py: Math.round(fy * height) })); + report('display', { width, height, density, taps }); + for (const { px, py } of taps) { + await adb('shell', 'input', 'touchscreen', 'tap', String(px), String(py)); + await sleep(600); + } + const [x1, y1] = SWIPE.from.map((f, i) => Math.round(f * (i === 0 ? width : height))); + const [x2, y2] = SWIPE.to.map((f, i) => Math.round(f * (i === 0 ? width : height))); + const swipe = [x1, y1, x2, y2, SWIPE.ms].map(String); + await adb('shell', 'input', 'touchscreen', 'swipe', ...swipe); + // The scenario uploads a fixed window after `ready`: every touch must be + // in before it, or the report could miss the last of them. Both ends are + // timed on the device: a marker logged there after the last touch, against + // the `ready` line's logcat stamp (host arrival times can lag either). + await adb('shell', 'log', '-t', 'BugseeE2EHost', `touches input-sent nonce=${nonce}`); + const sent = must( + await log.waitFor(new RegExp(`BugseeE2EHost.*touches input-sent nonce=${escape(nonce)}`), 10_000, ready.index), + 'the input-sent marker', + run.start, + ); + const windowMs = Number(/window=(\d+)/.exec(ready.text)?.[1]); + const elapsed = sent.deviceMs - ready.deviceMs; + report('input sent', { elapsedMs: elapsed, windowMs }); + if (!(elapsed < windowMs - INPUT_MARGIN_MS)) { + throw new Error(`the taps and swipe took ${elapsed} ms of the scenario's ${windowMs} ms window`); + } + + must( + await log.waitFor(new RegExp(`BUGSEE_E2E touches uploaded nonce=${escape(nonce)}`), 40_000, ready.index), + 'the touch-capture scenario uploading', + run.start, + ); + const bundles = await awaitBundles(1); + bundle = bundleBySummary(bundles, `touch-capture-${nonce}`); + report('manifest types', bundle.manifest.files.map(f => f.type)); + }); + + afterAll(async () => { + if (!ON_ANDROID) { + return; + } + try { + await stopApp(); + await clearBundles().catch((error: unknown) => report('cleanup clear failed', String(error))); + } finally { + try { + await airplane(false); + } finally { + const { removed, kept } = removePulledBundles(); + report('pulled bundle roots', { removed: removed.length, kept }); + stopDeviceLog(log); + resetScenario(); + } + } + }); + + (ON_ANDROID ? it : it.skip)('the run produced a report with a recorded video', () => { + expect(bundle.manifest.files.some(f => f.type === 'video')).toBe(true); + }); + + itTouches( + `the report holds the taps and the swipe as input events${ + ON_IOS ? ' [skipped on iOS: the harness has no way to tap the simulator]' : '' + }`, + () => { + expect(bundle.manifest.files.some(f => f.type === 'input')).toBe(true); + + // Pointer events only. `adb shell input` injects with an unknown tool + // type, which the SDK records as tool 5 ("other") rather than 1 (touch), + // so the tool is not asserted; key events (tool 7) are left out. + const events = captureEvents(bundle, 'input').filter(e => e.tool !== 7); + report('input events', { count: events.length, first: events[0] }); + const ofType = (type: string): Entry[] => events.filter(e => e.type === type); + const begins = ofType('begin'); + const ends = ofType('end'); + expect(begins.length).toBeGreaterThanOrEqual(TAPS.length + 1); + expect(ends.length).toBeGreaterThanOrEqual(TAPS.length + 1); + // Every gesture that began also ended, under one id (a missing id on + // both sides would otherwise match). + const endIds = new Set(ends.map(e => e.id)); + for (const begin of begins) { + expect(begin.id).toEqual(expect.anything()); + expect(endIds.has(begin.id)).toBe(true); + } + // The swipe moved. + expect(ofType('move').length).toBeGreaterThanOrEqual(1); + + // Each tap is recorded at its pixel divided by the density, in dp. + for (const { px, py } of taps) { + const ex = px / density; + const ey = py / density; + const hit = begins.find( + b => + Math.abs((b.x as number) - ex) <= TOLERANCE_DP && Math.abs((b.y as number) - ey) <= TOLERANCE_DP, + ); + report(`tap ${px},${py} -> expect ${ex.toFixed(2)},${ey.toFixed(2)}`, hit); + expect(hit).toBeDefined(); + } + }, + ); +}); diff --git a/examples/bare/scenarios/touches.ts b/examples/bare/scenarios/touches.ts new file mode 100644 index 00000000..9b6eb4be --- /dev/null +++ b/examples/bare/scenarios/touches.ts @@ -0,0 +1,32 @@ +/** + * The touch-capture scenario (e2e/touches.test.ts). + * + * After `Launched` it logs `ready`, gives the test a fixed window to tap and + * swipe the screen with the platform's input tool, then uploads one report + * and logs `uploaded`. The scenario renders nothing of its own: the touches + * land on the example app and only the SDK's recording of them matters. + */ +import Bugsee from '@bugsee/react-native'; + +export const TOUCHES_SCENARIOS = ['touch-capture'] as const; + +export type TouchesScenario = (typeof TOUCHES_SCENARIOS)[number]; + +export function isTouchesScenario(name: string): name is TouchesScenario { + return (TOUCHES_SCENARIOS as readonly string[]).includes(name); +} + +function mark(message: string): void { + console.log(`BUGSEE_E2E touches ${message}`); +} + +/** How long the test has to tap and swipe before the report is taken. */ +export const TOUCH_WINDOW_MS = 20_000; + +export function runTouchesScenario(nonce: string): void { + mark(`ready window=${TOUCH_WINDOW_MS} nonce=${nonce}`); + setTimeout(() => { + Bugsee.upload(`touch-capture-${nonce}`, ''); + mark(`uploaded nonce=${nonce}`); + }, TOUCH_WINDOW_MS); +}