Skip to content

Commit

Permalink
feat: Add DOM collectors. (#672)
Browse files Browse the repository at this point in the history
  • Loading branch information
kinyoklion authored Nov 8, 2024
1 parent 89ce6db commit 4473a06
Show file tree
Hide file tree
Showing 18 changed files with 747 additions and 16 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { UiBreadcrumb } from '../../../src/api/Breadcrumb';
import { Recorder } from '../../../src/api/Recorder';
import ClickCollector from '../../../src/collectors/dom/ClickCollector';

// Mock the window object
const mockAddEventListener = jest.fn();
const mockRemoveEventListener = jest.fn();

// Mock the document object
const mockDocument = {
body: document.createElement('div'),
};

// Setup global mocks
Object.defineProperty(global, 'window', {
value: {
addEventListener: mockAddEventListener,
removeEventListener: mockRemoveEventListener,
},
writable: true,
});
global.document = mockDocument as any;

describe('given a ClickCollector with a mock recorder', () => {
let mockRecorder: Recorder;
let collector: ClickCollector;
let clickHandler: Function;

beforeEach(() => {
// Reset mocks
mockAddEventListener.mockReset();
mockRemoveEventListener.mockReset();

// Capture the click handler when addEventListener is called
mockAddEventListener.mockImplementation((event, handler) => {
clickHandler = handler;
});
// Create mock recorder
mockRecorder = {
addBreadcrumb: jest.fn(),
captureError: jest.fn(),
captureErrorEvent: jest.fn(),
};

// Create collector
collector = new ClickCollector();
});

it('adds a click event listener when created', () => {
expect(mockAddEventListener).toHaveBeenCalledWith('click', expect.any(Function), true);
});

it('registers recorder and uses it for click events', () => {
// Register the recorder
collector.register(mockRecorder, 'test-session');

// Simulate a click event
const mockTarget = document.createElement('button');
mockTarget.className = 'test-button';
document.body.appendChild(mockTarget);
const mockEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
});
Object.defineProperty(mockEvent, 'target', { value: mockTarget });

// Call the captured click handler
clickHandler(mockEvent);

// Verify breadcrumb was added with correct properties
expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining<UiBreadcrumb>({
class: 'ui',
type: 'click',
level: 'info',
timestamp: expect.any(Number),
message: 'body > button.test-button',
}),
);
});

it('stops adding breadcrumbs after unregistering', () => {
// Register then unregister
collector.register(mockRecorder, 'test-session');
collector.unregister();
// Simulate click
const mockTarget = document.createElement('button');
const mockEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
});
Object.defineProperty(mockEvent, 'target', { value: mockTarget });

clickHandler(mockEvent);

expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled();
});

