From 40f4e3c756c66cfe31cd6174cb909709bada471d Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 13 Oct 2026 18:31:36 +0500 Subject: [PATCH 1/5] e2e: check that Android and iOS reports carry touch input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a touch-capture scenario and e2e/touches.test.ts: tap and swipe the example app with adb, take a report, and assert the bundle's input capture holds the taps (dp coordinates) and the swipe moves. On Android the touch case is it.failing: the SDK never installs its touch overlay in React Native apps (bugsee/bugsee-android#267), so the report has no input capture. A fixed SDK will surface as 'it.failing passed'. Skipped on iOS, where the harness cannot tap the simulator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- examples/bare/App.tsx | 8 +- examples/bare/e2e/touches.test.ts | 189 +++++++++++++++++++++++++++++ examples/bare/scenarios/touches.ts | 32 +++++ 3 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 examples/bare/e2e/touches.test.ts create mode 100644 examples/bare/scenarios/touches.ts 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/touches.test.ts b/examples/bare/e2e/touches.test.ts new file mode 100644 index 00000000..2d228874 --- /dev/null +++ b/examples/bare/e2e/touches.test.ts @@ -0,0 +1,189 @@ +/** + * 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: known SDK bug https://github.com/bugsee/bugsee-android/issues/267. + * The SDK decides its touch overlay is already installed with + * `findViewById(View.generateViewId())`, and React Native's root view has the + * id 1 as well, so the overlay is never added and the bundle has no `input` + * capture at all. The touch case is `it.failing` there until a fixed SDK is + * pinned: a fix then shows up as "it.failing passed". The preconditions (the + * bundle exists, the video was recorded) are plain `it`, so the pin cannot + * hide a broken run. + * + * 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, displaySize } 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 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)); + +// Android's touch capture is broken for React Native apps (bugsee-android#267). +// iOS cannot be driven here at all: skip, do not pin. +const itTouches = ON_IOS ? it.skip : ON_ANDROID ? it.failing : 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, + ); + + const { width, height } = await displaySize(); + 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))); + await adb('shell', 'input', 'touchscreen', 'swipe', String(x1), String(y1), String(x2), String(y2), String(SWIPE.ms)); + + 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_ANDROID ? ' [known: SDK never installs its touch overlay in RN apps (bugsee-android#267, https://github.com/bugsee/bugsee-android/issues/267)]' : '' + }${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. + const endIds = new Set(ends.map(e => e.id)); + for (const begin of begins) { + 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); +} From c837111db53cc7c21283a110b8de460f108a40e0 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 16:59:58 +0500 Subject: [PATCH 2/5] e2e touches: tap in the effective display size; require gesture ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Touches are injected in the `wm size` override when one is set, so the tap pixels now come from it (the density already preferred its override). A begin without an id no longer matches an end without one (CodeRabbit on #85). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- examples/bare/e2e/screen.ts | 13 +++++++++++++ examples/bare/e2e/touches.test.ts | 9 ++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) 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 index 2d228874..a3282f67 100644 --- a/examples/bare/e2e/touches.test.ts +++ b/examples/bare/e2e/touches.test.ts @@ -43,7 +43,7 @@ import { stopDeviceLog, } from './harness'; import { type DeviceLog, adb, resetScenario } from './scenario'; -import { bundleBySummary, displaySize } from './screen'; +import { bundleBySummary, effectiveDisplaySize } from './screen'; jest.setTimeout(6 * 60_000); @@ -103,7 +103,8 @@ describeDevice(`touch capture on ${TARGET_NAME}`, () => { run.start, ); - const { width, height } = await displaySize(); + // 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 }); @@ -165,9 +166,11 @@ describeDevice(`touch capture on ${TARGET_NAME}`, () => { 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. + // 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. From 2dabeb54a88bbb8a123afd7997fba1f62e576c89 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 17:10:38 +0500 Subject: [PATCH 3/5] e2e touches: fail if the touches overrun the scenario's upload window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scenario uploads a fixed window after `ready`. The test now checks, after the swipe, that at least 5 s of that window is left and that the upload has not been logged, so a slow run fails loudly instead of missing the last touches (CodeRabbit on #85). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- examples/bare/e2e/touches.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/examples/bare/e2e/touches.test.ts b/examples/bare/e2e/touches.test.ts index a3282f67..3ef1059c 100644 --- a/examples/bare/e2e/touches.test.ts +++ b/examples/bare/e2e/touches.test.ts @@ -57,6 +57,9 @@ const TAPS: ReadonlyArray = [ ]; 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; @@ -114,7 +117,18 @@ describeDevice(`touch capture on ${TARGET_NAME}`, () => { } 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))); - await adb('shell', 'input', 'touchscreen', 'swipe', String(x1), String(y1), String(x2), String(y2), String(SWIPE.ms)); + 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. + const windowMs = Number(/window=(\d+)/.exec(ready.text)?.[1]); + // `hostMs` is when the line reached this Mac, a little after the app wrote it. + const elapsed = ready.hostMs === undefined ? 0 : Date.now() - ready.hostMs; + const early = log.all(new RegExp(`BUGSEE_E2E touches uploaded nonce=${escape(nonce)}`), ready.index); + report('input sent', { elapsedMs: elapsed, windowMs }); + if (!(elapsed < windowMs - INPUT_MARGIN_MS) || early.length > 0) { + 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), From 9f9f9db2797d638b6df052777c64cac65cf5e393 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 17:17:46 +0500 Subject: [PATCH 4/5] e2e touches: time the input window on the device clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard compared host arrival times, which lag when logcat delivers a line late. A marker is now logged on the device after the last touch and its logcat stamp is compared with the `ready` line's (CodeRabbit on #85). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- examples/bare/e2e/touches.test.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/examples/bare/e2e/touches.test.ts b/examples/bare/e2e/touches.test.ts index 3ef1059c..fc35a759 100644 --- a/examples/bare/e2e/touches.test.ts +++ b/examples/bare/e2e/touches.test.ts @@ -120,13 +120,19 @@ describeDevice(`touch capture on ${TARGET_NAME}`, () => { 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. + // 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]); - // `hostMs` is when the line reached this Mac, a little after the app wrote it. - const elapsed = ready.hostMs === undefined ? 0 : Date.now() - ready.hostMs; - const early = log.all(new RegExp(`BUGSEE_E2E touches uploaded nonce=${escape(nonce)}`), ready.index); + const elapsed = sent.deviceMs - ready.deviceMs; report('input sent', { elapsedMs: elapsed, windowMs }); - if (!(elapsed < windowMs - INPUT_MARGIN_MS) || early.length > 0) { + if (!(elapsed < windowMs - INPUT_MARGIN_MS)) { throw new Error(`the taps and swipe took ${elapsed} ms of the scenario's ${windowMs} ms window`); } From af7b7e2471b976141ef56f58c702264d8b5d978a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 17:58:16 +0500 Subject: [PATCH 5/5] e2e touches: a plain it on Android now that 7.4.1 is pinned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android SDK 7.4.1 (#81) fixes bugsee-android#267, the overlay check that collided with React Native's root view id, so the touch case no longer needs the it.failing pin and any missing tap, move or coordinate fails. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- examples/bare/e2e/touches.test.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/examples/bare/e2e/touches.test.ts b/examples/bare/e2e/touches.test.ts index fc35a759..fcd4599e 100644 --- a/examples/bare/e2e/touches.test.ts +++ b/examples/bare/e2e/touches.test.ts @@ -9,14 +9,13 @@ * 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: known SDK bug https://github.com/bugsee/bugsee-android/issues/267. - * The SDK decides its touch overlay is already installed with - * `findViewById(View.generateViewId())`, and React Native's root view has the - * id 1 as well, so the overlay is never added and the bundle has no `input` - * capture at all. The touch case is `it.failing` there until a fixed SDK is - * pinned: a fix then shows up as "it.failing passed". The preconditions (the - * bundle exists, the video was recorded) are plain `it`, so the pin cannot - * hide a broken run. + * 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 @@ -77,9 +76,8 @@ async function densityScale(): Promise { const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); -// Android's touch capture is broken for React Native apps (bugsee-android#267). // iOS cannot be driven here at all: skip, do not pin. -const itTouches = ON_IOS ? it.skip : ON_ANDROID ? it.failing : it; +const itTouches = ON_IOS ? it.skip : it; describeDevice(`touch capture on ${TARGET_NAME}`, () => { let log: DeviceLog; @@ -171,8 +169,8 @@ describeDevice(`touch capture on ${TARGET_NAME}`, () => { itTouches( `the report holds the taps and the swipe as input events${ - ON_ANDROID ? ' [known: SDK never installs its touch overlay in RN apps (bugsee-android#267, https://github.com/bugsee/bugsee-android/issues/267)]' : '' - }${ON_IOS ? ' [skipped on iOS: the harness has no way to tap the simulator]' : ''}`, + 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);