@goliapkg/sentori-react-native 0.9.11 → 1.0.0-rc.10
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/android/src/main/java/com/sentori/SentoriForegroundActivity.kt +145 -0
- package/android/src/main/java/com/sentori/SentoriModule.kt +13 -0
- package/android/src/main/java/com/sentori/SentoriReplayCapture.kt +261 -68
- package/android/src/main/java/com/sentori/SentoriScreenshotCapture.kt +72 -36
- package/ios/SentoriModule.swift +15 -0
- package/ios/SentoriReplayCapture.swift +135 -10
- package/ios/SentoriScreenshotCapture.swift +69 -3
- package/lib/base64.d.ts +25 -0
- package/lib/base64.d.ts.map +1 -0
- package/lib/base64.js +30 -0
- package/lib/base64.js.map +1 -0
- package/lib/capture.d.ts +20 -1
- package/lib/capture.d.ts.map +1 -1
- package/lib/capture.js +45 -21
- package/lib/capture.js.map +1 -1
- package/lib/index.d.ts +2 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -1
- package/lib/index.js.bak +64 -0
- package/lib/index.js.map +1 -1
- package/lib/native.d.ts +68 -0
- package/lib/native.d.ts.map +1 -1
- package/lib/native.js +115 -0
- package/lib/native.js.map +1 -1
- package/lib/replay.d.ts +28 -4
- package/lib/replay.d.ts.map +1 -1
- package/lib/replay.js +242 -65
- package/lib/replay.js.map +1 -1
- package/lib/transport.d.ts.map +1 -1
- package/lib/transport.js +16 -0
- package/lib/transport.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/base64.test.ts +55 -0
- package/src/__tests__/capture-replay.test.ts +150 -0
- package/src/__tests__/replay-encoding.test.ts +237 -0
- package/src/base64.ts +29 -0
- package/src/capture.ts +56 -22
- package/src/index.ts +3 -0
- package/src/native.ts +177 -0
- package/src/replay.ts +294 -70
- package/src/transport.ts +31 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { __captureAndAttachReplayForTests } from '../capture';
|
|
4
|
+
import { setConfig, __resetForTests as resetConfig } from '../config';
|
|
5
|
+
import type { Event } from '../types';
|
|
6
|
+
|
|
7
|
+
// Insight 2026-05-18 rc.3 verify: walker is healthy, ticks land
|
|
8
|
+
// deep wireframe JSON with TextView text, but the `replay` kind
|
|
9
|
+
// never reaches `event.attachments`. Root cause was the inline
|
|
10
|
+
// `btoa(ndjson)` upload path — spec-compliant Hermes / Bun btoa
|
|
11
|
+
// throws `InvalidCharacterError` on any code point > 0xFF, and the
|
|
12
|
+
// surrounding catch swallowed the throw silently. rc.4 routes
|
|
13
|
+
// through `base64Utf8`, fixing both Latin-1 and UTF-8 inputs.
|
|
14
|
+
//
|
|
15
|
+
// These tests run the actual captureAndAttachReplay function the
|
|
16
|
+
// captureException pipeline runs. fetch is mocked at the
|
|
17
|
+
// transport.ts boundary so the upload landing isn't tested here
|
|
18
|
+
// (covered separately in screenshot.test.ts); what we care about
|
|
19
|
+
// is "does event.attachments grow when the input contains
|
|
20
|
+
// non-Latin-1 text?".
|
|
21
|
+
|
|
22
|
+
const origFetch = globalThis.fetch;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
setConfig({
|
|
26
|
+
enabled: true,
|
|
27
|
+
environment: 'test',
|
|
28
|
+
errorSampleRate: null,
|
|
29
|
+
ingestUrl: 'http://localhost:18080',
|
|
30
|
+
release: 'app@1.0.0+1',
|
|
31
|
+
screenshotsEnabled: true,
|
|
32
|
+
sessionTrailEnabled: true,
|
|
33
|
+
token: 'st_pk_test',
|
|
34
|
+
traceSampleRate: null,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
globalThis.fetch = origFetch;
|
|
40
|
+
resetConfig();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function makeEvent(): Event {
|
|
44
|
+
return {
|
|
45
|
+
id: '019eaa00-0000-7000-8000-000000000001',
|
|
46
|
+
timestamp: '2026-05-18T05:15:24.000Z',
|
|
47
|
+
kind: 'error',
|
|
48
|
+
platform: 'javascript',
|
|
49
|
+
release: 'app@1.0.0+1',
|
|
50
|
+
environment: 'test',
|
|
51
|
+
device: { os: 'android', osVersion: '36' },
|
|
52
|
+
app: {
|
|
53
|
+
version: '1.0.0',
|
|
54
|
+
build: '1',
|
|
55
|
+
framework: { name: 'react-native', version: '0.80.0' },
|
|
56
|
+
},
|
|
57
|
+
user: null,
|
|
58
|
+
tags: undefined,
|
|
59
|
+
breadcrumbs: [],
|
|
60
|
+
error: { type: 'Error', message: 'boom', stack: [], cause: null },
|
|
61
|
+
fingerprint: undefined,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function mockUploadSuccess(): { calls: { url: string; body: unknown }[] } {
|
|
66
|
+
const calls: { url: string; body: unknown }[] = [];
|
|
67
|
+
globalThis.fetch = mock(async (url: Request | string | URL, init?: RequestInit) => {
|
|
68
|
+
let body: unknown = undefined;
|
|
69
|
+
try {
|
|
70
|
+
body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
|
71
|
+
} catch {
|
|
72
|
+
body = init?.body;
|
|
73
|
+
}
|
|
74
|
+
calls.push({ url: String(url), body });
|
|
75
|
+
return new Response(
|
|
76
|
+
JSON.stringify({
|
|
77
|
+
kind: 'replay',
|
|
78
|
+
mediaType: 'application/x-ndjson',
|
|
79
|
+
refId: '019e3000-7000-7000-8000-00000000aaaa',
|
|
80
|
+
sizeBytes: 200,
|
|
81
|
+
}),
|
|
82
|
+
{ headers: { 'content-type': 'application/json' }, status: 201 },
|
|
83
|
+
);
|
|
84
|
+
}) as typeof fetch;
|
|
85
|
+
return { calls };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('captureAndAttachReplay — Insight rc.3 regression', () => {
|
|
89
|
+
test('Latin-1 ndjson attaches replay kind (control)', async () => {
|
|
90
|
+
const { calls } = mockUploadSuccess();
|
|
91
|
+
const event = makeEvent();
|
|
92
|
+
const ndjson =
|
|
93
|
+
'{"ts":1700000000,"width":390,"height":844,"nodes":[' +
|
|
94
|
+
'{"kind":"text","x":10,"y":20,"w":100,"h":20,"text":"Settings"}]}';
|
|
95
|
+
await __captureAndAttachReplayForTests(event, ndjson);
|
|
96
|
+
expect(calls.length).toBe(1);
|
|
97
|
+
expect(event.attachments?.length).toBe(1);
|
|
98
|
+
expect(event.attachments?.[0]?.kind).toBe('replay');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('UTF-8 ndjson attaches replay kind (the rc.3 Android crash repro)', async () => {
|
|
102
|
+
// This is the bug: pre-rc.4 `btoa(ndjson)` threw on the
|
|
103
|
+
// Japanese characters below, the catch swallowed it, the
|
|
104
|
+
// attachment silently never landed. Post-rc.4 the helper
|
|
105
|
+
// handles UTF-8 and the attachment lands normally.
|
|
106
|
+
const { calls } = mockUploadSuccess();
|
|
107
|
+
const event = makeEvent();
|
|
108
|
+
const ndjson =
|
|
109
|
+
'{"ts":1700000000,"width":390,"height":844,"nodes":[' +
|
|
110
|
+
'{"kind":"text","x":10,"y":20,"w":100,"h":20,"text":"設定"},' +
|
|
111
|
+
'{"kind":"text","x":10,"y":50,"w":100,"h":20,"text":"ログアウト"}]}';
|
|
112
|
+
await __captureAndAttachReplayForTests(event, ndjson);
|
|
113
|
+
expect(calls.length).toBe(1);
|
|
114
|
+
expect(event.attachments?.length).toBe(1);
|
|
115
|
+
expect(event.attachments?.[0]?.kind).toBe('replay');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('mixed em-dash / smart-quote / emoji attaches replay kind', async () => {
|
|
119
|
+
const { calls } = mockUploadSuccess();
|
|
120
|
+
const event = makeEvent();
|
|
121
|
+
const ndjson =
|
|
122
|
+
'{"ts":1,"width":390,"height":844,"nodes":[' +
|
|
123
|
+
'{"kind":"text","x":0,"y":0,"w":50,"h":20,"text":"hello — world"},' +
|
|
124
|
+
'{"kind":"text","x":0,"y":30,"w":50,"h":20,"text":"\\"quoted\\""},' +
|
|
125
|
+
'{"kind":"text","x":0,"y":60,"w":50,"h":20,"text":"🎉"}]}';
|
|
126
|
+
await __captureAndAttachReplayForTests(event, ndjson);
|
|
127
|
+
expect(calls.length).toBe(1);
|
|
128
|
+
expect(event.attachments?.length).toBe(1);
|
|
129
|
+
expect(event.attachments?.[0]?.kind).toBe('replay');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('upload returns null → no attachment, no throw', async () => {
|
|
133
|
+
globalThis.fetch = (async () =>
|
|
134
|
+
new Response('{"error":"tooLarge"}', { status: 413 })) as typeof fetch;
|
|
135
|
+
const event = makeEvent();
|
|
136
|
+
const ndjson = '{"ts":1,"nodes":[]}';
|
|
137
|
+
await __captureAndAttachReplayForTests(event, ndjson);
|
|
138
|
+
expect(event.attachments).toBeUndefined();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('fetch throws (offline) → no attachment, no throw out of the function', async () => {
|
|
142
|
+
globalThis.fetch = (async () => {
|
|
143
|
+
throw new TypeError('Network request failed');
|
|
144
|
+
}) as typeof fetch;
|
|
145
|
+
const event = makeEvent();
|
|
146
|
+
const ndjson = '{"ts":1,"nodes":[]}';
|
|
147
|
+
await __captureAndAttachReplayForTests(event, ndjson);
|
|
148
|
+
expect(event.attachments).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
__feedTickForTests,
|
|
5
|
+
__resetReplayForTests,
|
|
6
|
+
computeDelta,
|
|
7
|
+
drainReplay,
|
|
8
|
+
} from '../replay';
|
|
9
|
+
|
|
10
|
+
// Unit coverage for the rc.9 v2 replay encoder.
|
|
11
|
+
//
|
|
12
|
+
// The encoder lives in replay.ts; native module is mocked away via
|
|
13
|
+
// __feedTickForTests so we drive the captureTick body with hand-built
|
|
14
|
+
// snapshots and inspect the resulting NDJSON shape.
|
|
15
|
+
//
|
|
16
|
+
// See docs/replay-encoding-v2.md for the wire schema.
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
__resetReplayForTests();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
type Node = {
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
w: number;
|
|
26
|
+
h: number;
|
|
27
|
+
kind?: string;
|
|
28
|
+
text?: string;
|
|
29
|
+
color?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function frame(ts: number, nodes: Node[]) {
|
|
33
|
+
return JSON.stringify({ ts, width: 1080, height: 2340, nodes });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function nodeRect(x: number, y: number, w: number, h: number, color = '#FF0000FF'): Node {
|
|
37
|
+
return { x, y, w, h, kind: 'rect', color };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function nodeText(x: number, y: number, w: number, h: number, text: string, color = '#FFFFFFFF'): Node {
|
|
41
|
+
return { x, y, w, h, kind: 'text', text, color };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readDrainedLines(): unknown[] {
|
|
45
|
+
const drained = drainReplay();
|
|
46
|
+
if (drained.length === 0) return [];
|
|
47
|
+
return drained.split('\n').map((l) => JSON.parse(l) as unknown);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('rc.9 encoder — keyframe vs delta', () => {
|
|
51
|
+
test('first tick after start always emits a keyframe', () => {
|
|
52
|
+
__feedTickForTests(frame(1_000, [nodeRect(0, 0, 1080, 2340)]));
|
|
53
|
+
const lines = readDrainedLines();
|
|
54
|
+
expect(lines.length).toBe(1);
|
|
55
|
+
expect((lines[0] as { kind: string }).kind).toBe('key');
|
|
56
|
+
expect((lines[0] as { width: number }).width).toBe(1080);
|
|
57
|
+
expect((lines[0] as { nodes: Node[] }).nodes.length).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('static UI ticks emit no line (no-op heartbeat dropped)', () => {
|
|
61
|
+
const nodes = [nodeRect(0, 0, 1080, 2340, '#000000FF')];
|
|
62
|
+
__feedTickForTests(frame(1_000, nodes));
|
|
63
|
+
__feedTickForTests(frame(1_250, nodes));
|
|
64
|
+
__feedTickForTests(frame(1_500, nodes));
|
|
65
|
+
__feedTickForTests(frame(1_750, nodes));
|
|
66
|
+
const lines = readDrainedLines();
|
|
67
|
+
// 1 keyframe + 0 deltas (all subsequent ticks are no-op)
|
|
68
|
+
expect(lines.length).toBe(1);
|
|
69
|
+
expect((lines[0] as { kind: string }).kind).toBe('key');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a single node change emits exactly one delta with the changed node', () => {
|
|
73
|
+
const baseline = [
|
|
74
|
+
nodeRect(0, 0, 1080, 2340, '#0E0E10FF'),
|
|
75
|
+
nodeText(60, 192, 960, 112, 'before'),
|
|
76
|
+
];
|
|
77
|
+
const after = [
|
|
78
|
+
nodeRect(0, 0, 1080, 2340, '#0E0E10FF'),
|
|
79
|
+
nodeText(60, 192, 960, 112, 'after'),
|
|
80
|
+
];
|
|
81
|
+
__feedTickForTests(frame(1_000, baseline));
|
|
82
|
+
__feedTickForTests(frame(1_250, after));
|
|
83
|
+
const lines = readDrainedLines();
|
|
84
|
+
expect(lines.length).toBe(2);
|
|
85
|
+
expect((lines[0] as { kind: string }).kind).toBe('key');
|
|
86
|
+
expect((lines[1] as { kind: string }).kind).toBe('delta');
|
|
87
|
+
const d = lines[1] as { added: Node[]; changed: Node[]; removed: Node[] };
|
|
88
|
+
expect(d.added.length).toBe(0);
|
|
89
|
+
expect(d.removed.length).toBe(0);
|
|
90
|
+
expect(d.changed.length).toBe(1);
|
|
91
|
+
expect((d.changed[0] as Node).text).toBe('after');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('added + removed nodes both appear in the delta', () => {
|
|
95
|
+
const baseline = [nodeRect(0, 0, 1080, 100), nodeRect(0, 200, 1080, 100)];
|
|
96
|
+
const after = [nodeRect(0, 0, 1080, 100), nodeRect(0, 400, 1080, 100)];
|
|
97
|
+
__feedTickForTests(frame(1_000, baseline));
|
|
98
|
+
__feedTickForTests(frame(1_250, after));
|
|
99
|
+
const lines = readDrainedLines();
|
|
100
|
+
expect(lines.length).toBe(2);
|
|
101
|
+
const d = lines[1] as { added: Node[]; changed: Node[]; removed: Node[] };
|
|
102
|
+
expect(d.added.length).toBe(1);
|
|
103
|
+
expect((d.added[0] as Node).y).toBe(400);
|
|
104
|
+
expect(d.removed.length).toBe(1);
|
|
105
|
+
expect((d.removed[0] as Node).y).toBe(200);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('keyframe overdue ⇒ next emit is a keyframe even when delta is small', () => {
|
|
109
|
+
const baseline = [nodeRect(0, 0, 1080, 2340), nodeText(60, 192, 960, 112, 'a')];
|
|
110
|
+
// After 4 s (default keyframeMs), the next change triggers a fresh keyframe.
|
|
111
|
+
__feedTickForTests(frame(1_000, baseline));
|
|
112
|
+
const slightlyChanged = [
|
|
113
|
+
nodeRect(0, 0, 1080, 2340),
|
|
114
|
+
nodeText(60, 192, 960, 112, 'a-changed'),
|
|
115
|
+
];
|
|
116
|
+
__feedTickForTests(frame(1_000 + 4_001, slightlyChanged));
|
|
117
|
+
const lines = readDrainedLines();
|
|
118
|
+
expect(lines.length).toBe(2);
|
|
119
|
+
expect((lines[0] as { kind: string }).kind).toBe('key');
|
|
120
|
+
expect((lines[1] as { kind: string }).kind).toBe('key'); // overdue → key
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('big screen transition (>40% nodes change) prefers a keyframe over a huge delta', () => {
|
|
124
|
+
const baseline = Array.from({ length: 10 }, (_, i) => nodeRect(0, i * 100, 1080, 90));
|
|
125
|
+
const after = Array.from({ length: 10 }, (_, i) => nodeRect(0, i * 100 + 500, 1080, 90));
|
|
126
|
+
__feedTickForTests(frame(1_000, baseline));
|
|
127
|
+
__feedTickForTests(frame(1_250, after));
|
|
128
|
+
const lines = readDrainedLines();
|
|
129
|
+
expect(lines.length).toBe(2);
|
|
130
|
+
expect((lines[0] as { kind: string }).kind).toBe('key');
|
|
131
|
+
// Delta would be 10 added + 10 removed = 20 changes, > 10 * 0.4 threshold,
|
|
132
|
+
// so encoder should fall back to keyframe instead.
|
|
133
|
+
expect((lines[1] as { kind: string }).kind).toBe('key');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('reconstructing keyframe + applied deltas recovers identical final state', () => {
|
|
137
|
+
// Drive 6 ticks: keyframe + 5 small deltas, then assert reconstruction equals the last input state.
|
|
138
|
+
const finalNodes: Node[] = [];
|
|
139
|
+
const baseline = [nodeRect(0, 0, 1080, 2340, '#0E0E10FF')];
|
|
140
|
+
__feedTickForTests(frame(1_000, baseline));
|
|
141
|
+
let state: Node[] = [...baseline];
|
|
142
|
+
for (let i = 0; i < 5; i++) {
|
|
143
|
+
// Add one row per tick.
|
|
144
|
+
const row = nodeText(60, 200 + i * 100, 960, 80, `row ${i}`);
|
|
145
|
+
state = [...state, row];
|
|
146
|
+
__feedTickForTests(frame(1_000 + (i + 1) * 250, state));
|
|
147
|
+
}
|
|
148
|
+
finalNodes.push(...state);
|
|
149
|
+
|
|
150
|
+
const lines = readDrainedLines();
|
|
151
|
+
expect(lines.length).toBe(6);
|
|
152
|
+
// Reconstruct: start from keyframe, apply each delta in order.
|
|
153
|
+
let reconstructed = new Map<string, Node>();
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
const l = line as { kind: string; nodes?: Node[]; added?: Node[]; changed?: Node[]; removed?: Pick<Node, 'x' | 'y' | 'w' | 'h'>[] };
|
|
156
|
+
if (l.kind === 'key') {
|
|
157
|
+
reconstructed = new Map((l.nodes ?? []).map((n) => [`${n.x | 0},${n.y | 0},${n.w | 0},${n.h | 0}`, n]));
|
|
158
|
+
} else {
|
|
159
|
+
for (const r of l.removed ?? []) {
|
|
160
|
+
reconstructed.delete(`${r.x | 0},${r.y | 0},${r.w | 0},${r.h | 0}`);
|
|
161
|
+
}
|
|
162
|
+
for (const a of l.added ?? []) {
|
|
163
|
+
reconstructed.set(`${a.x | 0},${a.y | 0},${a.w | 0},${a.h | 0}`, a);
|
|
164
|
+
}
|
|
165
|
+
for (const c of l.changed ?? []) {
|
|
166
|
+
reconstructed.set(`${c.x | 0},${c.y | 0},${c.w | 0},${c.h | 0}`, c);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
expect(reconstructed.size).toBe(finalNodes.length);
|
|
171
|
+
for (const n of finalNodes) {
|
|
172
|
+
const got = reconstructed.get(`${n.x | 0},${n.y | 0},${n.w | 0},${n.h | 0}`);
|
|
173
|
+
expect(got).toBeDefined();
|
|
174
|
+
expect(got!.kind).toBe(n.kind);
|
|
175
|
+
expect(got!.text).toBe(n.text);
|
|
176
|
+
expect(got!.color).toBe(n.color);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('byte budget — 60 s × 4 Hz dense UI stays below the rc.8 baseline', () => {
|
|
181
|
+
// Simulate 60 s at 4 Hz = 240 ticks. Dense UI: 100 nodes, 2 nodes
|
|
182
|
+
// change per tick on average. This mirrors the typical
|
|
183
|
+
// mostly-static-with-small-animations app shape.
|
|
184
|
+
const baseNodes = Array.from({ length: 100 }, (_, i) => nodeRect(0, i * 24, 1080, 20));
|
|
185
|
+
let totalBytes = 0;
|
|
186
|
+
const tickCount = 240;
|
|
187
|
+
for (let t = 0; t < tickCount; t++) {
|
|
188
|
+
// Every 4th tick mutates two nodes.
|
|
189
|
+
const nodes = baseNodes.map((n, i) => {
|
|
190
|
+
if (t % 4 === 0 && (i === t % 100 || i === (t + 50) % 100)) {
|
|
191
|
+
return { ...n, color: t & 1 ? '#FF0000FF' : '#00FF00FF' };
|
|
192
|
+
}
|
|
193
|
+
return n;
|
|
194
|
+
});
|
|
195
|
+
__feedTickForTests(frame(1_000 + t * 250, nodes));
|
|
196
|
+
}
|
|
197
|
+
const drained = drainReplay();
|
|
198
|
+
totalBytes = drained.length;
|
|
199
|
+
|
|
200
|
+
// rc.8 baseline for the same window: 60 × 100 nodes × ~50 bytes/node ≈ 300 KB.
|
|
201
|
+
// rc.9 target: well under 200 KB.
|
|
202
|
+
expect(totalBytes).toBeLessThan(200_000);
|
|
203
|
+
// Sanity: not zero, and dominated by keyframes.
|
|
204
|
+
expect(totalBytes).toBeGreaterThan(10_000);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('computeDelta', () => {
|
|
209
|
+
function asMap(nodes: Node[]): Map<string, Node> {
|
|
210
|
+
return new Map(nodes.map((n) => [`${n.x | 0},${n.y | 0},${n.w | 0},${n.h | 0}`, n]));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
test('empty-vs-empty produces all-empty delta', () => {
|
|
214
|
+
const d = computeDelta(new Map(), new Map());
|
|
215
|
+
expect(d.added.length).toBe(0);
|
|
216
|
+
expect(d.changed.length).toBe(0);
|
|
217
|
+
expect(d.removed.length).toBe(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('identical state produces no delta', () => {
|
|
221
|
+
const m = asMap([nodeRect(0, 0, 10, 10), nodeRect(0, 10, 10, 10)]);
|
|
222
|
+
const d = computeDelta(m, m);
|
|
223
|
+
expect(d.added.length).toBe(0);
|
|
224
|
+
expect(d.changed.length).toBe(0);
|
|
225
|
+
expect(d.removed.length).toBe(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('integer-rounds fingerprints so sub-pixel jitter does not register', () => {
|
|
229
|
+
const prev = asMap([{ x: 0.1, y: 0.2, w: 10.3, h: 10.4, kind: 'rect' }]);
|
|
230
|
+
const curr = asMap([{ x: 0.4, y: 0.3, w: 10.2, h: 10.1, kind: 'rect' }]);
|
|
231
|
+
// Fingerprint of both is '0,0,10,10' → matched, no diff fields differ.
|
|
232
|
+
const d = computeDelta(prev, curr);
|
|
233
|
+
expect(d.added.length).toBe(0);
|
|
234
|
+
expect(d.changed.length).toBe(0);
|
|
235
|
+
expect(d.removed.length).toBe(0);
|
|
236
|
+
});
|
|
237
|
+
});
|
package/src/base64.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UTF-8-safe base64 encoder used by every JSON attachment path
|
|
3
|
+
* (sessionTrail, stateSnapshot, replay).
|
|
4
|
+
*
|
|
5
|
+
* Why this needs its own helper:
|
|
6
|
+
* • Hermes' `globalThis.btoa` (and the WHATWG spec) is **Latin-1
|
|
7
|
+
* only** — it throws `InvalidCharacterError` on any code point
|
|
8
|
+
* > 0xFF. A wireframe NDJSON that includes a TextView with
|
|
9
|
+
* Japanese / Chinese / em-dash text triggers it; the JS-side
|
|
10
|
+
* `try / catch` then swallows the throw and the replay
|
|
11
|
+
* attachment silently never lands.
|
|
12
|
+
* • Insight 2026-05-18 rc.3 verify hit exactly this on Android —
|
|
13
|
+
* the walker fix in rc.3 surfaced deep TextView text, which
|
|
14
|
+
* then collided with the unsafe `btoa(ndjson)` path that had
|
|
15
|
+
* worked accidentally on rc.2's shallow (text-free) snapshots.
|
|
16
|
+
*
|
|
17
|
+
* The pattern `btoa(unescape(encodeURIComponent(s)))` rewrites the
|
|
18
|
+
* UTF-8 byte sequence into a Latin-1-equivalent string that btoa
|
|
19
|
+
* can chew. `unescape` is deprecated for HTML but its byte-level
|
|
20
|
+
* behaviour is stable across every JS engine we ship to.
|
|
21
|
+
*
|
|
22
|
+
* Node / bun test fallback uses `Buffer` directly.
|
|
23
|
+
*/
|
|
24
|
+
export function base64Utf8(s: string): string {
|
|
25
|
+
if (typeof globalThis.btoa === 'function') {
|
|
26
|
+
return globalThis.btoa(unescape(encodeURIComponent(s)));
|
|
27
|
+
}
|
|
28
|
+
return Buffer.from(s, 'utf8').toString('base64');
|
|
29
|
+
}
|
package/src/capture.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sealTrail, shouldSample } from '@goliapkg/sentori-core';
|
|
2
2
|
|
|
3
|
+
import { base64Utf8 } from './base64';
|
|
3
4
|
import {
|
|
4
5
|
__peekBreadcrumbCount,
|
|
5
6
|
addBreadcrumb,
|
|
@@ -8,7 +9,7 @@ import {
|
|
|
8
9
|
import { getBundleInfo } from './bundle-info';
|
|
9
10
|
import { getConfig, isInitialized } from './config';
|
|
10
11
|
import { getFeatureFlagSnapshot } from './feature-flags';
|
|
11
|
-
import { drainReplay } from './replay';
|
|
12
|
+
import { drainReplay, isReplayRunning } from './replay';
|
|
12
13
|
import { clearStateSnapshots, getStateSnapshots } from './state-snapshots';
|
|
13
14
|
import { symbolicateErrorViaMetro } from './handlers/dev-symbolicate';
|
|
14
15
|
import { captureScreenshot } from './handlers/screenshot';
|
|
@@ -143,6 +144,7 @@ export const captureError = (error: Error, extras?: CaptureExtras): void => {
|
|
|
143
144
|
'breadcrumbs=', crumbs.length,
|
|
144
145
|
'wantScreenshot=', config.screenshotsEnabled && extras?.screenshot !== false,
|
|
145
146
|
'wantSessionTrail=', config.sessionTrailEnabled,
|
|
147
|
+
'wantReplay=', isReplayRunning(),
|
|
146
148
|
);
|
|
147
149
|
}
|
|
148
150
|
|
|
@@ -184,6 +186,17 @@ export const captureError = (error: Error, extras?: CaptureExtras): void => {
|
|
|
184
186
|
const replayNdjson = drainReplay();
|
|
185
187
|
if (replayNdjson.length > 0) {
|
|
186
188
|
await captureAndAttachReplay(event, replayNdjson);
|
|
189
|
+
} else if (typeof __DEV__ !== 'undefined' && __DEV__ && isReplayRunning()) {
|
|
190
|
+
// rc.4 — explicit "replay was on but ring drained empty" signal.
|
|
191
|
+
// Without this, "kinds=screenshot,sessionTrail" looks
|
|
192
|
+
// indistinguishable from `replay: off` even though the ticks
|
|
193
|
+
// were healthy upstream. Insight 2026-05-18 verify shape made
|
|
194
|
+
// this gap painful to triage.
|
|
195
|
+
// eslint-disable-next-line no-console
|
|
196
|
+
console.warn(
|
|
197
|
+
'[sentori] replay drain empty (no frames buffered at captureException)',
|
|
198
|
+
'eventId=', event.id,
|
|
199
|
+
);
|
|
187
200
|
}
|
|
188
201
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
189
202
|
// eslint-disable-next-line no-console
|
|
@@ -202,25 +215,49 @@ export const captureError = (error: Error, extras?: CaptureExtras): void => {
|
|
|
202
215
|
|
|
203
216
|
/** v0.9.6 #2 — upload the wireframe replay ring as a `replay`
|
|
204
217
|
* attachment. Plain NDJSON (one snapshot per line) — server may
|
|
205
|
-
* gzip on storage; the network upload is base64.
|
|
218
|
+
* gzip on storage; the network upload is base64.
|
|
219
|
+
*
|
|
220
|
+
* rc.4: route through `base64Utf8` so non-Latin-1 text inside any
|
|
221
|
+
* walked TextView (Japanese / Chinese / em-dash etc.) doesn't blow
|
|
222
|
+
* up the Hermes-spec `btoa`. The pre-rc.4 inline `btoa(ndjson)` path
|
|
223
|
+
* threw `InvalidCharacterError` on those code points, the
|
|
224
|
+
* surrounding catch swallowed it silently, and the replay
|
|
225
|
+
* attachment never landed. Insight 2026-05-18 verify caught it
|
|
226
|
+
* after rc.3's walker fix surfaced deep TextView content. Dev
|
|
227
|
+
* logs replace the silent catch so the next failure shape is
|
|
228
|
+
* visible. */
|
|
206
229
|
async function captureAndAttachReplay(event: Event, ndjson: string): Promise<void> {
|
|
207
230
|
try {
|
|
208
|
-
const base64 =
|
|
209
|
-
typeof globalThis.btoa === 'function'
|
|
210
|
-
? globalThis.btoa(ndjson)
|
|
211
|
-
: Buffer.from(ndjson, 'utf8').toString('base64');
|
|
231
|
+
const base64 = base64Utf8(ndjson);
|
|
212
232
|
const meta = await uploadAttachment(
|
|
213
233
|
event.id,
|
|
214
234
|
'replay',
|
|
215
235
|
{ base64, mediaType: 'application/x-ndjson' },
|
|
216
236
|
{ source: 'js' },
|
|
217
237
|
);
|
|
218
|
-
if (meta) {
|
|
219
|
-
if (
|
|
220
|
-
|
|
238
|
+
if (!meta) {
|
|
239
|
+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
240
|
+
// eslint-disable-next-line no-console
|
|
241
|
+
console.warn(
|
|
242
|
+
'[sentori] replay upload returned null',
|
|
243
|
+
'eventId=', event.id,
|
|
244
|
+
'ndjsonBytes=', ndjson.length,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!event.attachments) event.attachments = [];
|
|
250
|
+
event.attachments.push(meta);
|
|
251
|
+
} catch (e) {
|
|
252
|
+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
253
|
+
// eslint-disable-next-line no-console
|
|
254
|
+
console.warn(
|
|
255
|
+
'[sentori] replay attachment threw',
|
|
256
|
+
'eventId=', event.id,
|
|
257
|
+
'ndjsonBytes=', ndjson.length,
|
|
258
|
+
e,
|
|
259
|
+
);
|
|
221
260
|
}
|
|
222
|
-
} catch {
|
|
223
|
-
// best-effort
|
|
224
261
|
}
|
|
225
262
|
}
|
|
226
263
|
|
|
@@ -233,11 +270,7 @@ async function captureAndAttachStateSnapshots(
|
|
|
233
270
|
): Promise<void> {
|
|
234
271
|
try {
|
|
235
272
|
const payload = JSON.stringify({ snapshots });
|
|
236
|
-
const base64 =
|
|
237
|
-
typeof globalThis.btoa === 'function'
|
|
238
|
-
? globalThis.btoa(payload)
|
|
239
|
-
: // Bun / node fallback
|
|
240
|
-
Buffer.from(payload, 'utf8').toString('base64');
|
|
273
|
+
const base64 = base64Utf8(payload);
|
|
241
274
|
const meta = await uploadAttachment(
|
|
242
275
|
event.id,
|
|
243
276
|
'stateSnapshot',
|
|
@@ -267,12 +300,7 @@ async function captureAndAttachSessionTrail(event: Event): Promise<void> {
|
|
|
267
300
|
const payload = sealTrail(trail);
|
|
268
301
|
trail.clear();
|
|
269
302
|
const json = JSON.stringify(payload);
|
|
270
|
-
|
|
271
|
-
// trick the screenshot path uses).
|
|
272
|
-
const base64 =
|
|
273
|
-
typeof globalThis.btoa === 'function'
|
|
274
|
-
? globalThis.btoa(unescape(encodeURIComponent(json)))
|
|
275
|
-
: Buffer.from(json, 'utf-8').toString('base64');
|
|
303
|
+
const base64 = base64Utf8(json);
|
|
276
304
|
const attachment = await uploadAttachment(
|
|
277
305
|
event.id,
|
|
278
306
|
'sessionTrail',
|
|
@@ -289,6 +317,12 @@ async function captureAndAttachSessionTrail(event: Event): Promise<void> {
|
|
|
289
317
|
|
|
290
318
|
export const captureException = captureError;
|
|
291
319
|
|
|
320
|
+
/** rc.4 — test hook. The real replay attach path is internal so we
|
|
321
|
+
* don't bloat the public surface, but the encoding bug Insight hit
|
|
322
|
+
* on 2026-05-18 needs a behaviour-level test that exercises the
|
|
323
|
+
* same code path captureException runs in production. */
|
|
324
|
+
export const __captureAndAttachReplayForTests = captureAndAttachReplay;
|
|
325
|
+
|
|
292
326
|
/** Phase 42 sub-D.08: per-session screenshot quota gate. */
|
|
293
327
|
function allowScreenshot(): boolean {
|
|
294
328
|
const budget = screenshotBudget();
|
package/src/index.ts
CHANGED
|
@@ -102,10 +102,13 @@ export {
|
|
|
102
102
|
} from './state-snapshots';
|
|
103
103
|
export { RageTapCapture } from './rage-tap';
|
|
104
104
|
export {
|
|
105
|
+
probeNativeScreenshot,
|
|
106
|
+
probeNativeWireframe,
|
|
105
107
|
startAnrWatchdog,
|
|
106
108
|
stopAnrWatchdog,
|
|
107
109
|
triggerNativeCrash,
|
|
108
110
|
} from './native';
|
|
111
|
+
export { drainReplay, startReplay, stopReplay } from './replay';
|
|
109
112
|
export {
|
|
110
113
|
endSession,
|
|
111
114
|
markSessionCrashed,
|