@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/README.md +11 -1
- package/dist/capture.d.ts +12 -0
- package/dist/core.d.ts +51 -1
- package/dist/core.js +142 -5
- package/dist/index.d.ts +30 -11
- package/dist/index.js +199 -113
- package/dist/tap-label.d.ts +6 -0
- package/dist/tap-label.js +73 -0
- package/dist/touches.d.ts +15 -0
- package/dist/touches.js +35 -0
- package/dist/trigger.d.ts +55 -0
- package/dist/trigger.js +297 -0
- package/package.json +8 -2
- package/src/capture.ts +6 -0
- package/src/core.ts +136 -5
- package/src/index.tsx +205 -125
- package/src/tap-label.ts +69 -0
- package/src/touches.tsx +37 -0
- package/src/trigger.tsx +364 -0
package/src/index.tsx
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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 = {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
134
|
+
if (utils && handler) utils.setGlobalHandler((error, fatal) => {
|
|
75
135
|
captureError(error, fatal ? 'fatal' : 'uncaught');
|
|
76
|
-
handler
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
});
|
package/src/tap-label.ts
ADDED
|
@@ -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
|
+
}
|
package/src/touches.tsx
ADDED
|
@@ -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 } });
|