Skip to content

Commit 4cfa1af

Browse files
committed
test(core): Add tests for rage tap detection and replay converters
- New ragetap.test.ts with 10 unit tests for RageTapDetector: threshold detection, different targets, time window expiry, buffer reset, disabled mode, custom threshold/timeWindow, component name+file identity, empty path, and consecutive rage tap triggers. - 3 integration tests in touchevents.test.tsx verifying TouchEventBoundary wires the detector correctly: end-to-end detection, disabled prop, and custom threshold/timeWindow props. - Android converter test (Kotlin) and iOS converter test (Swift) for the ui.frustration breadcrumb category in RNSentryReplayBreadcrumbConverter.
1 parent 7d06010 commit 4cfa1af

4 files changed

Lines changed: 351 additions & 0 deletions

File tree

packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,34 @@ class RNSentryReplayBreadcrumbConverterTest {
9090
assertEquals(null, actual)
9191
}
9292

93+
@Test
94+
fun convertFrustrationBreadcrumb() {
95+
val converter = RNSentryReplayBreadcrumbConverter()
96+
val testBreadcrumb = Breadcrumb()
97+
testBreadcrumb.level = SentryLevel.WARNING
98+
testBreadcrumb.type = "user"
99+
testBreadcrumb.category = "ui.frustration"
100+
testBreadcrumb.message = "Rage tap detected on: Submit"
101+
testBreadcrumb.setData(
102+
"path",
103+
arrayListOf(
104+
mapOf(
105+
"name" to "SubmitButton",
106+
"label" to "Submit",
107+
"file" to "form.tsx",
108+
),
109+
),
110+
)
111+
testBreadcrumb.setData("type", "rage_tap")
112+
testBreadcrumb.setData("tapCount", 3.0)
113+
val actual = converter.convert(testBreadcrumb) as RRWebBreadcrumbEvent
114+
115+
assertRRWebBreadcrumbDefaults(actual)
116+
assertEquals(SentryLevel.WARNING, actual.level)
117+
assertEquals("ui.frustration", actual.category)
118+
assertEquals("Submit(form.tsx)", actual.message)
119+
}
120+
93121
@Test
94122
fun convertTouchBreadcrumb() {
95123
val converter = RNSentryReplayBreadcrumbConverter()

packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,33 @@ final class RNSentryReplayBreadcrumbConverterTests: XCTestCase {
100100
XCTAssertNil(actual)
101101
}
102102

103+
func testConvertFrustrationBreadcrumb() {
104+
let converter = RNSentryReplayBreadcrumbConverter()
105+
let testBreadcrumb = Breadcrumb()
106+
testBreadcrumb.timestamp = Date()
107+
testBreadcrumb.level = .warning
108+
testBreadcrumb.type = "user"
109+
testBreadcrumb.category = "ui.frustration"
110+
testBreadcrumb.message = "Rage tap detected on: Submit"
111+
testBreadcrumb.data = [
112+
"path": [
113+
["name": "SubmitButton", "label": "Submit", "file": "form.tsx"]
114+
],
115+
"type": "rage_tap",
116+
"tapCount": 3
117+
]
118+
let actual = converter.convert(from: testBreadcrumb)
119+
120+
XCTAssertNotNil(actual)
121+
let event = actual!.serialize()
122+
let data = event["data"] as! [String: Any?]
123+
let payload = data["payload"] as! [String: Any?]
124+
assertRRWebBreadcrumbDefaults(actual: event)
125+
XCTAssertEqual("warning", payload["level"] as! String)
126+
XCTAssertEqual("ui.frustration", payload["category"] as! String)
127+
XCTAssertEqual("Submit(form.tsx)", payload["message"] as! String)
128+
}
129+
103130
func testConvertTouchBreadcrumb() {
104131
let converter = RNSentryReplayBreadcrumbConverter()
105132
let testBreadcrumb = Breadcrumb()

packages/core/test/ragetap.test.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import * as core from '@sentry/core';
2+
3+
import { RageTapDetector } from '../src/js/ragetap';
4+
5+
describe('RageTapDetector', () => {
6+
let addBreadcrumb: jest.SpyInstance;
7+
8+
beforeEach(() => {
9+
jest.resetAllMocks();
10+
addBreadcrumb = jest.spyOn(core, 'addBreadcrumb');
11+
jest.spyOn(Date, 'now').mockReturnValue(1000);
12+
});
13+
14+
afterEach(() => {
15+
jest.restoreAllMocks();
16+
});
17+
18+
it('emits ui.frustration breadcrumb after 3 taps on same label', () => {
19+
const detector = new RageTapDetector();
20+
const path = [{ name: 'Button', label: 'submit' }];
21+
22+
detector.check(path, 'submit');
23+
detector.check(path, 'submit');
24+
detector.check(path, 'submit');
25+
26+
expect(addBreadcrumb).toHaveBeenCalledTimes(1);
27+
expect(addBreadcrumb).toHaveBeenCalledWith(
28+
expect.objectContaining({
29+
category: 'ui.frustration',
30+
level: 'warning',
31+
message: 'Rage tap detected on: submit',
32+
type: 'user',
33+
data: expect.objectContaining({
34+
type: 'rage_tap',
35+
tapCount: 3,
36+
label: 'submit',
37+
path,
38+
}),
39+
}),
40+
);
41+
});
42+
43+
it('does not emit for taps on different targets', () => {
44+
const detector = new RageTapDetector();
45+
46+
detector.check([{ name: 'A', label: 'a' }], 'a');
47+
detector.check([{ name: 'B', label: 'b' }], 'b');
48+
detector.check([{ name: 'A', label: 'a' }], 'a');
49+
50+
expect(addBreadcrumb).not.toHaveBeenCalled();
51+
});
52+
53+
it('does not emit when taps are outside the time window', () => {
54+
const detector = new RageTapDetector();
55+
const path = [{ name: 'Button', label: 'ok' }];
56+
const nowMock = jest.spyOn(Date, 'now');
57+
58+
nowMock.mockReturnValue(1000);
59+
detector.check(path, 'ok');
60+
61+
nowMock.mockReturnValue(1500);
62+
detector.check(path, 'ok');
63+
64+
// Third tap is beyond 1000ms from the first
65+
nowMock.mockReturnValue(2500);
66+
detector.check(path, 'ok');
67+
68+
expect(addBreadcrumb).not.toHaveBeenCalled();
69+
});
70+
71+
it('resets buffer after rage tap is detected', () => {
72+
const detector = new RageTapDetector();
73+
const path = [{ name: 'Button', label: 'ok' }];
74+
75+
// Trigger rage tap
76+
detector.check(path, 'ok');
77+
detector.check(path, 'ok');
78+
detector.check(path, 'ok');
79+
expect(addBreadcrumb).toHaveBeenCalledTimes(1);
80+
81+
// Two more taps should NOT re-trigger (buffer was reset)
82+
detector.check(path, 'ok');
83+
detector.check(path, 'ok');
84+
expect(addBreadcrumb).toHaveBeenCalledTimes(1);
85+
});
86+
87+
it('does nothing when disabled', () => {
88+
const detector = new RageTapDetector({ enabled: false });
89+
const path = [{ name: 'Button', label: 'ok' }];
90+
91+
detector.check(path, 'ok');
92+
detector.check(path, 'ok');
93+
detector.check(path, 'ok');
94+
95+
expect(addBreadcrumb).not.toHaveBeenCalled();
96+
});
97+
98+
it('respects custom threshold', () => {
99+
const detector = new RageTapDetector({ threshold: 5 });
100+
const path = [{ name: 'Button', label: 'ok' }];
101+
102+
for (let i = 0; i < 4; i++) {
103+
detector.check(path, 'ok');
104+
}
105+
expect(addBreadcrumb).not.toHaveBeenCalled();
106+
107+
detector.check(path, 'ok');
108+
expect(addBreadcrumb).toHaveBeenCalledTimes(1);
109+
expect(addBreadcrumb).toHaveBeenCalledWith(
110+
expect.objectContaining({
111+
data: expect.objectContaining({ tapCount: 5 }),
112+
}),
113+
);
114+
});
115+
116+
it('respects custom time window', () => {
117+
const detector = new RageTapDetector({ timeWindow: 500 });
118+
const path = [{ name: 'Button', label: 'ok' }];
119+
const nowMock = jest.spyOn(Date, 'now');
120+
121+
nowMock.mockReturnValue(1000);
122+
detector.check(path, 'ok');
123+
124+
nowMock.mockReturnValue(1200);
125+
detector.check(path, 'ok');
126+
127+
// 600ms after first tap — outside 500ms window
128+
nowMock.mockReturnValue(1600);
129+
detector.check(path, 'ok');
130+
131+
expect(addBreadcrumb).not.toHaveBeenCalled();
132+
});
133+
134+
it('uses component name+file as identity when no label', () => {
135+
const detector = new RageTapDetector();
136+
const path = [{ name: 'SubmitButton', file: 'form.tsx' }];
137+
138+
detector.check(path);
139+
detector.check(path);
140+
detector.check(path);
141+
142+
expect(addBreadcrumb).toHaveBeenCalledTimes(1);
143+
expect(addBreadcrumb).toHaveBeenCalledWith(
144+
expect.objectContaining({
145+
message: 'Rage tap detected on: SubmitButton (form.tsx)',
146+
data: expect.objectContaining({ label: undefined }),
147+
}),
148+
);
149+
});
150+
151+
it('treats different components with same name but different files as different targets', () => {
152+
const detector = new RageTapDetector();
153+
154+
detector.check([{ name: 'Button', file: 'a.tsx' }]);
155+
detector.check([{ name: 'Button', file: 'b.tsx' }]);
156+
detector.check([{ name: 'Button', file: 'a.tsx' }]);
157+
158+
expect(addBreadcrumb).not.toHaveBeenCalled();
159+
});
160+
161+
it('does nothing for empty touch path', () => {
162+
const detector = new RageTapDetector();
163+
164+
detector.check([]);
165+
detector.check([]);
166+
detector.check([]);
167+
168+
expect(addBreadcrumb).not.toHaveBeenCalled();
169+
});
170+
171+
it('can trigger a second rage tap after buffer reset and enough new taps', () => {
172+
const detector = new RageTapDetector();
173+
const path = [{ name: 'Button', label: 'ok' }];
174+
175+
// First rage tap
176+
detector.check(path, 'ok');
177+
detector.check(path, 'ok');
178+
detector.check(path, 'ok');
179+
expect(addBreadcrumb).toHaveBeenCalledTimes(1);
180+
181+
// Three more taps → second rage tap
182+
detector.check(path, 'ok');
183+
detector.check(path, 'ok');
184+
detector.check(path, 'ok');
185+
expect(addBreadcrumb).toHaveBeenCalledTimes(2);
186+
});
187+
});

packages/core/test/touchevents.test.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,115 @@ describe('TouchEventBoundary._onTouchStart', () => {
314314
});
315315
});
316316

