Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add DOM collectors. #672

Merged
merged 6 commits into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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