Repository navigation
e2e: check that reports carry touch input (Android plain it on 7.4.1) #85
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
40f4e3c
e2e: check that Android and iOS reports carry touch input
krassx c837111
e2e touches: tap in the effective display size; require gesture ids
krassx 2dabeb5
e2e touches: fail if the touches overrun the scenario's upload window
krassx 9f9f9db
e2e touches: time the input window on the device clock
krassx af7b7e2
e2e touches: a plain it on Android now that 7.4.1 is pinned
krassx File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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-<nonce>` 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<string, unknown>; | ||
|
|
||
| /** Where the taps land, as fractions of the display, and the swipe's ends. */ | ||
| const TAPS: ReadonlyArray<readonly [number, number]> = [ | ||
| [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<number> { | ||
| 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<void>(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); | ||
|
krassx marked this conversation as resolved.
|
||
| } | ||
| // 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(); | ||
| } | ||
| }, | ||
| ); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.