317+
describe('rage tap detection', () => {
318+
beforeEach(() => {
319+
jest.spyOn(Date, 'now').mockReturnValue(1000);
320+
});
321+
322+
it('emits ui.frustration breadcrumb after 3 taps on same target', () => {
323+
const { defaultProps } = TouchEventBoundary;
324+
const boundary = new TouchEventBoundary(defaultProps);
325+
326+
const event = {
327+
_targetInst: {
328+
elementType: { displayName: 'Button' },
329+
memoizedProps: { 'sentry-label': 'submit' },
330+
},
331+
};
332+
333+
// @ts-expect-error Calling private member
334+
boundary._onTouchStart(event);
335+
// @ts-expect-error Calling private member
336+
boundary._onTouchStart(event);
337+
// @ts-expect-error Calling private member
338+
boundary._onTouchStart(event);
339+
340+
// 3 touch breadcrumbs + 1 frustration breadcrumb
341+
expect(addBreadcrumb).toHaveBeenCalledTimes(4);
342+
expect(addBreadcrumb).toHaveBeenLastCalledWith(
343+
expect.objectContaining({
344+
category: 'ui.frustration',
345+
level: 'warning',
346+
message: 'Rage tap detected on: submit',
347+
type: 'user',
348+
data: expect.objectContaining({
349+
type: 'rage_tap',
350+
tapCount: 3,
351+
label: 'submit',
352+
}),
353+
}),
354+
);
355+
});
356+
357+
it('does not emit frustration breadcrumb when disabled via prop', () => {
358+
const { defaultProps } = TouchEventBoundary;
359+
const boundary = new TouchEventBoundary({
360+
...defaultProps,
361+
enableRageTapDetection: false,
362+
});
363+
364+
const event = {
365+
_targetInst: {
366+
elementType: { displayName: 'Button' },
367+
memoizedProps: { 'sentry-label': 'submit' },
368+
},
369+
};
370+
371+
// @ts-expect-error Calling private member
372+
boundary._onTouchStart(event);
373+
// @ts-expect-error Calling private member
374+
boundary._onTouchStart(event);
375+
// @ts-expect-error Calling private member
376+
boundary._onTouchStart(event);
377+
378+
// Only touch breadcrumbs
379+
expect(addBreadcrumb).toHaveBeenCalledTimes(3);
380+
for (const call of addBreadcrumb.mock.calls) {
381+
expect(call[0].category).toBe('touch');
382+
}
383+
});
384+
385+
it('respects custom threshold and time window props', () => {
386+
const { defaultProps } = TouchEventBoundary;
387+
const boundary = new TouchEventBoundary({
388+
...defaultProps,
389+
rageTapThreshold: 5,
390+
rageTapTimeWindow: 2000,
391+
});
392+
393+
const event = {
394+
_targetInst: {
395+
elementType: { displayName: 'Button' },
396+
memoizedProps: { 'sentry-label': 'submit' },
397+
},
398+
};
399+
400+
// 3 taps should not trigger with threshold=5
401+
for (let i = 0; i < 3; i++) {
402+
// @ts-expect-error Calling private member
403+
boundary._onTouchStart(event);
404+
}
405+
expect(addBreadcrumb).toHaveBeenCalledTimes(3);
406+
for (const call of addBreadcrumb.mock.calls) {
407+
expect(call[0].category).toBe('touch');
408+
}
409+
410+
// 2 more taps (total 5) should trigger
411+
// @ts-expect-error Calling private member
412+
boundary._onTouchStart(event);
413+
// @ts-expect-error Calling private member
414+
boundary._onTouchStart(event);
415+
416+
expect(addBreadcrumb).toHaveBeenCalledTimes(6); // 5 touch + 1 frustration
417+
expect(addBreadcrumb).toHaveBeenLastCalledWith(
418+
expect.objectContaining({
419+
category: 'ui.frustration',
420+
data: expect.objectContaining({ tapCount: 5 }),
421+
}),
422+
);
423+
});
424+
});
425+
317426
describe('sentry-span-attributes', () => {
318427
it('sets custom attributes from prop on user interaction span', () => {
319428
const { defaultProps } = TouchEventBoundary;

0 commit comments

Comments
 (0)