@bugsession/react-native 0.1.0-beta.3 → 0.1.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.tsx CHANGED
@@ -1,9 +1,12 @@
1
1
  import React from 'react';
2
- import { Animated, PanResponder, Pressable, StyleSheet, Text, View, useWindowDimensions, type PressableProps, type StyleProp, type ViewStyle, Platform } from 'react-native';
3
- import { Collector, instrumentFetch, safeText, safeUrl, SessionClock } from './core';
2
+ import { Dimensions, Pressable, StyleSheet, Text, View, type PressableProps, Platform } from 'react-native';
3
+ import { createTrigger, type IndicatorPhase } from './trigger';
4
+ export type { Annotation, BugSessionTriggerProps, IndicatorPhase } from './trigger';
5
+ import { Collector, ignoreRequests, instrumentFetch, instrumentXhr, safeText, safeUrl, SessionClock, Telemetry, type Evidence } from './core';
6
+ import { createCapture } from './touches';
4
7
  import { alignNativeRecording, type NativeCaptureAdapter } from './capture';
5
8
  export type { NativeCaptureAdapter } from './capture';
6
- export { Collector, SessionClock, safeText, safeUrl } from './core';
9
+ export { Collector, SessionClock, Telemetry, safeText, safeUrl } from './core';
7
10
 
