Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion examples/bare/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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-<case>` are scenarios/coverage.ts. `early-report` is
* scenarios/early-report.ts (it owns the launch).
*/
Expand Down Expand Up @@ -105,6 +105,7 @@ import {
isLogFilterScenario,
runLogFilterScenario,
} from './scenarios/log-filter';
import { isTouchesScenario, runTouchesScenario } from './scenarios/touches';
import {
isBreadcrumbFilterScenario,
runBreadcrumbFilterScenario,
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions examples/bare/e2e/screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
210 changes: 210 additions & 0 deletions examples/bare/e2e/touches.test.ts
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),
Comment thread
krassx marked this conversation as resolved.
'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);
Comment thread
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();
}
},
);
});
32 changes: 32 additions & 0 deletions examples/bare/scenarios/touches.ts
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);
}
Loading