Skip to content

Commit 4473a06

Browse files
authored
feat: Add DOM collectors. (#672)
1 parent 89ce6db commit 4473a06

File tree

18 files changed

+747
-16
lines changed

18 files changed

+747
-16
lines changed
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { UiBreadcrumb } from '../../../src/api/Breadcrumb';
2+
import { Recorder } from '../../../src/api/Recorder';
3+
import ClickCollector from '../../../src/collectors/dom/ClickCollector';
4+
5+
// Mock the window object
6+
const mockAddEventListener = jest.fn();
7+
const mockRemoveEventListener = jest.fn();
8+
9+
// Mock the document object
10+
const mockDocument = {
11+
body: document.createElement('div'),
12+
};
13+
14+
// Setup global mocks
15+
Object.defineProperty(global, 'window', {
16+
value: {
17+
addEventListener: mockAddEventListener,
18+
removeEventListener: mockRemoveEventListener,
19+
},
20+
writable: true,
21+
});
22+
global.document = mockDocument as any;
23+
24+
describe('given a ClickCollector with a mock recorder', () => {
25+
let mockRecorder: Recorder;
26+
let collector: ClickCollector;
27+
let clickHandler: Function;
28+
29+
beforeEach(() => {
30+
// Reset mocks
31+
mockAddEventListener.mockReset();
32+
mockRemoveEventListener.mockReset();
33+
34+
// Capture the click handler when addEventListener is called
35+
mockAddEventListener.mockImplementation((event, handler) => {
36+
clickHandler = handler;
37+
});
38+
// Create mock recorder
39+
mockRecorder = {
40+
addBreadcrumb: jest.fn(),
41+
captureError: jest.fn(),
42+
captureErrorEvent: jest.fn(),
43+
};
44+
45+
// Create collector
46+
collector = new ClickCollector();
47+
});
48+
49+
it('adds a click event listener when created', () => {
50+
expect(mockAddEventListener).toHaveBeenCalledWith('click', expect.any(Function), true);
51+
});
52+
53+
it('registers recorder and uses it for click events', () => {
54+
// Register the recorder
55+
collector.register(mockRecorder, 'test-session');
56+
57+
// Simulate a click event
58+
const mockTarget = document.createElement('button');
59+
mockTarget.className = 'test-button';
60+
document.body.appendChild(mockTarget);
61+
const mockEvent = new MouseEvent('click', {
62+
bubbles: true,
63+
cancelable: true,
64+
});
65+
Object.defineProperty(mockEvent, 'target', { value: mockTarget });
66+
67+
// Call the captured click handler
68+
clickHandler(mockEvent);
69+
70+
// Verify breadcrumb was added with correct properties
71+
expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
72+
expect.objectContaining<UiBreadcrumb>({
73+
class: 'ui',
74+
type: 'click',
75+
level: 'info',
76+
timestamp: expect.any(Number),
77+
message: 'body > button.test-button',
78+
}),
79+
);
80+
});
81+
82+
it('stops adding breadcrumbs after unregistering', () => {
83+
// Register then unregister
84+
collector.register(mockRecorder, 'test-session');
85+
collector.unregister();
86+
// Simulate click
87+
const mockTarget = document.createElement('button');
88+
const mockEvent = new MouseEvent('click', {
89+
bubbles: true,
90+
cancelable: true,
91+
});
92+
Object.defineProperty(mockEvent, 'target', { value: mockTarget });
93+
94+
clickHandler(mockEvent);
95+
96+
expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled();
97+
});
98+
99+
it('does not add a bread crumb for a null target', () => {
100+
collector.register(mockRecorder, 'test-session');
101+
102+
const mockEvent = { target: null } as MouseEvent;
103+
clickHandler(mockEvent);
104+
105+
expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled();
106+
});
107+
});
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { UiBreadcrumb } from '../../../src/api/Breadcrumb';
2+
import { Recorder } from '../../../src/api/Recorder';
3+
import KeypressCollector from '../../../src/collectors/dom/KeypressCollector';
4+
5+
// Mock the window object
6+
const mockAddEventListener = jest.fn();
7+
const mockRemoveEventListener = jest.fn();
8+
9+
// Mock the document object
10+
const mockDocument = {
11+
body: document.createElement('div'),
12+
};
13+
14+
// Setup global mocks
15+
Object.defineProperty(global, 'window', {
16+
value: {
17+
addEventListener: mockAddEventListener,
18+
removeEventListener: mockRemoveEventListener,
19+
},
20+
writable: true,
21+
});
22+
global.document = mockDocument as any;
23+
24+
describe('given a KeypressCollector with a mock recorder', () => {
25+
let mockRecorder: Recorder;
26+
let collector: KeypressCollector;
27+
let keypressHandler: Function;
28+
29+
beforeEach(() => {
30+
// Reset mocks
31+
mockAddEventListener.mockReset();
32+
mockRemoveEventListener.mockReset();
33+
34+
// Capture the keypress handler when addEventListener is called
35+
mockAddEventListener.mockImplementation((event, handler) => {
36+
keypressHandler = handler;
37+
});
38+
39+
// Create mock recorder
40+
mockRecorder = {
41+
addBreadcrumb: jest.fn(),
42+
captureError: jest.fn(),
43+
captureErrorEvent: jest.fn(),
44+
};
45+
46+
// Create collector
47+
collector = new KeypressCollector();
48+
});
49+
50+
it('adds a keypress event listener when created', () => {
51+
expect(mockAddEventListener).toHaveBeenCalledWith('keypress', expect.any(Function), true);
52+
});
53+
54+
it('registers recorder and uses it for keypress events on input elements', () => {
55+
collector.register(mockRecorder, 'test-session');
56+
57+
const mockTarget = document.createElement('input');
58+
mockTarget.className = 'test-input';
59+
document.body.appendChild(mockTarget);
60+
const mockEvent = new KeyboardEvent('keypress');
61+
Object.defineProperty(mockEvent, 'target', { value: mockTarget });
62+
63+
keypressHandler(mockEvent);
64+
65+
expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
66+
expect.objectContaining<UiBreadcrumb>({
67+
class: 'ui',
68+
type: 'input',
69+
level: 'info',
70+
timestamp: expect.any(Number),
71+
message: 'body > input.test-input',
72+
}),
73+
);
74+
});
75+
76+
it('registers recorder and uses it for keypress events on textarea elements', () => {
77+
collector.register(mockRecorder, 'test-session');
78+
79+
const mockTarget = document.createElement('textarea');
80+
mockTarget.className = 'test-textarea';
81+
document.body.appendChild(mockTarget);
82+
const mockEvent = new KeyboardEvent('keypress');
83+
Object.defineProperty(mockEvent, 'target', { value: mockTarget });
84+
85+
keypressHandler(mockEvent);
86+
87+
expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
88+
expect.objectContaining<UiBreadcrumb>({
89+
class: 'ui',
90+
type: 'input',
91+
level: 'info',
92+
timestamp: expect.any(Number),
93+
message: 'body > textarea.test-textarea',
94+
}),
95+
);
96+
});
97+
98+
it('registers recorder and uses it for keypress events on contentEditable elements', () => {
99+
collector.register(mockRecorder, 'test-session');
100+
101+
const mockTarget = document.createElement('p');
102+
mockTarget.className = 'test-editable';
103+
mockTarget.contentEditable = 'true';
104+
// https://github.com/jsdom/jsdom/issues/1670
105+
Object.defineProperties(mockTarget, {
106+
isContentEditable: {
107+
value: true,
108+
},
109+
});
110+
document.body.appendChild(mockTarget);
111+
const mockEvent = new KeyboardEvent('keypress');
112+
Object.defineProperty(mockEvent, 'target', { value: mockTarget });
113+
114+
keypressHandler(mockEvent);
115+
116+
expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
117+
expect.objectContaining<UiBreadcrumb>({
118+
class: 'ui',
119+
type: 'input',
120+
level: 'info',
121+
timestamp: expect.any(Number),
122+
message: 'body > p.test-editable',
123+
}),
124+
);
125+
});
126+
127+
it('does not add breadcrumb for non-input non-editable elements', () => {
128+
collector.register(mockRecorder, 'test-session');
129+
130+
const mockTarget = document.createElement('div');
131+
const mockEvent = new KeyboardEvent('keypress');
132+
Object.defineProperty(mockEvent, 'target', { value: mockTarget });
133+
134+
keypressHandler(mockEvent);
135+
136+
expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled();
137+
});
138+
139+
it('stops adding breadcrumbs after unregistering', () => {
140+
collector.register(mockRecorder, 'test-session');
141+
collector.unregister();
142+
143+
const mockTarget = document.createElement('input');
144+
const mockEvent = new KeyboardEvent('keypress');
145+
Object.defineProperty(mockEvent, 'target', { value: mockTarget });
146+
147+
keypressHandler(mockEvent);
148+
149+
expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled();
150+
});
151+
152+
it('does not add a breadcrumb for a null target', () => {
153+
collector.register(mockRecorder, 'test-session');
154+
155+
const mockEvent = { target: null } as KeyboardEvent;
156+
keypressHandler(mockEvent);
157+
158+
expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled();
159+
});
160+
161+
it('deduplicates events within throttle time', () => {
162+
collector.register(mockRecorder, 'test-session');
163+
164+
const mockTarget = document.createElement('input');
165+
mockTarget.className = 'test-input';
166+
document.body.appendChild(mockTarget);
167+
const mockEvent = new KeyboardEvent('keypress');
168+
Object.defineProperty(mockEvent, 'target', { value: mockTarget });
169+
170+
// First event should be recorded
171+
keypressHandler(mockEvent);
172+
expect(mockRecorder.addBreadcrumb).toHaveBeenCalledTimes(1);
173+
174+
// Second event within throttle time should be ignored
175+
keypressHandler(mockEvent);
176+
expect(mockRecorder.addBreadcrumb).toHaveBeenCalledTimes(1);
177+
});
178+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import toSelector, { elementToString, getClassName } from '../../../src/collectors/dom/toSelector';
2+
3+
it.each([
4+
[{}, undefined],
5+
[{ className: '' }, undefined],
6+
[{ className: 'potato' }, '.potato'],
7+
[{ className: 'cheese potato' }, '.cheese.potato'],
8+
])('can format class names', (element: any, expected?: string) => {
9+
expect(getClassName(element)).toBe(expected);
10+
});
11+
12+
it.each([
13+
[{}, ''],
14+
[{ tagName: 'DIV' }, 'div'],
15+
[{ tagName: 'P', id: 'test' }, 'p#test'],
16+
[{ tagName: 'P', className: 'bold' }, 'p.bold'],
17+
[{ tagName: 'P', className: 'bold', id: 'test' }, 'p#test.bold'],
18+
])('can format an element as a string', (element: any, expected: string) => {
19+
expect(elementToString(element)).toBe(expected);
20+
});
21+
22+
it.each([
23+
[{}, ''],
24+
[undefined, ''],
25+
[null, ''],
26+
['toaster', ''],
27+
[
28+
{
29+
tagName: 'BODY',
30+
parentNode: {
31+
tagName: 'HTML',
32+
},
33+
},
34+
'body',
35+
],
36+
[
37+
{
38+
tagName: 'DIV',
39+
parentNode: {
40+
tagName: 'BODY',
41+
parentNode: {
42+
tagName: 'HTML',
43+
},
44+
},
45+
},
46+
'body > div',
47+
],
48+
[
49+
{
50+
tagName: 'DIV',
51+
className: 'cheese taco',
52+
id: 'taco',
53+
parentNode: {
54+
tagName: 'BODY',
55+
parentNode: {
56+
tagName: 'HTML',
57+
},
58+
},
59+
},
60+
'body > div#taco.cheese.taco',
61+
],
62+
])('can produce a CSS selector from a dom element', (element: any, expected: string) => {
63+
expect(toSelector(element)).toBe(expected);
64+
});
65+
66+
it('respects max depth', () => {
67+
const element = {
68+
tagName: 'DIV',
69+
className: 'cheese taco',
70+
id: 'taco',
71+
parentNode: {
72+
tagName: 'P',
73+
parentNode: {
74+
tagName: 'BODY',
75+
parentNode: {
76+
tagName: 'HTML',
77+
},
78+
},
79+
},
80+
};
81+
82+
expect(toSelector(element, { maxDepth: 1 })).toBe('div#taco.cheese.taco');
83+
expect(toSelector(element, { maxDepth: 2 })).toBe('p > div#taco.cheese.taco');
84+
});

packages/telemetry/browser-telemetry/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
"description": "Telemetry integration for LaunchDarkly browser SDKs.",
2525
"scripts": {
2626
"test": "npx jest --runInBand",
27-
"build": "tsup",
27+
"build": "tsc --noEmit && tsup",
2828
"prettier": "prettier --write 'src/*.@(js|ts|tsx|json)'",
2929
"check": "yarn && yarn prettier && yarn lint && tsc && yarn test",
3030
"lint": "npx eslint . --ext .ts"

0 commit comments

Comments
 (0)