8
11
  export type AudioAdapter = {
9
12
  prepare(): Promise<void>;
@@ -11,31 +14,81 @@ export type AudioAdapter = {
11
14
  stop(): Promise<string | null>;
12
15
  upload(url: string, uri: string, headers?: Record<string, string>): Promise<void>;
13
16
  };
14
- export type BugSessionOptions = { recorderUrl: string; recorderToken?: string; audio?: AudioAdapter; capture?: NativeCaptureAdapter; device: Record<string, unknown>; application: Record<string, unknown> };
15
- export type BugSessionTriggerProps = {
16
- position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
17
- offset?: number;
18
- draggable?: boolean;
19
- style?: StyleProp<ViewStyle>;
20
- accessibilityLabel?: string;
21
- children?: React.ReactNode;
22
- onError?: (error: unknown) => void;
17
+ export type BugSessionOptions = {
18
+ recorderUrl: string;
19
+ recorderToken?: string;
20
+ audio?: AudioAdapter;
21
+ capture?: NativeCaptureAdapter;
22
+ device: Record<string, unknown>;
23
+ application: Record<string, unknown>;
24
+ /**
25
+ * Seconds of app events (logs, errors, network metadata, actions, navigation; never video or audio)
26
+ * kept in memory before a recording starts and attached to it. Defaults to 60, max 300; 0 disables.
27
+ */
28
+ preRecordSeconds?: number;
29
+ /**
30
+ * Seconds of app events still collected after the tester stops, while the video uploads,
31
+ * so a request or error that lands just after Stop is kept. Defaults to 5, max 30; 0 disables.
32
+ */
33
+ postRecordSeconds?: number;
23
34
  };
35
+ export type BugSessionErrorBoundaryProps = { error: Error; retry: () => void | Promise<void> };
24
36
  type ErrorUtilsType = { getGlobalHandler(): (e: Error, fatal?: boolean) => void; setGlobalHandler(handler: (e: Error, fatal?: boolean) => void): void };
25
- const globals = globalThis as typeof globalThis & { ErrorUtils?: ErrorUtilsType };
37
+ type RejectionTracker = { allRejections: boolean; onUnhandled(id: number, rejection: unknown): void; onHandled(id: number): void };
38
+ const globals = globalThis as typeof globalThis & {
39
+ ErrorUtils?: ErrorUtilsType;
40
+ HermesInternal?: { enablePromiseRejectionTracker?(options: RejectionTracker): void };
41
+ };
26
42
  let options: BugSessionOptions;
27
43
  let collector: Collector | null = null;
44
+ const telemetry = new Telemetry();
45
+ let installed = false;
46
+ let preRecordEvents: Evidence[] = [];
47
+ const MAX_POST_RECORD_SECONDS = 30;
28
48
  let session: { id: string; videoStartMs: number; audioStartMs: number; uncertaintyMs: number; captureBackend: 'adb' | 'replaykit' | 'mediaprojection'; nativeOffsetMs?: number; nativeUncertaintyMs?: number } | null = null;
29
49
  let state: 'idle' | 'starting' | 'recording' | 'stopping' = 'idle';
30
- let restore: (() => void) | null = null;
31
50
  let currentScreen = 'Home';
32
51
  let rawFetch: typeof fetch;
33
- let triggerPosition: { left: number; top: number } | null = null;
34
- const triggerSize = 58;
35
- function clampTriggerPosition(point: { left: number; top: number }, width: number, height: number) {
52
+ // Status shown by the trigger's indicator; also reflects start()/stop() called directly by the app.
53
+ let phase: IndicatorPhase = { kind: 'idle' };
54
+ let phaseTimer: ReturnType<typeof setTimeout> | undefined;
55
+ const phaseListeners = new Set<() => void>();
56
+ function setPhase(next: IndicatorPhase) {
57
+ phase = next;
58
+ clearTimeout(phaseTimer);
59
+ if (next.kind === 'sent' || next.kind === 'failed') phaseTimer = setTimeout(() => setPhase({ kind: 'idle' }), next.kind === 'sent' ? 3000 : 6000);
60
+ phaseListeners.forEach(listener => listener());
61
+ }
62
+ function compact(values: Record<string, unknown>) {
63
+ return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== null && value !== ''));
64
+ }
65
+ /**
66
+ * App and device facts for the recording's metadata. Native values (bundle id, hardware
67
+ * model) come from the capture module when available; values passed to configure() win.
68
+ */
69
+ async function recordingContext() {
70
+ const constants = (Platform.constants ?? {}) as Record<string, unknown>;
71
+ const rn = constants.reactNativeVersion as { major: number; minor: number; patch: number } | undefined;
72
+ const screen = Dimensions.get('screen');
73
+ let locale: string | undefined;
74
+ let timeZone: string | undefined;
75
+ try { ({ locale, timeZone } = Intl.DateTimeFormat().resolvedOptions()); } catch { /* Intl unavailable */ }
76
+ const device: Record<string, unknown> = {
77
+ platform: Platform.OS,
78
+ osVersion: Platform.OS === 'android' ? constants.Release : String(Platform.Version),
79
+ screen: { width: Math.round(screen.width), height: Math.round(screen.height), scale: screen.scale },
80
+ locale, timeZone,
81
+ ...(Platform.OS === 'android' ? { model: constants.Model, manufacturer: constants.Manufacturer } : { idiom: constants.interfaceIdiom }),
82
+ };
83
+ const application: Record<string, unknown> = {
84
+ reactNative: rn ? `${rn.major}.${rn.minor}.${rn.patch}` : undefined,
85
+ jsEngine: globals.HermesInternal ? 'hermes' : 'jsc',
86
+ buildType: __DEV__ ? 'development' : 'release',
87
+ };
88
+ const native = await options.capture?.appContext?.().catch(() => null) ?? null;
36
89
  return {
37
- left: Math.max(0, Math.min(point.left, Math.max(0, width - triggerSize))),
38
- top: Math.max(0, Math.min(point.top, Math.max(0, height - triggerSize))),
90
+ device: compact({ ...device, ...native?.device, ...options.device }),
91
+ application: compact({ ...application, ...native?.application, ...options.application }),
39
92
  };
40
93
  }
41
94
  function authHeaders(): Record<string, string> {
@@ -58,38 +111,76 @@ async function request(path: string, data?: unknown) {
58
111
  if (!response.ok) throw new Error(`Recorder ${response.status}: ${await response.text()}`);
59
112
  return response.json();
60
113
  }
61
- function install(c: Collector) {
62
- const originalFetch = globalThis.fetch;
63
- const wrappedFetch = instrumentFetch(rawFetch, c);
64
- globalThis.fetch = wrappedFetch;
65
- const originals = { log: console.log, warn: console.warn, error: console.error };
66
- for (const level of ['log', 'warn', 'error'] as const) {
114
+ // Installed once at configure() for the app's lifetime, so events from before a
115
+ // recording starts can be attached to it (see Telemetry).
116
+ function install() {
117
+ installed = true;
118
+ // The recorder itself, and in development Metro's own symbolication and log requests.
119
+ ignoreRequests(url => (!!options && url.startsWith(options.recorderUrl)) || (__DEV__ && /\/(symbolicate|logs)(\?|$)/.test(url)));
120
+ globalThis.fetch = instrumentFetch(rawFetch, telemetry);
121
+ instrumentXhr((globalThis as { XMLHttpRequest?: Parameters<typeof instrumentXhr>[0] }).XMLHttpRequest, telemetry);
122
+ for (const level of ['log', 'info', 'debug', 'warn', 'error'] as const) {
123
+ const original = console[level];
67
124
  console[level] = (...args: unknown[]) => {
68
- c.add(level === 'error' ? 'error' : 'log', { level, message: args.map(safeText).join(' '), source: 'console', stack: args.find(a => a instanceof Error)?.stack ? safeText((args.find(a => a instanceof Error) as Error).stack) : undefined });
69
- originals[level](...args);
125
+ if (telemetry.active && !(level === 'error' && repeatsCapturedError(args))) {
126
+ const error = args.find((a): a is Error => a instanceof Error);
127
+ telemetry.add(level === 'error' ? 'error' : 'log', { level, message: args.map(safeText).join(' '), source: 'console', stack: error?.stack ? safeText(error.stack) : undefined, screen: currentScreen });
128
+ }
129
+ original(...args);
70
130
  };
71
131
  }
72
132
  const utils = globals.ErrorUtils;
73
133
  const handler = utils?.getGlobalHandler();
74
- const onError = (error: Error, fatal?: boolean) => {
134
+ if (utils && handler) utils.setGlobalHandler((error, fatal) => {
75
135
  captureError(error, fatal ? 'fatal' : 'uncaught');
76
- handler?.(error, fatal);
77
- };
78
- if (utils && handler) utils.setGlobalHandler(onError);
79
- return () => {
80
- if (globalThis.fetch === wrappedFetch) globalThis.fetch = originalFetch;
81
- for (const level of ['log', 'warn', 'error'] as const) console[level] = originals[level];
82
- if (utils?.getGlobalHandler() === onError && handler) utils.setGlobalHandler(handler);
83
- };
136
+ handler(error, fatal);
137
+ });
138
+ // React Native only tracks unhandled rejections in development, and reports them
139
+ // through ExceptionsManager rather than the global handler. Track them in every
140
+ // build, and keep React Native's development behavior (LogBox) by forwarding.
141
+ globals.HermesInternal?.enablePromiseRejectionTracker?.({
142
+ allRejections: true,
143
+ onUnhandled: (id, rejection) => {
144
+ captureError(rejection instanceof Error ? rejection : new Error(rejectionMessage(rejection)), 'unhandled-rejection');
145
+ if (__DEV__) handler?.(new Error(`Uncaught (in promise, id: ${id}): "${rejectionMessage(rejection)}"`), false);
146
+ },
147
+ onHandled: id => {
148
+ if (__DEV__) console.warn(`Promise rejection handled (id: ${id})`);
149
+ },
150
+ });
151
+ }
152
+ function rejectionMessage(rejection: unknown) {
153
+ if (rejection instanceof Error) return rejection.toString();
154
+ if (typeof rejection === 'string') return rejection;
155
+ try { return JSON.stringify(rejection) ?? String(rejection); } catch { return String(rejection); }
156
+ }
157
+ // React Native also prints captured errors (such as unhandled rejections in development)
158
+ // through console.error; those repeats are skipped for a moment after capture.
159
+ const recentErrors: { message: string; at: number }[] = [];
160
+ function repeatsCapturedError(args: unknown[]) {
161
+ const now = Date.now();
162
+ while (recentErrors.length && now - recentErrors[0].at > 2000) recentErrors.shift();
163
+ if (!recentErrors.length) return false;
164
+ const printed = args.map(a => a instanceof Error ? a.message : typeof a === 'string' ? a : '').join(' ');
165
+ return recentErrors.some(e => e.message.length > 8 && printed.includes(e.message));
84
166
  }
85
167
  function captureError(error: unknown, source = 'boundary') {
86
- collector?.add('error', { source, message: safeText(error), stack: error instanceof Error ? safeText(error.stack ?? '') : undefined, screen: currentScreen });
168
+ if (!telemetry.active) return;
169
+ const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '';
170
+ if (message && repeatsCapturedError([message])) return;
171
+ if (message) recentErrors.push({ message: message.slice(0, 200), at: Date.now() });
172
+ telemetry.add('error', { source, message: safeText(error), stack: error instanceof Error ? safeText(error.stack ?? '') : undefined, screen: currentScreen });
87
173
  }
88
174
 
89
175
  export const BugSession = {
90
176
  configure(config: BugSessionOptions) {
91
177
  if (state !== 'idle') throw new Error('Cannot configure an active BugSession');
92
178
  options = { ...config, recorderUrl: config.recorderUrl.replace(/\/$/, '') };
179
+ telemetry.configure((config.preRecordSeconds ?? 60) * 1000);
180
+ if (!installed) {
181
+ rawFetch = globalThis.fetch.bind(globalThis);
182
+ install();
183
+ }
93
184
  },
94
185
  get status() { return state; },
95
186
  async start() {
@@ -99,7 +190,7 @@ export const BugSession = {
99
190
  if (Platform.OS === 'android' && !options.audio) throw new Error('Android needs a microphone adapter');
100
191
  if (state !== 'idle') throw new Error(`BugSession is ${state}`);
101
192
  state = 'starting';
102
- rawFetch = globalThis.fetch.bind(globalThis);
193
+ setPhase({ kind: 'starting' });
103
194
  let createdId: string | null = null;
104
195
  let audioStarted = false;
105
196
  const isIOS = Platform.OS === 'ios';
@@ -112,7 +203,8 @@ export const BugSession = {
112
203
  const best = await calibrate(clock, async () => (await request('/clock')).monoMs);
113
204
  collector = new Collector(clock);
114
205
  const captureBackend = isIOS ? 'replaykit' : useNativeCapture ? 'mediaprojection' : 'adb';
115
- const result = await request('/sessions/start', { device: options.device, application: options.application, captureBackend, startedAt: new Date(Date.now() - clock.now()).toISOString() });
206
+ const context = await recordingContext();
207
+ const result = await request('/sessions/start', { device: context.device, application: context.application, captureBackend, startedAt: new Date(Date.now() - clock.now()).toISOString() });
116
208
  createdId = result.id;
117
209
  const nativeStart = useNativeCapture ? await options.capture!.start() : undefined;
118
210
  const audioStartMs = clock.now();
@@ -126,24 +218,42 @@ export const BugSession = {
126
218
  uncertaintyMs: best.rtt / 2, captureBackend,
127
219
  ...(nativeClock ? { nativeOffsetMs: nativeClock.offset, nativeUncertaintyMs: nativeClock.rtt / 2 } : {}),
128
220
  };
129
- restore = install(collector);
221
+ preRecordEvents = telemetry.begin(collector);
130
222
  state = 'recording';
223
+ setPhase({ kind: 'recording', startedAt: Date.now() });
131
224
  collector.add('navigation', { from: null, to: currentScreen });
132
225
  return session.id;
133
226
  } catch (error) {
134
227
  if (useNativeCapture) await options.capture?.cancel().catch(() => {});
135
228
  if (audioStarted) await options.audio!.stop().catch(() => null);
136
229
  if (createdId) await request(`/sessions/${createdId}/abort`, {}).catch(() => {});
137
- collector = null; session = null; state = 'idle'; throw error;
230
+ telemetry.end(); preRecordEvents = [];
231
+ collector = null; session = null; state = 'idle';
232
+ setPhase({ kind: 'failed', message: safeText(error) });
233
+ throw error;
138
234
  }
139
235
  },
140
236
  async stop() {
141
237
  if (state !== 'recording' || !session || !collector) throw new Error('No recording is active');
142
238
  state = 'stopping';
239
+ setPhase({ kind: 'uploading' });
143
240
  const c = collector;
144
241
  const active = session;
145
242
  c.add('session-stop', {});
146
- restore?.(); restore = null;
243
+ const stoppedAtMs = c.clock.now();
244
+ const postRecordMs = Math.max(0, Math.min(options.postRecordSeconds ?? 5, MAX_POST_RECORD_SECONDS)) * 1000;
245
+ // Sent separately from `events` so recorders that predate pre/post-record support ignore them.
246
+ const before = preRecordEvents;
247
+ preRecordEvents = [];
248
+ // App events keep flowing to this session while media uploads; anything after Stop is
249
+ // reported separately as post-recording evidence.
250
+ const split = () => {
251
+ telemetry.end();
252
+ return {
253
+ events: c.events.filter(e => e.timestampMs <= stoppedAtMs),
254
+ after: c.events.filter(e => e.timestampMs > stoppedAtMs && e.timestampMs <= stoppedAtMs + postRecordMs).slice(0, 1000).map(e => ({ ...e, postRecording: true })),
255
+ };
256
+ };
147
257
  try {
148
258
  let media: Record<string, unknown>;
149
259
  if (active.captureBackend === 'replaykit') {
@@ -169,25 +279,67 @@ export const BugSession = {
169
279
  await options.audio!.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
170
280
  media = { ...active, audioEndMs };
171
281
  }
172
- return await request(`/sessions/${active.id}/stop`, {
173
- durationMs: c.clock.now(), events: c.events, droppedEvents: c.dropped,
282
+ const remainingMs = stoppedAtMs + postRecordMs - c.clock.now();
283
+ if (remainingMs > 0) await new Promise(resolve => setTimeout(resolve, remainingMs));
284
+ const { events, after } = split();
285
+ const result = await request(`/sessions/${active.id}/stop`, {
286
+ durationMs: stoppedAtMs, events, droppedEvents: c.dropped, preRecordEvents: before,
287
+ ...(after.length ? { postRecordEvents: after } : {}),
174
288
  media,
175
289
  });
290
+ setPhase({ kind: 'sent' });
291
+ return result;
176
292
  } catch (error) {
177
293
  if (active.captureBackend !== 'adb') await options.capture!.cancel().catch(() => {});
178
294
  // Release the device recorder, but preserve partial files for recovery.
179
- await request(`/sessions/${active.id}/abort`, { reason: safeText(error), events: c.events, media: active, durationMs: c.clock.now() }).catch(() => {});
295
+ const { events, after } = split();
296
+ await request(`/sessions/${active.id}/abort`, { reason: safeText(error), events, preRecordEvents: before, ...(after.length ? { postRecordEvents: after } : {}), media: active, durationMs: stoppedAtMs }).catch(() => {});
297
+ setPhase({ kind: 'failed', message: safeText(error) });
180
298
  throw error;
181
- } finally { collector = null; session = null; state = 'idle'; }
299
+ } finally { telemetry.end(); collector = null; session = null; state = 'idle'; }
182
300
  },
183
301
  trackAction(action: { name: string; screen?: string }) {
184
- collector?.add('action', { target: safeText(action.name), screen: safeText(action.screen ?? currentScreen) });
302
+ if (telemetry.active) telemetry.add('action', { target: safeText(action.name), screen: safeText(action.screen ?? currentScreen) });
185
303
  },
186
304
  trackNavigation(screen: string) {
187
- if (currentScreen !== screen) collector?.add('navigation', { from: currentScreen, to: screen });
305
+ if (currentScreen !== screen && telemetry.active) telemetry.add('navigation', { from: currentScreen, to: screen });
188
306
  currentScreen = screen;
189
307
  },
190
308
  captureError,
309
+ /**
310
+ * Wrap the app once (inside the navigator's container, around your screens) to record
311
+ * every tap automatically: position, current screen and the element's label when available.
312
+ */
313
+ Capture: createCapture(() => telemetry.active ? tap => {
314
+ telemetry.add('action', { target: safeText(tap.label ?? `Tap at (${tap.x}, ${tap.y})`), screen: currentScreen, x: tap.x, y: tap.y, auto: true });
315
+ } : null),
316
+ Trigger: createTrigger({
317
+ getPhase: () => phase,
318
+ subscribe(listener) { phaseListeners.add(listener); return () => { phaseListeners.delete(listener); }; },
319
+ async toggle() {
320
+ if (state === 'recording') await BugSession.stop();
321
+ else if (state === 'idle') await BugSession.start();
322
+ },
323
+ fail(error) { if (phase.kind !== 'failed') setPhase({ kind: 'failed', message: safeText(error) }); },
324
+ onAudioLevel: listener => options?.capture?.onAudioLevel?.(listener) ?? null,
325
+ safeAreaInsets: async () => options?.capture?.safeAreaInsets ? options.capture.safeAreaInsets() : null,
326
+ annotate(annotation) { if (state === 'recording') telemetry.add('annotation', { ...annotation, screen: currentScreen }); },
327
+ }),
328
+ /**
329
+ * Expo Router error boundary that reports render errors (which never reach the
330
+ * global error handler). In a route or layout file:
331
+ * `export const ErrorBoundary = BugSession.ErrorBoundary;`
332
+ */
333
+ ErrorBoundary({ error, retry }: BugSessionErrorBoundaryProps) {
334
+ React.useEffect(() => { captureError(error, 'render'); }, [error]);
335
+ return <View style={styles.boundary}>
336
+ <Text style={styles.boundaryTitle}>Something went wrong</Text>
337
+ <Text style={styles.boundaryMessage}>{error.message}</Text>
338
+ <Pressable accessibilityRole="button" onPress={() => { void retry(); }} style={styles.boundaryRetry}>
339
+ <Text style={styles.boundaryRetryText}>Try again</Text>
340
+ </Pressable>
341
+ </View>;
342
+ },
191
343
  /** Use with NavigationContainer.onStateChange for non-Expo React Navigation apps. */
192
344
  navigationListener(ref: { getCurrentRoute(): { name: string } | undefined }) {
193
345
  return () => { const route = ref.getCurrentRoute(); if (route) BugSession.trackNavigation(route.name); };
@@ -195,84 +347,12 @@ export const BugSession = {
195
347
  Touchable({ name, screen, onPress, ...props }: PressableProps & { name: string; screen?: string }) {
196
348
  return <Pressable {...props} onPress={e => { BugSession.trackAction({ name, screen }); onPress?.(e); }} />;
197
349
  },
198
- Trigger({ position = 'bottom-right', offset = 24, draggable = true, style, accessibilityLabel, children, onError }: BugSessionTriggerProps) {
199
- const { width, height } = useWindowDimensions();
200
- const [point, setPoint] = React.useState(() => triggerPosition
201
- ? clampTriggerPosition(triggerPosition, width, height)
202
- : clampTriggerPosition({
203
- left: position.includes('right') ? width - triggerSize - offset : offset,
204
- top: position.includes('bottom') ? height - triggerSize - offset : offset,
205
- }, width, height));
206
- const pointRef = React.useRef(point);
207
- pointRef.current = point;
208
- const drag = React.useRef<Animated.ValueXY | null>(null);
209
- if (!drag.current) drag.current = new Animated.ValueXY();
210
- const dragStart = React.useRef(point);
211
- React.useEffect(() => {
212
- const next = clampTriggerPosition(pointRef.current, width, height);
213
- pointRef.current = next;
214
- setPoint(next);
215
- if (triggerPosition) triggerPosition = next;
216
- drag.current?.setValue({ x: 0, y: 0 });
217
- }, [width, height]);
218
- const responder = React.useMemo(() => PanResponder.create({
219
- onStartShouldSetPanResponder: () => false,
220
- onMoveShouldSetPanResponder: (_, gesture) => draggable && Math.hypot(gesture.dx, gesture.dy) > 7,
221
- onPanResponderGrant: () => { dragStart.current = pointRef.current; },
222
- onPanResponderMove: (_, gesture) => {
223
- const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
224
- drag.current?.setValue({ x: next.left - dragStart.current.left, y: next.top - dragStart.current.top });
225
- },
226
- onPanResponderRelease: (_, gesture) => {
227
- const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
228
- triggerPosition = next;
229
- pointRef.current = next;
230
- setPoint(next);
231
- drag.current?.setValue({ x: 0, y: 0 });
232
- },
233
- onPanResponderTerminate: (_, gesture) => {
234
- const next = clampTriggerPosition({ left: dragStart.current.left + gesture.dx, top: dragStart.current.top + gesture.dy }, width, height);
235
- triggerPosition = next;
236
- pointRef.current = next;
237
- setPoint(next);
238
- drag.current?.setValue({ x: 0, y: 0 });
239
- },
240
- }), [draggable, width, height]);
241
- const [busy, setBusy] = React.useState(false);
242
- const recording = state === 'recording';
243
- const toggle = async () => {
244
- if (busy) return;
245
- setBusy(true);
246
- try {
247
- if (state === 'recording') await BugSession.stop();
248
- else await BugSession.start();
249
- } catch (error) { onError?.(error); }
250
- finally { setBusy(false); }
251
- };
252
- return <View pointerEvents="box-none" style={styles.triggerLayer}>
253
- <Animated.View {...(draggable ? responder.panHandlers : {})} style={[styles.triggerPosition, { left: point.left, top: point.top, transform: drag.current?.getTranslateTransform() }]}>
254
- <Pressable
255
- accessibilityRole="button"
256
- accessibilityLabel={accessibilityLabel ?? (recording ? 'Stop BugSession recording' : 'Start BugSession recording')}
257
- accessibilityHint={draggable ? 'Drag to move the BugSession button' : undefined}
258
- accessibilityState={{ busy, selected: recording }}
259
- disabled={busy}
260
- onPress={() => { void toggle(); }}
261
- style={[styles.trigger, recording && styles.triggerRecording, style]}
262
- >
263
- {children ?? <><Text style={styles.triggerMark}>b</Text><View style={[styles.triggerDot, recording && styles.triggerDotRecording]} /></>}
264
- </Pressable>
265
- </Animated.View>
266
- </View>;
267
- },
268
350
  };
269
351
 
270
352
  const styles = StyleSheet.create({
271
- triggerLayer: { ...StyleSheet.absoluteFill, zIndex: 9999 },
272
- triggerPosition: { position: 'absolute' },
273
- trigger: { width: 58, height: 58, borderRadius: 29, backgroundColor: '#173B34', alignItems: 'center', justifyContent: 'center', elevation: 8, shadowColor: '#000', shadowOpacity: 0.24, shadowRadius: 8, shadowOffset: { width: 0, height: 4 } },
274
- triggerRecording: { backgroundColor: '#B9382F' },
275
- triggerMark: { color: '#F4F4EC', fontSize: 31, fontWeight: '800', lineHeight: 38 },
276
- triggerDot: { position: 'absolute', right: 9, bottom: 9, width: 10, height: 10, borderRadius: 5, backgroundColor: '#D8F36A', borderWidth: 1, borderColor: '#173B34' },
277
- triggerDotRecording: { backgroundColor: '#FFF', borderColor: '#B9382F' },
353
+ boundary: { flex: 1, justifyContent: 'center', padding: 24, gap: 12, backgroundColor: '#FFFFFF' },
354
+ boundaryTitle: { fontSize: 22, fontWeight: '700', color: '#111111' },
355
+ boundaryMessage: { fontSize: 15, color: '#555555' },
356
+ boundaryRetry: { alignSelf: 'flex-start', paddingVertical: 10, paddingHorizontal: 16, borderRadius: 8, backgroundColor: '#173B34' },
357
+ boundaryRetryText: { color: '#F4F4EC', fontWeight: '700' },
278
358
  });
@@ -0,0 +1,69 @@
1
+ /** Pure helpers for automatic tap capture; no React Native imports so they can be unit tested. */
2
+ type Fiber = { memoizedProps?: Record<string, unknown> | null; return?: Fiber | null; child?: Fiber | null; sibling?: Fiber | null };
3
+
4
+ const LABEL_DEPTH = 12;
5
+ const PRESSABLE_DEPTH = 4;
6
+ const DESCENDANT_BUDGET = 40;
7
+
8
+ function text(value: unknown): string | undefined {
9
+ if (typeof value === 'string' || typeof value === 'number') return String(value).trim() || undefined;
10
+ if (Array.isArray(value)) {
11
+ const joined = value.map(text).filter(Boolean).join(' ');
12
+ return joined || undefined;
13
+ }
14
+ return undefined;
15
+ }
16
+
17
+ /**
18
+ * Best-effort name for the touched element: walks up from the touched host component
19
+ * reading accessibility labels, test IDs, titles and visible text. Never reads input
20
+ * values. React internals differ between renderers, so every failure yields undefined.
21
+ */
22
+ export function tapLabel(event: unknown): string | undefined {
23
+ try {
24
+ const native = event as { _targetInst?: Fiber; target?: { __internalInstanceHandle?: Fiber } } | null;
25
+ const target = native?._targetInst ?? native?.target?.__internalInstanceHandle;
26
+ let fiber: Fiber | null | undefined = target;
27
+ for (let depth = 0; fiber && depth < LABEL_DEPTH; depth++, fiber = fiber.return) {
28
+ const label = ownLabel(fiber.memoizedProps);
29
+ if (label) return label.slice(0, 80);
30
+ // A tap on a button's padding targets the button itself; its text is inside it.
31
+ if (depth < PRESSABLE_DEPTH && pressable(fiber.memoizedProps)) {
32
+ const inner = descendantText(fiber.child);
33
+ if (inner) return inner.slice(0, 80);
34
+ }
35
+ }
36
+ } catch { /* renderer internals unavailable */ }
37
+ return undefined;
38
+ }
39
+
40
+ function ownLabel(props: Record<string, unknown> | null | undefined): string | undefined {
41
+ if (!props || typeof props !== 'object') return undefined;
42
+ return text(props.accessibilityLabel) ?? text(props['aria-label']) ?? text(props.title)
43
+ ?? (typeof props.placeholder === 'string' ? props.placeholder : undefined)
44
+ ?? (props.editable === undefined && props.value === undefined ? text(props.children) : undefined)
45
+ ?? text(props.testID);
46
+ }
47
+
48
+ function pressable(props: Record<string, unknown> | null | undefined) {
49
+ if (!props || typeof props !== 'object') return false;
50
+ const role = props.accessibilityRole ?? props.role;
51
+ return typeof props.onPress === 'function' || typeof props.onClick === 'function' || role === 'button' || role === 'link'
52
+ || typeof props.onResponderRelease === 'function';
53
+ }
54
+
55
+ /** First readable text inside a pressable, breadth-first and bounded; never input values. */
56
+ function descendantText(first: Fiber | null | undefined): string | undefined {
57
+ const queue: Fiber[] = [];
58
+ for (let f = first; f; f = f.sibling) queue.push(f);
59
+ for (let visited = 0; queue.length && visited < DESCENDANT_BUDGET; visited++) {
60
+ const fiber = queue.shift()!;
61
+ const props = fiber.memoizedProps;
62
+ if (props && typeof props === 'object' && props.editable === undefined && props.value === undefined) {
63
+ const label = text(props.accessibilityLabel) ?? text(props.children);
64
+ if (label) return label;
65
+ }
66
+ for (let f = fiber.child; f; f = f.sibling) queue.push(f);
67
+ }
68
+ return undefined;
69
+ }
@@ -0,0 +1,37 @@
1
+ import React, { useRef } from 'react';
2
+ import { StyleSheet, View, type ViewProps } from 'react-native';
3
+ import { tapLabel } from './tap-label';
4
+
5
+ export type TapSink = (tap: { label?: string; x: number; y: number }) => void;
6
+
7
+ const TAP_MAX_MOVE = 10;
8
+ const TAP_MAX_MS = 800;
9
+ /**
10
+ * Wraps the app once and records every tap (position, screen and the element's label
11
+ * when one can be read), so testers' steps appear in the timeline without per-button code.
12
+ * It only observes touches; it never becomes the responder or blocks gestures.
13
+ */
14
+ export function createCapture(sink: () => TapSink | null) {
15
+ return function Capture({ children, style, ...props }: ViewProps & { children?: React.ReactNode }) {
16
+ const start = useRef<{ x: number; y: number; at: number } | null>(null);
17
+ return <View {...props} style={[styles.fill, style]}
18
+ onTouchStartCapture={e => {
19
+ props.onTouchStartCapture?.(e);
20
+ start.current = e.nativeEvent.touches.length > 1 ? null : { x: e.nativeEvent.pageX, y: e.nativeEvent.pageY, at: Date.now() };
21
+ }}
22
+ onTouchEndCapture={e => {
23
+ props.onTouchEndCapture?.(e);
24
+ const began = start.current;
25
+ start.current = null;
26
+ const add = sink();
27
+ if (!began || !add) return;
28
+ const { pageX, pageY } = e.nativeEvent;
29
+ if (Math.hypot(pageX - began.x, pageY - began.y) > TAP_MAX_MOVE || Date.now() - began.at > TAP_MAX_MS) return;
30
+ add({ label: tapLabel(e), x: Math.round(pageX), y: Math.round(pageY) });
31
+ }}>
32
+ {children}
33
+ </View>;
34
+ };
35
+ }
36
+
37
+ const styles = StyleSheet.create({ fill: { flex: 1 } });