Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
3b8a6b8
BugseeSecure: cover native transitions and native-driven transforms
krassx Oct 14, 2026
7227833
BugseeSecure: tests for every transition and native-animation branch
krassx Oct 14, 2026
aacfe77
e2e secure-transitions, and a compat guard for the internals it reads
krassx Oct 14, 2026
fc17fd7
BugseeSecure: hold a transition's cover 600 ms, and none for a fading…
krassx Oct 14, 2026
596dfb6
BugseeSecure: re-measure every frame under any native-driven transform
krassx Oct 14, 2026
3fc8b97
BugseeSecure: review fixes for transitions and Animated components
krassx Oct 14, 2026
757d826
BugseeSecure: cover a Modal set visible={false} while it stays mounted
krassx Oct 14, 2026
e89281f
BugseeSecure: watch the Modal a view is in, whatever carries it
krassx Oct 14, 2026
b1dd162
e2e secure-transitions: a looping native animation stopped part-way
krassx Oct 14, 2026
dd96b83
BugseeSecure: kill the remaining mutants on the transition paths
krassx Oct 14, 2026
95a1258
BugseeSecure: a stack inside a slide Modal gets the Modal's band too
krassx Oct 14, 2026
3037ac9
BugseeSecure: review round 2 (native transforms, transitions, perf)
krassx Oct 14, 2026
006fbd8
BugseeSecure: mutation-tight tests for round 2, and an Animated perf …
krassx Oct 14, 2026
536b0d8
BugseeSecure: cover the screen below a push or pop with no secure view
krassx Oct 14, 2026
1b6bc16
e2e secure-transitions: the native-driven mask contains where the vie…
krassx Oct 14, 2026
b3851eb
Test: scope a Debug budget for the animated secure-perf modes
krassx Oct 14, 2026
4f1251b
BugseeSecure: keep the later deadline when a second transition starts
krassx Oct 14, 2026
ac048f6
BugseeSecure: review round 3 (first native animation, events, late co…
krassx Oct 14, 2026
91bb32e
BugseeSecure: mutation-tight tests for round 3
krassx Oct 14, 2026
9875201
BugseeSecure: kill the round-3 mutants left
krassx Oct 14, 2026
4eca51c
BugseeSecure: a native level inside any host scaled up is unbounded
krassx Oct 14, 2026
82d025a
BugseeSecure: follow attachNativeEvent, bound scroll offsets, cover pops
krassx Oct 14, 2026
35bc9f2
BugseeSecure: require React Native's Animated internals by literal paths
krassx Oct 14, 2026
4646fcb
BugseeSecure: mutation-tight round 4; docs to match
krassx Oct 14, 2026
6aabb70
BugseeSecure: cover a value JS moves in the same JS task
krassx Oct 14, 2026
df40dba
BugseeSecure: the last round-4 mutants
krassx Oct 14, 2026
2032617
BugseeSecure: mutation-tight synchronous wake
krassx Oct 14, 2026
ee753ca
BugseeSecure: mark re-watching every time as only work
krassx Oct 15, 2026
6853187
BugseeSecure: round 5 (R4-1, R4-2 and the P3s)
krassx Oct 15, 2026
d03b4e1
BugseeSecure: bound a running JS-driven transform over its path
krassx Oct 15, 2026
72afc28
BugseeSecure: mutation-tight round 5
krassx Oct 15, 2026
699aab5
BugseeSecure: wrap a test title
krassx Oct 15, 2026
0d3cecb
BugseeSecure: a native value beside a JS-driven one stays bounded (test)
krassx Oct 15, 2026
c55dc99
BugseeSecure: a view inside the content its ScrollView's offset drive…
krassx Oct 15, 2026
c402078
BugseeSecure: mutation-tight sticky and iOS reach tests
krassx Oct 15, 2026
1c4a606
BugseeSecure: the driving ScrollView anywhere above the view, not onl…
krassx Oct 15, 2026
6753385
BugseeSecure: the sticky rule fails closed where the scroll is not fo…
krassx Oct 15, 2026
aec7cb3
BugseeSecure: mutation-tight P3-A
krassx Oct 15, 2026
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
53 changes: 53 additions & 0 deletions examples/bare/e2e/secure-perf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,56 @@ describeDevice(`<BugseeSecure> cost of 200 still, clipped views on ${TARGET_NAME
expect(Number(results.get('many-frames')!.p95)).toBeLessThan(0.5);
});
});

/**
* Scenario `secure-perf-animated`: 52 `<BugseeSecure>` under `Animated`
* components, as in a react-native-screens app (every screen is one): 50
* rows (18 in Debug) in an `Animated.ScrollView` driving a native `Animated.event`, and 2
* in a native-driven collapsing header. Ticks and frames are timed idle
* (`animated`) and while the list is scrolled every 400 ms
* (`animated-scrolling`).
*/
describeDevice(`<BugseeSecure> cost under Animated components on ${TARGET_NAME}`, () => {
// Release times 52 views; Debug (React's dev build, several times slower) 20, the count of the other modes, so
// every mode keeps the one 2 ms budget in both builds.
const MODES_ANIMATED = ['animated', 'animated-frames', 'animated-scrolling', 'animated-scrolling-frames'];
let log: DeviceLog;
const results = new Map<string, Record<string, string>>();

beforeAll(async () => {
log = await startDeviceLog('#88 perf animated', '#88 perf animated');
const run = await startRun('secure-perf-animated');
const nonce = run.scenario.nonce;
for (const mode of MODES_ANIMATED) {
const done = new RegExp(`BUGSEE_E2E secure-perf (result mode=${mode}|threw) .*nonce=${escape(nonce)}`);
const line = must(await log.waitFor(done, 120_000, run.start), `the secure-perf ${mode} result`, run.start);
const result = Object.fromEntries(
[...line.text.matchAll(/(\w+)=(\S+)/g)].map(([, key, value]) => [key!, value!]),
);
results.set(mode, result);
report('secure-perf', { label: process.env.E2E_PERF_LABEL ?? 'unlabelled', dev: run.dev, ...result });
}
});

afterAll(async () => {
try {
await stopApp();
} finally {
stopDeviceLog(log);
resetScenario();
}
});

describe.each(MODES_ANIMATED)('%s', (mode) => {
it('timed 52 views in Release, 20 in Debug', () => {
const result = results.get(mode)!;
expect(result.secures).toBe(result.dev === 'true' ? '20' : '52');
// The frames too: the header's watched value keeps the frame loop running (2300-2400 frames a run).
expect(Number(result.ticks)).toBeGreaterThanOrEqual(MIN_TICKS);
});

it(`keeps p95 under ${P95_BUDGET_MS} ms`, () => {
expect(Number(results.get(mode)!.p95)).toBeLessThan(P95_BUDGET_MS);
});
});
});
166 changes: 166 additions & 0 deletions examples/bare/e2e/secure-transitions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* Follow-up to #87: `<BugseeSecure>` stays covered while native still draws
* it after or beside React.
*
* Scenario `secure-transitions` (scenarios/secure-transitions.tsx): a view
* unmounted in place; a view in a `<Modal animationType="slide">` opening and
* closing; a view under a native-driven `Animated` translateX. Markers after
* each change are lined up with native's own log of each publish, `BugseeRN
* secure published display=0 surface=<surface> raw=[...]`, sent only on a
* change, so the last one on a surface before a marker is what native held
* then. Android-only, like secure-visible.test.ts: iOS logs no such line.
*/
import {
ON_ANDROID,
type Run,
TARGET_NAME,
describeDevice,
escape,
must,
report,
startDeviceLog,
startRun,
stopApp,
stopDeviceLog,
} from './harness';
import { type DeviceLog, type LogLine, adb, resetScenario } from './scenario';

jest.setTimeout(4 * 60_000);

const describeAndroid = ON_ANDROID ? describeDevice : describe.skip;

const PUBLISHED = /BugseeRN\s*:\s*secure published display=0 surface=(\d+) raw=\[([^\]]*)\]/;

/** How far the scenario's native-driven animation moves the view, in points (its NATIVE_SHIFT). */
const NATIVE_SHIFT = 120;

/** The bottom edge of a slide band, as the registry flattens it: past any display. */
const BAND_BOTTOM_POINTS = 100_000;

interface Box {
x: number;
y: number;
w: number;
h: number;
}

interface Marker {
self: Box | null;
/** `native-moved`'s translateX, where the animation was stopped. */
value: number;
ratio: number;
line: LogLine;
}

function rects(flat: string): number[][] {
const values = flat.trim() === '' ? [] : flat.split(',').map(value => Number(value.trim()));
const out: number[][] = [];
for (let i = 0; i < values.length; i += 4) {
out.push(values.slice(i, i + 4));
}
return out;
}

/** As the registry flattens a rectangle: scaled, then rounded outwards. */
function pixels({ x, y, w, h }: Box, ratio: number): number[] {
return [Math.floor(x * ratio), Math.floor(y * ratio), Math.ceil((x + w) * ratio), Math.ceil((y + h) * ratio)];
}

const near = (a: number[], b: number[]): boolean => a.every((value, i) => Math.abs(value - b[i]!) <= 1);

describeAndroid(`<BugseeSecure> stays covered through native transitions, on ${TARGET_NAME}`, () => {
let log: DeviceLog;
let run: Run;
const markers = new Map<string, Marker>();

beforeAll(async () => {
log = await startDeviceLog('#87', 'secure transitions');
await adb('shell', 'setprop', 'log.tag.BugseeRN', 'DEBUG');
run = await startRun('secure-transitions');
const nonce = run.scenario.nonce;
report('banner', run.banner.text.trim());

const marker = new RegExp(
`BUGSEE_E2E secure-transitions phase=(\\S+)(?: value=(\\S+))? self=(\\S+) ratio=(\\S+) nonce=${escape(nonce)}`,
);
const finished = new RegExp(`BUGSEE_E2E secure-transitions (done|threw).* nonce=${escape(nonce)}`);
const end = must(await log.waitFor(finished, 90_000, run.start), 'the scenario finishing', run.start);
expect(end.text).toContain('secure-transitions done');
for (const line of log.all(marker, run.start)) {
const [, name, value, self, ratio] = marker.exec(line.text)!;
const [x, y, w, h] = self!.split(',').map(Number) as [number, number, number, number];
const box = self === 'none' ? null : { x, y, w, h };
markers.set(name!, { self: box, value: Number(value), ratio: Number(ratio), line });
}
});

afterAll(async () => {
try {
await stopApp();
} finally {
await adb('shell', 'setprop', 'log.tag.BugseeRN', "''").catch(() => {});
stopDeviceLog(log);
resetScenario();
}
});

const marker = (name: string): Marker => {
const found = markers.get(name);
if (found === undefined) {
throw new Error(`the scenario logged no phase ${name} (saw ${[...markers.keys()].join(', ')})`);
}
return found;
};

/** What native held on `surface` (any but the main one when `undefined`) at marker `name`. */
function heldAt(name: string, surface?: number): number[][] {
const before = log
.all(PUBLISHED, run.start, marker(name).line.index)
.map(line => PUBLISHED.exec(line.text)!)
.filter(([, on]) => (surface === undefined ? Number(on) !== 0 : Number(on) === surface));
const last = before[before.length - 1];
report(`held at ${name}`, last?.[0] ?? 'nothing');
return last === undefined ? [] : rects(last[2]!);
}

it('an unmounted view stays covered while native may still draw it, then is removed', () => {
const mounted = marker('mounted');
const box = pixels(mounted.self!, mounted.ratio);

expect(heldAt('mounted', 0).some(rect => near(rect, box))).toBe(true);
expect(heldAt('held', 0).some(rect => near(rect, box))).toBe(true);
expect(heldAt('gone', 0).some(rect => near(rect, box))).toBe(false);
});

it('a Modal sliding in and out covers the band the view slides along, then only the view, then nothing', () => {
const settled = marker('modal-settled');
const box = pixels(settled.self!, settled.ratio);
const isBand = (rect: number[]): boolean =>
rect[0]! <= box[0]! + 1 && rect[2]! >= box[2]! - 1 && rect[3]! >= BAND_BOTTOM_POINTS * settled.ratio - 1;

expect(heldAt('modal-opened').some(isBand)).toBe(true);
const atRest = heldAt('modal-settled');
expect(atRest.some(isBand)).toBe(false);
expect(atRest.some(rect => near(rect, box))).toBe(true);
expect(heldAt('modal-closed').some(isBand)).toBe(true);
expect(heldAt('modal-gone')).toEqual([]);
});

it('a view a native-driven loop moved is covered where it is drawn, not where it was measured', () => {
const rest = marker('native-rest');
const moved = marker('native-moved');
// Stopped part-way through a loop.
expect(moved.value).toBeGreaterThan(0);
expect(moved.value).toBeLessThan(NATIVE_SHIFT);
report('native-moved measured at', { rest: rest.self, moved: moved.self, value: moved.value });
const drawn = pixels({ ...rest.self!, x: rest.self!.x + moved.value }, moved.ratio);
// The mask joins where the view is drawn with where the shadow tree has it: it contains it.
const contains = (outer: number[], inner: number[]): boolean =>
outer[0]! <= inner[0]! + 1 &&
outer[1]! <= inner[1]! + 1 &&
outer[2]! >= inner[2]! - 1 &&
outer[3]! >= inner[3]! - 1;

expect(heldAt('native-moved', 0).some(rect => contains(rect, drawn))).toBe(true);
});
});
137 changes: 137 additions & 0 deletions examples/bare/scenarios/secure-transitions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* `<BugseeSecure>` stays covered through what native draws after or beside
* React (the follow-up to #87), asserted by e2e/secure-transitions.test.ts.
*
* secure-transitions three steps, each logging `secure-transitions
* phase=<phase>` markers the test lines native's
* publishes up against:
* - unmount: a `<BugseeSecure>` is unmounted (`unmounted`), and 250 ms
* later (`held`) and 1.5 s later (`gone`) the step logs again;
* - modal: a transparent `<Modal animationType="slide">` holding a
* `<BugseeSecure>` opens (`modal-opening`, then `modal-opened` 250 ms
* later and `modal-settled` 1.5 s later), and closes the same way
* (`modal-closing`, `modal-closed`, `modal-gone`);
* - native: a `<BugseeSecure>` under an `Animated.View` whose translateX
* a native-driven `Animated.loop` moves from 0 to 120 points again and
* again; stopped part-way (a looping animation's value is synced back
* to JS, not to the shadow tree), 600 ms later (`native-moved`) the
* step logs the view's window rectangle as React Native measures it
* and `value=` the translateX it was stopped at.
* Every marker carries `self=x,y,w,h` (window points, `none` if unmounted)
* and `ratio=` the pixel ratio.
*/
import { useEffect, useRef, useState } from 'react';
import { Animated, Modal, PixelRatio, StyleSheet, View } from 'react-native';
import { BugseeSecure } from '@bugsee/react-native';

export const SECURE_TRANSITIONS_SCENARIO = 'secure-transitions';

/** What the native-driven animation moves the view by, in points. */
export const NATIVE_SHIFT = 120;

function mark(message: string): void {
console.log(`BUGSEE_E2E ${message}`);
}

function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

type Measurable = { measureInWindow(cb: (x: number, y: number, w: number, h: number) => void): void };

function measured(view: Measurable | null): Promise<string> {
return new Promise(resolve => {
if (view === null) {
resolve('none');
return;
}
view.measureInWindow((x, y, w, h) => resolve(`${x},${y},${w},${h}`));
});
}

const BOX = { position: 'absolute', top: 200, left: 40, width: 200, height: 80 } as const;

export function SecureTransitionsProbe({ nonce }: { nonce: string }) {
const [plain, setPlain] = useState(true);
const [modal, setModal] = useState(false);
const [animated, setAnimated] = useState(false);
const shift = useRef(new Animated.Value(0)).current;
const twin = useRef<View>(null);
const modalTwin = useRef<View>(null);
const animatedTwin = useRef<View>(null);

useEffect(() => {
const log = async (phase: string, view: View | null) => {
const self = await measured(view);
mark(`secure-transitions phase=${phase} self=${self} ratio=${PixelRatio.get()} nonce=${nonce}`);
};
(async () => {
await sleep(1500);
await log('mounted', twin.current);
setPlain(false);
await log('unmounted', null);
await sleep(250);
await log('held', null);
await sleep(1250);
await log('gone', null);

setModal(true);
await log('modal-opening', null);
await sleep(250);
await log('modal-opened', modalTwin.current);
await sleep(1250);
await log('modal-settled', modalTwin.current);
setModal(false);
await log('modal-closing', null);
await sleep(250);
await log('modal-closed', null);
await sleep(1250);
await log('modal-gone', null);

setAnimated(true);
await sleep(1500);
await log('native-rest', animatedTwin.current);
const loop = Animated.loop(
Animated.timing(shift, { toValue: NATIVE_SHIFT, duration: 700, useNativeDriver: true }),
);
loop.start();
await sleep(1000);
// Stopped as an app stops a loop; native sends the value back to JS once it has stopped.
loop.stop();
await sleep(600);
const value = (shift as unknown as { __getValue(): number }).__getValue();
await log(`native-moved value=${value}`, animatedTwin.current);
mark(`secure-transitions done nonce=${nonce}`);
})().catch((error: unknown) => {
mark(`secure-transitions threw ${String(error)} nonce=${nonce}`);
});
}, [nonce, shift]);

return (
<View style={styles.stage}>
{plain && (
<>
<BugseeSecure style={BOX} />
<View ref={twin} collapsable={false} pointerEvents="none" style={BOX} />
</>
)}
<Modal visible={modal} transparent animationType="slide" onRequestClose={() => setModal(false)}>
<View style={styles.fill}>
<BugseeSecure style={BOX} />
<View ref={modalTwin} collapsable={false} pointerEvents="none" style={BOX} />
</View>
</Modal>
{animated && (
<Animated.View style={[StyleSheet.absoluteFill, { transform: [{ translateX: shift }] }]}>
<BugseeSecure style={BOX} />
<View ref={animatedTwin} collapsable={false} pointerEvents="none" style={BOX} />
</Animated.View>
)}
</View>
);
}

const styles = StyleSheet.create({
stage: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: '#FFFFFF' },
fill: { flex: 1 },
});
Loading
Loading