@bugsession/react-native 0.1.0-beta.4 → 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 +5 -1
- package/dist/core.d.ts +18 -0
- package/dist/core.js +77 -3
- package/dist/index.d.ts +12 -0
- package/dist/index.js +51 -5
- 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/package.json +1 -1
- package/src/core.ts +70 -3
- package/src/index.tsx +52 -7
- package/src/tap-label.ts +69 -0
- package/src/touches.tsx +37 -0
package/README.md
CHANGED
|
@@ -58,7 +58,11 @@ React Navigation integration: call the function returned by `BugSession.navigati
|
|
|
58
58
|
|
|
59
59
|
Render errors are caught by Expo Router before they reach the global handler. Export the SDK's boundary from your root layout so they are captured: `export const ErrorBoundary = BugSession.ErrorBoundary;`. Unhandled promise rejections are captured in every build; in development they still reach LogBox.
|
|
60
60
|
|
|
61
|
-
`configure()` installs fetch, console (`log`, `info`, `debug`, `warn`, `error`), error and rejection hooks for the app's lifetime. By default the SDK keeps the last 60 seconds of these app events (at most 500; never video or audio) in memory. When a recording starts, they are attached with negative timestamps and `preRecording: true`, so a bug that happened just before the tester pressed record keeps its context. Nothing leaves the device unless a recording starts. Set `preRecordSeconds` (maximum 300) to change the window, or `0` to collect only while recording. Recorders deployed before pre-record support accept the recording and ignore these events.
|
|
61
|
+
`configure()` installs fetch, XMLHttpRequest (axios and most HTTP clients), console (`log`, `info`, `debug`, `warn`, `error`), error and rejection hooks for the app's lifetime; each request is recorded once, and the SDK's own uploads are skipped. By default the SDK keeps the last 60 seconds of these app events (at most 500; never video or audio) in memory. When a recording starts, they are attached with negative timestamps and `preRecording: true`, so a bug that happened just before the tester pressed record keeps its context. After Stop, events keep being collected for `postRecordSeconds` (default 5, maximum 30, `0` disables) while the video uploads, and are attached with `postRecording: true`, so the response to the tester's last tap is kept. Nothing leaves the device unless a recording starts. Set `preRecordSeconds` (maximum 300) to change the earlier window, or `0` to collect only while recording. Recorders deployed before pre- or post-record support accept the recording and ignore these events.
|
|
62
|
+
|
|
63
|
+
Failed requests (status 400 or higher, text or JSON responses) include `responseExcerpt`: the first 1,000 characters of the response body, with JSON fields named like passwords, tokens, secrets, keys, cookies and sessions masked, plus the usual bearer and URL redaction. Successful responses and request bodies are never captured.
|
|
64
|
+
|
|
65
|
+
Wrap your navigator once in `<BugSession.Capture>` to record every tap as an `action` event with `auto: true`, the tap position in points, the current screen and, when one can be read, the touched element's label (accessibility label, `aria-label`, title, placeholder, visible text or `testID`; never a text field's value). It only observes touches and never blocks gestures. `BugSession.Touchable` and `trackAction` remain available for explicit names.
|
|
62
66
|
|
|
63
67
|
`<BugSession.Trigger />` renders a slim tab docked to a screen edge, so it never covers bottom buttons or tab bars. Tap it to start recording. Drag it along the edge or across to the other side; it remembers its placement while the app is open. Render it once above the app navigator. `position` picks the initial edge and half (`bottom-right` by default), `offset` the distance from the top or bottom safe area, and `draggable={false}` locks it. Supply `children` to replace the idle record mark.
|
|
64
68
|
|
package/dist/core.d.ts
CHANGED
|
@@ -51,4 +51,22 @@ export declare class Telemetry implements TelemetrySink {
|
|
|
51
51
|
end(): void;
|
|
52
52
|
private prune;
|
|
53
53
|
}
|
|
54
|
+
/** Response excerpt for a failed request: text only, at most 1000 characters, secrets masked. */
|
|
55
|
+
export declare function safeBody(text: unknown): string | undefined;
|
|
56
|
+
/** Requests to these URLs (the recorder itself) are never recorded. */
|
|
57
|
+
export declare function ignoreRequests(match: (url: string) => boolean): void;
|
|
54
58
|
export declare function instrumentFetch(original: typeof fetch, collector: TelemetrySink): typeof fetch;
|
|
59
|
+
type XhrLike = {
|
|
60
|
+
open(method: string, url: string | URL, ...rest: unknown[]): void;
|
|
61
|
+
send(body?: unknown): void;
|
|
62
|
+
addEventListener(type: string, listener: () => void): void;
|
|
63
|
+
status: number;
|
|
64
|
+
responseType?: string;
|
|
65
|
+
responseText?: string;
|
|
66
|
+
getResponseHeader?(name: string): string | null;
|
|
67
|
+
};
|
|
68
|
+
/** Records XMLHttpRequest traffic (axios and most HTTP libraries) that does not go through fetch. */
|
|
69
|
+
export declare function instrumentXhr(Xhr: {
|
|
70
|
+
prototype: XhrLike;
|
|
71
|
+
} | undefined, collector: TelemetrySink): void;
|
|
72
|
+
export {};
|
package/dist/core.js
CHANGED
|
@@ -3,7 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.Telemetry = exports.MAX_PRE_RECORD_MS = exports.Collector = exports.SessionClock = void 0;
|
|
4
4
|
exports.safeUrl = safeUrl;
|
|
5
5
|
exports.safeText = safeText;
|
|
6
|
+
exports.safeBody = safeBody;
|
|
7
|
+
exports.ignoreRequests = ignoreRequests;
|
|
6
8
|
exports.instrumentFetch = instrumentFetch;
|
|
9
|
+
exports.instrumentXhr = instrumentXhr;
|
|
7
10
|
function safeUrl(input) {
|
|
8
11
|
try {
|
|
9
12
|
const u = new URL(input, 'http://relative.invalid');
|
|
@@ -110,14 +113,48 @@ exports.Telemetry = Telemetry;
|
|
|
110
113
|
function shiftRequestTime(data, offsetMs) {
|
|
111
114
|
return typeof data.requestTimestampMs === 'number' ? { ...data, requestTimestampMs: data.requestTimestampMs - offsetMs } : data;
|
|
112
115
|
}
|
|
116
|
+
/** Response excerpt for a failed request: text only, at most 1000 characters, secrets masked. */
|
|
117
|
+
function safeBody(text) {
|
|
118
|
+
if (typeof text !== 'string' || !text.trim())
|
|
119
|
+
return undefined;
|
|
120
|
+
return safeText(text.slice(0, 4000)
|
|
121
|
+
.replace(/("(?:[\w-]*(?:password|passwd|secret|token|authorization|cookie|api[-_]?key|session)[\w-]*)"\s*:\s*)("(?:[^"\\]|\\.)*"|[^,}\s]+)/gi, '$1"[redacted]"'))
|
|
122
|
+
.slice(0, 1000);
|
|
123
|
+
}
|
|
124
|
+
const textual = /json|text|xml|html|problem/i;
|
|
125
|
+
const sdkRequests = { ignored: (_url) => false };
|
|
126
|
+
/** Requests to these URLs (the recorder itself) are never recorded. */
|
|
127
|
+
function ignoreRequests(match) { sdkRequests.ignored = match; }
|
|
128
|
+
// React Native's fetch is built on XMLHttpRequest. While a fetch call creates its
|
|
129
|
+
// request synchronously, the XHR hook skips it so each request is recorded once.
|
|
130
|
+
let insideFetch = 0;
|
|
113
131
|
function instrumentFetch(original, collector) {
|
|
114
132
|
return (async (input, init) => {
|
|
115
|
-
const
|
|
133
|
+
const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
116
134
|
const method = (init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET')).toUpperCase();
|
|
117
135
|
const start = collector.clock.now();
|
|
136
|
+
let pending;
|
|
137
|
+
insideFetch++;
|
|
138
|
+
try {
|
|
139
|
+
pending = original(input, init);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
insideFetch--;
|
|
143
|
+
}
|
|
144
|
+
if (sdkRequests.ignored(rawUrl))
|
|
145
|
+
return pending;
|
|
146
|
+
const url = safeUrl(rawUrl);
|
|
118
147
|
try {
|
|
119
|
-
const response = await
|
|
120
|
-
|
|
148
|
+
const response = await pending;
|
|
149
|
+
const durationMs = collector.clock.now() - start;
|
|
150
|
+
let responseExcerpt;
|
|
151
|
+
if (response.status >= 400 && textual.test(response.headers?.get?.('content-type') ?? 'text')) {
|
|
152
|
+
responseExcerpt = await Promise.race([
|
|
153
|
+
response.clone().text().then(safeBody, () => undefined),
|
|
154
|
+
new Promise(resolve => setTimeout(() => resolve(undefined), 500)),
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
157
|
+
collector.add('network', { requestTimestampMs: start, method, url, status: response.status, durationMs, ...(responseExcerpt ? { responseExcerpt } : {}) });
|
|
121
158
|
return response;
|
|
122
159
|
}
|
|
123
160
|
catch (error) {
|
|
@@ -126,3 +163,40 @@ function instrumentFetch(original, collector) {
|
|
|
126
163
|
}
|
|
127
164
|
});
|
|
128
165
|
}
|
|
166
|
+
/** Records XMLHttpRequest traffic (axios and most HTTP libraries) that does not go through fetch. */
|
|
167
|
+
function instrumentXhr(Xhr, collector) {
|
|
168
|
+
if (!Xhr?.prototype?.open || !Xhr.prototype.send)
|
|
169
|
+
return;
|
|
170
|
+
const proto = Xhr.prototype;
|
|
171
|
+
const open = proto.open, send = proto.send;
|
|
172
|
+
const meta = new WeakMap();
|
|
173
|
+
proto.open = function (method, url, ...rest) {
|
|
174
|
+
const href = String(url);
|
|
175
|
+
if (!insideFetch && !sdkRequests.ignored(href))
|
|
176
|
+
meta.set(this, { method: String(method || 'GET').toUpperCase(), url: safeUrl(href) });
|
|
177
|
+
else
|
|
178
|
+
meta.delete(this);
|
|
179
|
+
return open.call(this, method, url, ...rest);
|
|
180
|
+
};
|
|
181
|
+
proto.send = function (body) {
|
|
182
|
+
const request = meta.get(this);
|
|
183
|
+
if (request) {
|
|
184
|
+
const start = collector.clock.now();
|
|
185
|
+
let error;
|
|
186
|
+
for (const type of ['error', 'timeout', 'abort'])
|
|
187
|
+
this.addEventListener(type, () => { error ?? (error = `Request ${type}`); });
|
|
188
|
+
this.addEventListener('loadend', () => {
|
|
189
|
+
const status = error || !this.status ? null : this.status;
|
|
190
|
+
let responseExcerpt;
|
|
191
|
+
try {
|
|
192
|
+
if (status !== null && status >= 400 && (!this.responseType || this.responseType === 'text') && textual.test(this.getResponseHeader?.('content-type') ?? 'text'))
|
|
193
|
+
responseExcerpt = safeBody(this.responseText);
|
|
194
|
+
}
|
|
195
|
+
catch { /* response not readable */ }
|
|
196
|
+
collector.add('network', { requestTimestampMs: start, method: request.method, url: request.url, status, durationMs: collector.clock.now() - start, client: 'xhr',
|
|
197
|
+
...(error ? { error } : {}), ...(responseExcerpt ? { responseExcerpt } : {}) });
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return send.call(this, body);
|
|
201
|
+
};
|
|
202
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -22,6 +22,11 @@ export type BugSessionOptions = {
|
|
|
22
22
|
* kept in memory before a recording starts and attached to it. Defaults to 60, max 300; 0 disables.
|
|
23
23
|
*/
|
|
24
24
|
preRecordSeconds?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Seconds of app events still collected after the tester stops, while the video uploads,
|
|
27
|
+
* so a request or error that lands just after Stop is kept. Defaults to 5, max 30; 0 disables.
|
|
28
|
+
*/
|
|
29
|
+
postRecordSeconds?: number;
|
|
25
30
|
};
|
|
26
31
|
export type BugSessionErrorBoundaryProps = {
|
|
27
32
|
error: Error;
|
|
@@ -39,6 +44,13 @@ export declare const BugSession: {
|
|
|
39
44
|
}): void;
|
|
40
45
|
trackNavigation(screen: string): void;
|
|
41
46
|
captureError: typeof captureError;
|
|
47
|
+
/**
|
|
48
|
+
* Wrap the app once (inside the navigator's container, around your screens) to record
|
|
49
|
+
* every tap automatically: position, current screen and the element's label when available.
|
|
50
|
+
*/
|
|
51
|
+
Capture: ({ children, style, ...props }: import("react-native").ViewProps & {
|
|
52
|
+
children?: React.ReactNode;
|
|
53
|
+
}) => React.JSX.Element;
|
|
42
54
|
Trigger: ({ position, offset, draggable, style, accessibilityLabel, children, onError, maxDurationSeconds }: import("./trigger").BugSessionTriggerProps) => React.JSX.Element;
|
|
43
55
|
/**
|
|
44
56
|
* Expo Router error boundary that reports render errors (which never reach the
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ const react_1 = __importDefault(require("react"));
|
|
|
9
9
|
const react_native_1 = require("react-native");
|
|
10
10
|
const trigger_1 = require("./trigger");
|
|
11
11
|
const core_1 = require("./core");
|
|
12
|
+
const touches_1 = require("./touches");
|
|
12
13
|
const capture_1 = require("./capture");
|
|
13
14
|
var core_2 = require("./core");
|
|
14
15
|
Object.defineProperty(exports, "Collector", { enumerable: true, get: function () { return core_2.Collector; } });
|
|
@@ -22,6 +23,7 @@ let collector = null;
|
|
|
22
23
|
const telemetry = new core_1.Telemetry();
|
|
23
24
|
let installed = false;
|
|
24
25
|
let preRecordEvents = [];
|
|
26
|
+
const MAX_POST_RECORD_SECONDS = 30;
|
|
25
27
|
let session = null;
|
|
26
28
|
let state = 'idle';
|
|
27
29
|
let currentScreen = 'Home';
|
|
@@ -100,11 +102,14 @@ async function request(path, data) {
|
|
|
100
102
|
// recording starts can be attached to it (see Telemetry).
|
|
101
103
|
function install() {
|
|
102
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)));
|
|
103
107
|
globalThis.fetch = (0, core_1.instrumentFetch)(rawFetch, telemetry);
|
|
108
|
+
(0, core_1.instrumentXhr)(globalThis.XMLHttpRequest, telemetry);
|
|
104
109
|
for (const level of ['log', 'info', 'debug', 'warn', 'error']) {
|
|
105
110
|
const original = console[level];
|
|
106
111
|
console[level] = (...args) => {
|
|
107
|
-
if (telemetry.active) {
|
|
112
|
+
if (telemetry.active && !(level === 'error' && repeatsCapturedError(args))) {
|
|
108
113
|
const error = args.find((a) => a instanceof Error);
|
|
109
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 });
|
|
110
115
|
}
|
|
@@ -146,9 +151,26 @@ function rejectionMessage(rejection) {
|
|
|
146
151
|
return String(rejection);
|
|
147
152
|
}
|
|
148
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));
|
|
165
|
+
}
|
|
149
166
|
function captureError(error, source = 'boundary') {
|
|
150
167
|
if (!telemetry.active)
|
|
151
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() });
|
|
152
174
|
telemetry.add('error', { source, message: (0, core_1.safeText)(error), stack: error instanceof Error ? (0, core_1.safeText)(error.stack ?? '') : undefined, screen: currentScreen });
|
|
153
175
|
}
|
|
154
176
|
exports.BugSession = {
|
|
@@ -238,10 +260,20 @@ exports.BugSession = {
|
|
|
238
260
|
const c = collector;
|
|
239
261
|
const active = session;
|
|
240
262
|
c.add('session-stop', {});
|
|
241
|
-
|
|
242
|
-
|
|
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.
|
|
243
266
|
const before = preRecordEvents;
|
|
244
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
|
+
};
|
|
245
277
|
try {
|
|
246
278
|
let media;
|
|
247
279
|
if (active.captureBackend === 'replaykit') {
|
|
@@ -271,8 +303,13 @@ exports.BugSession = {
|
|
|
271
303
|
await options.audio.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
|
|
272
304
|
media = { ...active, audioEndMs };
|
|
273
305
|
}
|
|
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();
|
|
274
310
|
const result = await request(`/sessions/${active.id}/stop`, {
|
|
275
|
-
durationMs:
|
|
311
|
+
durationMs: stoppedAtMs, events, droppedEvents: c.dropped, preRecordEvents: before,
|
|
312
|
+
...(after.length ? { postRecordEvents: after } : {}),
|
|
276
313
|
media,
|
|
277
314
|
});
|
|
278
315
|
setPhase({ kind: 'sent' });
|
|
@@ -282,11 +319,13 @@ exports.BugSession = {
|
|
|
282
319
|
if (active.captureBackend !== 'adb')
|
|
283
320
|
await options.capture.cancel().catch(() => { });
|
|
284
321
|
// Release the device recorder, but preserve partial files for recovery.
|
|
285
|
-
|
|
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(() => { });
|
|
286
324
|
setPhase({ kind: 'failed', message: (0, core_1.safeText)(error) });
|
|
287
325
|
throw error;
|
|
288
326
|
}
|
|
289
327
|
finally {
|
|
328
|
+
telemetry.end();
|
|
290
329
|
collector = null;
|
|
291
330
|
session = null;
|
|
292
331
|
state = 'idle';
|
|
@@ -302,6 +341,13 @@ exports.BugSession = {
|
|
|
302
341
|
currentScreen = screen;
|
|
303
342
|
},
|
|
304
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),
|
|
305
351
|
Trigger: (0, trigger_1.createTrigger)({
|
|
306
352
|
getPhase: () => phase,
|
|
307
353
|
subscribe(listener) { phaseListeners.add(listener); return () => { phaseListeners.delete(listener); }; },
|
|
@@ -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 } });
|
package/package.json
CHANGED
package/src/core.ts
CHANGED
|
@@ -94,14 +94,42 @@ function shiftRequestTime<T extends Record<string, unknown>>(data: T, offsetMs:
|
|
|
94
94
|
return typeof data.requestTimestampMs === 'number' ? { ...data, requestTimestampMs: data.requestTimestampMs - offsetMs } : data;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/** Response excerpt for a failed request: text only, at most 1000 characters, secrets masked. */
|
|
98
|
+
export function safeBody(text: unknown): string | undefined {
|
|
99
|
+
if (typeof text !== 'string' || !text.trim()) return undefined;
|
|
100
|
+
return safeText(text.slice(0, 4000)
|
|
101
|
+
.replace(/("(?:[\w-]*(?:password|passwd|secret|token|authorization|cookie|api[-_]?key|session)[\w-]*)"\s*:\s*)("(?:[^"\\]|\\.)*"|[^,}\s]+)/gi, '$1"[redacted]"'))
|
|
102
|
+
.slice(0, 1000);
|
|
103
|
+
}
|
|
104
|
+
const textual = /json|text|xml|html|problem/i;
|
|
105
|
+
const sdkRequests = { ignored: (_url: string) => false };
|
|
106
|
+
/** Requests to these URLs (the recorder itself) are never recorded. */
|
|
107
|
+
export function ignoreRequests(match: (url: string) => boolean) { sdkRequests.ignored = match; }
|
|
108
|
+
// React Native's fetch is built on XMLHttpRequest. While a fetch call creates its
|
|
109
|
+
// request synchronously, the XHR hook skips it so each request is recorded once.
|
|
110
|
+
let insideFetch = 0;
|
|
111
|
+
|
|
97
112
|
export function instrumentFetch(original: typeof fetch, collector: TelemetrySink): typeof fetch {
|
|
98
113
|
return (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
|
99
|
-
const
|
|
114
|
+
const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
100
115
|
const method = (init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET')).toUpperCase();
|
|
101
116
|
const start = collector.clock.now();
|
|
117
|
+
let pending: Promise<Response>;
|
|
118
|
+
insideFetch++;
|
|
119
|
+
try { pending = original(input, init); } finally { insideFetch--; }
|
|
120
|
+
if (sdkRequests.ignored(rawUrl)) return pending;
|
|
121
|
+
const url = safeUrl(rawUrl);
|
|
102
122
|
try {
|
|
103
|
-
const response = await
|
|
104
|
-
|
|
123
|
+
const response = await pending;
|
|
124
|
+
const durationMs = collector.clock.now() - start;
|
|
125
|
+
let responseExcerpt: string | undefined;
|
|
126
|
+
if (response.status >= 400 && textual.test(response.headers?.get?.('content-type') ?? 'text')) {
|
|
127
|
+
responseExcerpt = await Promise.race([
|
|
128
|
+
response.clone().text().then(safeBody, () => undefined),
|
|
129
|
+
new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), 500)),
|
|
130
|
+
]);
|
|
131
|
+
}
|
|
132
|
+
collector.add('network', { requestTimestampMs: start, method, url, status: response.status, durationMs, ...(responseExcerpt ? { responseExcerpt } : {}) });
|
|
105
133
|
return response;
|
|
106
134
|
} catch (error) {
|
|
107
135
|
collector.add('network', { requestTimestampMs: start, method, url, status: null, durationMs: collector.clock.now() - start, error: safeText(error) });
|
|
@@ -109,3 +137,42 @@ export function instrumentFetch(original: typeof fetch, collector: TelemetrySink
|
|
|
109
137
|
}
|
|
110
138
|
}) as typeof fetch;
|
|
111
139
|
}
|
|
140
|
+
|
|
141
|
+
type XhrLike = {
|
|
142
|
+
open(method: string, url: string | URL, ...rest: unknown[]): void;
|
|
143
|
+
send(body?: unknown): void;
|
|
144
|
+
addEventListener(type: string, listener: () => void): void;
|
|
145
|
+
status: number; responseType?: string; responseText?: string;
|
|
146
|
+
getResponseHeader?(name: string): string | null;
|
|
147
|
+
};
|
|
148
|
+
/** Records XMLHttpRequest traffic (axios and most HTTP libraries) that does not go through fetch. */
|
|
149
|
+
export function instrumentXhr(Xhr: { prototype: XhrLike } | undefined, collector: TelemetrySink) {
|
|
150
|
+
if (!Xhr?.prototype?.open || !Xhr.prototype.send) return;
|
|
151
|
+
const proto = Xhr.prototype;
|
|
152
|
+
const open = proto.open, send = proto.send;
|
|
153
|
+
const meta = new WeakMap<object, { method: string; url: string }>();
|
|
154
|
+
proto.open = function (this: XhrLike, method: string, url: string | URL, ...rest: unknown[]) {
|
|
155
|
+
const href = String(url);
|
|
156
|
+
if (!insideFetch && !sdkRequests.ignored(href)) meta.set(this, { method: String(method || 'GET').toUpperCase(), url: safeUrl(href) });
|
|
157
|
+
else meta.delete(this);
|
|
158
|
+
return open.call(this, method, url, ...rest);
|
|
159
|
+
};
|
|
160
|
+
proto.send = function (this: XhrLike, body?: unknown) {
|
|
161
|
+
const request = meta.get(this);
|
|
162
|
+
if (request) {
|
|
163
|
+
const start = collector.clock.now();
|
|
164
|
+
let error: string | undefined;
|
|
165
|
+
for (const type of ['error', 'timeout', 'abort']) this.addEventListener(type, () => { error ??= `Request ${type}`; });
|
|
166
|
+
this.addEventListener('loadend', () => {
|
|
167
|
+
const status = error || !this.status ? null : this.status;
|
|
168
|
+
let responseExcerpt: string | undefined;
|
|
169
|
+
try {
|
|
170
|
+
if (status !== null && status >= 400 && (!this.responseType || this.responseType === 'text') && textual.test(this.getResponseHeader?.('content-type') ?? 'text')) responseExcerpt = safeBody(this.responseText);
|
|
171
|
+
} catch { /* response not readable */ }
|
|
172
|
+
collector.add('network', { requestTimestampMs: start, method: request.method, url: request.url, status, durationMs: collector.clock.now() - start, client: 'xhr',
|
|
173
|
+
...(error ? { error } : {}), ...(responseExcerpt ? { responseExcerpt } : {}) });
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return send.call(this, body);
|
|
177
|
+
};
|
|
178
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -2,7 +2,8 @@ import React from 'react';
|
|
|
2
2
|
import { Dimensions, Pressable, StyleSheet, Text, View, type PressableProps, Platform } from 'react-native';
|
|
3
3
|
import { createTrigger, type IndicatorPhase } from './trigger';
|
|
4
4
|
export type { Annotation, BugSessionTriggerProps, IndicatorPhase } from './trigger';
|
|
5
|
-
import { Collector, instrumentFetch, safeText, safeUrl, SessionClock, Telemetry, type Evidence } from './core';
|
|
5
|
+
import { Collector, ignoreRequests, instrumentFetch, instrumentXhr, safeText, safeUrl, SessionClock, Telemetry, type Evidence } from './core';
|
|
6
|
+
import { createCapture } from './touches';
|
|
6
7
|
import { alignNativeRecording, type NativeCaptureAdapter } from './capture';
|
|
7
8
|
export type { NativeCaptureAdapter } from './capture';
|
|
8
9
|
export { Collector, SessionClock, Telemetry, safeText, safeUrl } from './core';
|
|
@@ -25,6 +26,11 @@ export type BugSessionOptions = {
|
|
|
25
26
|
* kept in memory before a recording starts and attached to it. Defaults to 60, max 300; 0 disables.
|
|
26
27
|
*/
|
|
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;
|
|
28
34
|
};
|
|
29
35
|
export type BugSessionErrorBoundaryProps = { error: Error; retry: () => void | Promise<void> };
|
|
30
36
|
type ErrorUtilsType = { getGlobalHandler(): (e: Error, fatal?: boolean) => void; setGlobalHandler(handler: (e: Error, fatal?: boolean) => void): void };
|
|
@@ -38,6 +44,7 @@ let collector: Collector | null = null;
|
|
|
38
44
|
const telemetry = new Telemetry();
|
|
39
45
|
let installed = false;
|
|
40
46
|
let preRecordEvents: Evidence[] = [];
|
|
47
|
+
const MAX_POST_RECORD_SECONDS = 30;
|
|
41
48
|
let session: { id: string; videoStartMs: number; audioStartMs: number; uncertaintyMs: number; captureBackend: 'adb' | 'replaykit' | 'mediaprojection'; nativeOffsetMs?: number; nativeUncertaintyMs?: number } | null = null;
|
|
42
49
|
let state: 'idle' | 'starting' | 'recording' | 'stopping' = 'idle';
|
|
43
50
|
let currentScreen = 'Home';
|
|
@@ -108,11 +115,14 @@ async function request(path: string, data?: unknown) {
|
|
|
108
115
|
// recording starts can be attached to it (see Telemetry).
|
|
109
116
|
function install() {
|
|
110
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)));
|
|
111
120
|
globalThis.fetch = instrumentFetch(rawFetch, telemetry);
|
|
121
|
+
instrumentXhr((globalThis as { XMLHttpRequest?: Parameters<typeof instrumentXhr>[0] }).XMLHttpRequest, telemetry);
|
|
112
122
|
for (const level of ['log', 'info', 'debug', 'warn', 'error'] as const) {
|
|
113
123
|
const original = console[level];
|
|
114
124
|
console[level] = (...args: unknown[]) => {
|
|
115
|
-
if (telemetry.active) {
|
|
125
|
+
if (telemetry.active && !(level === 'error' && repeatsCapturedError(args))) {
|
|
116
126
|
const error = args.find((a): a is Error => a instanceof Error);
|
|
117
127
|
telemetry.add(level === 'error' ? 'error' : 'log', { level, message: args.map(safeText).join(' '), source: 'console', stack: error?.stack ? safeText(error.stack) : undefined, screen: currentScreen });
|
|
118
128
|
}
|
|
@@ -144,8 +154,21 @@ function rejectionMessage(rejection: unknown) {
|
|
|
144
154
|
if (typeof rejection === 'string') return rejection;
|
|
145
155
|
try { return JSON.stringify(rejection) ?? String(rejection); } catch { return String(rejection); }
|
|
146
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));
|
|
166
|
+
}
|
|
147
167
|
function captureError(error: unknown, source = 'boundary') {
|
|
148
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() });
|
|
149
172
|
telemetry.add('error', { source, message: safeText(error), stack: error instanceof Error ? safeText(error.stack ?? '') : undefined, screen: currentScreen });
|
|
150
173
|
}
|
|
151
174
|
|
|
@@ -217,10 +240,20 @@ export const BugSession = {
|
|
|
217
240
|
const c = collector;
|
|
218
241
|
const active = session;
|
|
219
242
|
c.add('session-stop', {});
|
|
220
|
-
|
|
221
|
-
|
|
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.
|
|
222
246
|
const before = preRecordEvents;
|
|
223
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
|
+
};
|
|
224
257
|
try {
|
|
225
258
|
let media: Record<string, unknown>;
|
|
226
259
|
if (active.captureBackend === 'replaykit') {
|
|
@@ -246,8 +279,12 @@ export const BugSession = {
|
|
|
246
279
|
await options.audio!.upload(`${options.recorderUrl}/sessions/${active.id}/audio`, uri, authHeaders());
|
|
247
280
|
media = { ...active, audioEndMs };
|
|
248
281
|
}
|
|
282
|
+
const remainingMs = stoppedAtMs + postRecordMs - c.clock.now();
|
|
283
|
+
if (remainingMs > 0) await new Promise(resolve => setTimeout(resolve, remainingMs));
|
|
284
|
+
const { events, after } = split();
|
|
249
285
|
const result = await request(`/sessions/${active.id}/stop`, {
|
|
250
|
-
durationMs:
|
|
286
|
+
durationMs: stoppedAtMs, events, droppedEvents: c.dropped, preRecordEvents: before,
|
|
287
|
+
...(after.length ? { postRecordEvents: after } : {}),
|
|
251
288
|
media,
|
|
252
289
|
});
|
|
253
290
|
setPhase({ kind: 'sent' });
|
|
@@ -255,10 +292,11 @@ export const BugSession = {
|
|
|
255
292
|
} catch (error) {
|
|
256
293
|
if (active.captureBackend !== 'adb') await options.capture!.cancel().catch(() => {});
|
|
257
294
|
// Release the device recorder, but preserve partial files for recovery.
|
|
258
|
-
|
|
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(() => {});
|
|
259
297
|
setPhase({ kind: 'failed', message: safeText(error) });
|
|
260
298
|
throw error;
|
|
261
|
-
} finally { collector = null; session = null; state = 'idle'; }
|
|
299
|
+
} finally { telemetry.end(); collector = null; session = null; state = 'idle'; }
|
|
262
300
|
},
|
|
263
301
|
trackAction(action: { name: string; screen?: string }) {
|
|
264
302
|
if (telemetry.active) telemetry.add('action', { target: safeText(action.name), screen: safeText(action.screen ?? currentScreen) });
|
|
@@ -268,6 +306,13 @@ export const BugSession = {
|
|
|
268
306
|
currentScreen = screen;
|
|
269
307
|
},
|
|
270
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),
|
|
271
316
|
Trigger: createTrigger({
|
|
272
317
|
getPhase: () => phase,
|
|
273
318
|
subscribe(listener) { phaseListeners.add(listener); return () => { phaseListeners.delete(listener); }; },
|
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 } });
|