it('does not add a bread crumb for a null target', () => {
collector.register(mockRecorder, 'test-session');

const mockEvent = { target: null } as MouseEvent;
clickHandler(mockEvent);

expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { UiBreadcrumb } from '../../../src/api/Breadcrumb';
import { Recorder } from '../../../src/api/Recorder';
import KeypressCollector from '../../../src/collectors/dom/KeypressCollector';

// Mock the window object
const mockAddEventListener = jest.fn();
const mockRemoveEventListener = jest.fn();

// Mock the document object
const mockDocument = {
body: document.createElement('div'),
};

// Setup global mocks
Object.defineProperty(global, 'window', {
value: {
addEventListener: mockAddEventListener,
removeEventListener: mockRemoveEventListener,
},
writable: true,
});
global.document = mockDocument as any;

describe('given a KeypressCollector with a mock recorder', () => {
let mockRecorder: Recorder;
let collector: KeypressCollector;
let keypressHandler: Function;

beforeEach(() => {
// Reset mocks
mockAddEventListener.mockReset();
mockRemoveEventListener.mockReset();

// Capture the keypress handler when addEventListener is called
mockAddEventListener.mockImplementation((event, handler) => {
keypressHandler = handler;
});

// Create mock recorder
mockRecorder = {
addBreadcrumb: jest.fn(),
captureError: jest.fn(),
captureErrorEvent: jest.fn(),
};

// Create collector
collector = new KeypressCollector();
});

it('adds a keypress event listener when created', () => {
expect(mockAddEventListener).toHaveBeenCalledWith('keypress', expect.any(Function), true);
});

it('registers recorder and uses it for keypress events on input elements', () => {
collector.register(mockRecorder, 'test-session');

const mockTarget = document.createElement('input');
mockTarget.className = 'test-input';
document.body.appendChild(mockTarget);
const mockEvent = new KeyboardEvent('keypress');
Object.defineProperty(mockEvent, 'target', { value: mockTarget });

keypressHandler(mockEvent);

expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining<UiBreadcrumb>({
class: 'ui',
type: 'input',
level: 'info',
timestamp: expect.any(Number),
message: 'body > input.test-input',
}),
);
});

it('registers recorder and uses it for keypress events on textarea elements', () => {
collector.register(mockRecorder, 'test-session');

const mockTarget = document.createElement('textarea');
mockTarget.className = 'test-textarea';
document.body.appendChild(mockTarget);
const mockEvent = new KeyboardEvent('keypress');
Object.defineProperty(mockEvent, 'target', { value: mockTarget });

keypressHandler(mockEvent);

expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining<UiBreadcrumb>({
class: 'ui',
type: 'input',
level: 'info',
timestamp: expect.any(Number),
message: 'body > textarea.test-textarea',
}),
);
});

it('registers recorder and uses it for keypress events on contentEditable elements', () => {
collector.register(mockRecorder, 'test-session');

const mockTarget = document.createElement('p');
mockTarget.className = 'test-editable';
mockTarget.contentEditable = 'true';
// https://github.com/jsdom/jsdom/issues/1670
Object.defineProperties(mockTarget, {
isContentEditable: {
value: true,
},
});
document.body.appendChild(mockTarget);
const mockEvent = new KeyboardEvent('keypress');
Object.defineProperty(mockEvent, 'target', { value: mockTarget });

keypressHandler(mockEvent);

expect(mockRecorder.addBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining<UiBreadcrumb>({
class: 'ui',
type: 'input',
level: 'info',
timestamp: expect.any(Number),
message: 'body > p.test-editable',
}),
);
});

it('does not add breadcrumb for non-input non-editable elements', () => {
collector.register(mockRecorder, 'test-session');

const mockTarget = document.createElement('div');
const mockEvent = new KeyboardEvent('keypress');
Object.defineProperty(mockEvent, 'target', { value: mockTarget });

keypressHandler(mockEvent);

expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled();
});

it('stops adding breadcrumbs after unregistering', () => {
collector.register(mockRecorder, 'test-session');
collector.unregister();

const mockTarget = document.createElement('input');
const mockEvent = new KeyboardEvent('keypress');
Object.defineProperty(mockEvent, 'target', { value: mockTarget });

keypressHandler(mockEvent);

expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled();
});

it('does not add a breadcrumb for a null target', () => {
collector.register(mockRecorder, 'test-session');

const mockEvent = { target: null } as KeyboardEvent;
keypressHandler(mockEvent);

expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled();
});

it('deduplicates events within throttle time', () => {
collector.register(mockRecorder, 'test-session');

const mockTarget = document.createElement('input');
mockTarget.className = 'test-input';
document.body.appendChild(mockTarget);
const mockEvent = new KeyboardEvent('keypress');
Object.defineProperty(mockEvent, 'target', { value: mockTarget });

// First event should be recorded
keypressHandler(mockEvent);
expect(mockRecorder.addBreadcrumb).toHaveBeenCalledTimes(1);

// Second event within throttle time should be ignored
keypressHandler(mockEvent);
expect(mockRecorder.addBreadcrumb).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import toSelector, { elementToString, getClassName } from '../../../src/collectors/dom/toSelector';

it.each([
[{}, undefined],
[{ className: '' }, undefined],
[{ className: 'potato' }, '.potato'],
[{ className: 'cheese potato' }, '.cheese.potato'],
])('can format class names', (element: any, expected?: string) => {
expect(getClassName(element)).toBe(expected);
});

it.each([
[{}, ''],
[{ tagName: 'DIV' }, 'div'],
[{ tagName: 'P', id: 'test' }, 'p#test'],
[{ tagName: 'P', className: 'bold' }, 'p.bold'],
[{ tagName: 'P', className: 'bold', id: 'test' }, 'p#test.bold'],
])('can format an element as a string', (element: any, expected: string) => {
expect(elementToString(element)).toBe(expected);
});

it.each([
[{}, ''],
[undefined, ''],
[null, ''],
['toaster', ''],
[
{
tagName: 'BODY',
parentNode: {
tagName: 'HTML',
},
},
'body',
],
[
{
tagName: 'DIV',
parentNode: {
tagName: 'BODY',
parentNode: {
tagName: 'HTML',
},
},
},
'body > div',
],
[
{
tagName: 'DIV',
className: 'cheese taco',
id: 'taco',
parentNode: {
tagName: 'BODY',
parentNode: {
tagName: 'HTML',
},
},
},
'body > div#taco.cheese.taco',
],
])('can produce a CSS selector from a dom element', (element: any, expected: string) => {
expect(toSelector(element)).toBe(expected);
});

it('respects max depth', () => {
const element = {
tagName: 'DIV',
className: 'cheese taco',
id: 'taco',
parentNode: {
tagName: 'P',
parentNode: {
tagName: 'BODY',
parentNode: {
tagName: 'HTML',
},
},
},
};

expect(toSelector(element, { maxDepth: 1 })).toBe('div#taco.cheese.taco');
expect(toSelector(element, { maxDepth: 2 })).toBe('p > div#taco.cheese.taco');
});
2 changes: 1 addition & 1 deletion packages/telemetry/browser-telemetry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"description": "Telemetry integration for LaunchDarkly browser SDKs.",
"scripts": {
"test": "npx jest --runInBand",
"build": "tsup",
"build": "tsc --noEmit && tsup",
"prettier": "prettier --write 'src/*.@(js|ts|tsx|json)'",
"check": "yarn && yarn prettier && yarn lint && tsc && yarn test",
"lint": "npx eslint . --ext .ts"
Expand Down
Loading

0 comments on commit 4473a06

Please sign in to comment.