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