Skip to content

Commit

Permalink
feat: identify DOM events using CSS locator (#87)
Browse files Browse the repository at this point in the history
  • Loading branch information
limhjgrace committed Feb 15, 2022
1 parent 2f43f5e commit 1c911e0
Show file tree
Hide file tree
Showing 6 changed files with 319 additions and 5 deletions.
3 changes: 3 additions & 0 deletions app/dom_event.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
<button id="button1">Button One</button>
<a> Link One </a>
<hr />
<button id="button2" label="label1">Button Two</button>
<button id="button3" label="label1">Button Three</button>
<hr />
<button id="dispatch" onclick="dispatch()">Dispatch</button>
<button id="clearRequestResponse" onclick="clearRequestResponse()">
Clear
Expand Down
4 changes: 4 additions & 0 deletions src/event-schemas/dom-event.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
"elementId": {
"type": "string",
"description": "DOM element ID."
},
"cssLocator": {
"type": "string",
"description": "CSS Locator string."
}
},
"additionalProperties": false,
Expand Down
16 changes: 15 additions & 1 deletion src/loader/loader-dom-event.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,21 @@ loader('cwr', 'abc123', '1.0', 'us-west-2', './rum_javascript_telemetry.js', {
dispatchInterval: 0,
metaDataPluginsToLoad: [],
eventPluginsToLoad: [
new DomEventPlugin({ events: [{ event: 'click', element: document }] })
new DomEventPlugin({
events: [
{ event: 'click', element: document },
{
event: 'click',
elementId: 'button1',
cssLocator: '[label="label1"]'
},
{
event: 'click',
elementId: 'button1',
element: document
}
]
})
],
telemetries: [],
clientBuilder: showRequestClientBuilder
Expand Down
27 changes: 23 additions & 4 deletions src/plugins/event-plugins/DomEventPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export type TargetDomEvent = {
*/
elementId?: string;

/**
* DOM element map to identify one element attribute and its expected value
*/
cssLocator?: string;

/**
* DOM element
*/
Expand Down Expand Up @@ -84,13 +89,16 @@ export class DomEventPlugin implements Plugin {
);
}

private getEventListener(): EventListener {
private getEventListener(cssLocator?: string): EventListener {
return (event: Event): void => {
const eventData: DomEvent = {
version: '1.0.0',
event: event.type,
elementId: this.getElementId(event)
};
if (cssLocator !== undefined) {
eventData.cssLocator = cssLocator;
}
if (this.recordEvent) {
this.recordEvent(DOM_EVENT_TYPE, eventData);
}
Expand All @@ -115,10 +123,16 @@ export class DomEventPlugin implements Plugin {

private addEventHandler(domEvent: TargetDomEvent): void {
const eventType = domEvent.event;
const eventListener = this.getEventListener();
const eventListener = this.getEventListener(domEvent.cssLocator);
this.eventListenerMap.set(domEvent, eventListener);

if (domEvent.elementId) {
// first add event listener to all elements identified by the CSS locator
if (domEvent.cssLocator) {
const elementList = document.querySelectorAll(domEvent.cssLocator);
elementList.forEach((element: HTMLElement) => {
element.addEventListener(eventType, eventListener);
});
} else if (domEvent.elementId) {
document
.getElementById(domEvent.elementId)
?.addEventListener(eventType, eventListener);
Expand All @@ -132,7 +146,12 @@ export class DomEventPlugin implements Plugin {
| EventListener
| undefined = this.eventListenerMap.get(domEvent);

if (domEvent.elementId && eventListener) {
if (domEvent.cssLocator && eventListener) {
const elementList = document.querySelectorAll(domEvent.cssLocator);
elementList.forEach((element: HTMLElement) => {
element.removeEventListener(domEvent.event, eventListener);
});
} else if (domEvent.elementId && eventListener) {
const element = document.getElementById(domEvent.elementId);
if (element) {
element.removeEventListener(domEvent.event, eventListener);
Expand Down
121 changes: 121 additions & 0 deletions src/plugins/event-plugins/__integ__/DomEventPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ const enable: Selector = Selector(`#enable`);
const button1: Selector = Selector(`#button1`);
const link1: Selector = Selector(`a`);

const button2: Selector = Selector(`#button2`);
const button3: Selector = Selector(`#button3`);

const dispatch: Selector = Selector(`#dispatch`);
const clear: Selector = Selector(`#clearRequestResponse`);

Expand Down Expand Up @@ -139,3 +142,121 @@ test('when client is disabled and enabled then button click is recorded', async
elementId: 'button1'
});
});

test('when element identified by a CSS selector is clicked then CSS selector is recorded', async (t: TestController) => {
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
await t
.wait(300)
.click(button2)
.click(dispatch)
.expect(REQUEST_BODY.textContent)
.contains('BatchId');

const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
(e) =>
e.type === DOM_EVENT_TYPE &&
JSON.parse(e.details).cssLocator === '[label="label1"]'
);

const eventType = events[0].type;
const eventDetails = JSON.parse(events[0].details);

await t
.expect(eventType)
.eql(DOM_EVENT_TYPE)
.expect(eventDetails)
.contains({
event: 'click',
cssLocator: '[label="label1"]'
});
});
test('when two elements identified by a CSS selector are clicked then CSS selector is recorded', async (t: TestController) => {
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
await t
.wait(300)
.click(button2)
.click(button3)
.click(dispatch)
.expect(REQUEST_BODY.textContent)
.contains('BatchId');

const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
(e) =>
e.type === DOM_EVENT_TYPE &&
JSON.parse(e.details).cssLocator === '[label="label1"]'
);

for (let i = 0; i < events.length; i++) {
let eventType = events[i].type;
let eventDetails = JSON.parse(events[i].details);

await t
.expect(events.length)
.eql(2)
.expect(eventType)
.eql(DOM_EVENT_TYPE)
.expect(eventDetails)
.contains({
event: 'click',
cssLocator: '[label="label1"]'
});
}
});

test('when element not identified by a CSS selector is clicked then CSS selector field is not recorded', async (t: TestController) => {
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
await t
.wait(300)
.click(button1)
.click(dispatch)
.expect(REQUEST_BODY.textContent)
.contains('BatchId');

const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
(e) =>
e.type === DOM_EVENT_TYPE &&
JSON.parse(e.details).elementId === 'button1'
);

const eventType = events[0].type;
const eventDetails = JSON.parse(events[0].details);

await t
.expect(eventType)
.eql(DOM_EVENT_TYPE)
.expect(eventDetails)
.notContains({
cssLocator: '[label="label1"]'
});
});

test('when element ID and CSS selector are specified then only event for element identified by CSS selector is recorded', async (t: TestController) => {
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
await t
.wait(300)
.click(button1)
.click(button3)
.click(dispatch)
.expect(REQUEST_BODY.textContent)
.contains('BatchId');

const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
(e) =>
e.type === DOM_EVENT_TYPE &&
JSON.parse(e.details).cssLocator === '[label="label1"]'
);
const eventType = events[0].type;
const eventDetails = JSON.parse(events[0].details);

await t
.expect(eventType)
.eql(DOM_EVENT_TYPE)
.expect(eventDetails)
.notContains({
elementId: 'button1'
});
});
153 changes: 153 additions & 0 deletions src/plugins/event-plugins/__tests__/DomEventPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,157 @@ describe('DomEventPlugin tests', () => {
})
);
});
test('when listening to document click and event target has CSS selector, element CSS selector is used as CSS selector', async () => {
// Init
document.body.innerHTML = '<button label="label1"/>';
const plugin: DomEventPlugin = new DomEventPlugin({
events: [{ event: 'click', cssLocator: '[label="label1"]' }]
});

// Run
plugin.load(context);
let element: HTMLElement = document.querySelector(
'[label="label1"]'
) as HTMLElement;
element.click();
plugin.disable();

// Assert
expect(record).toHaveBeenCalledTimes(1);
expect(record.mock.calls[0][0]).toEqual(DOM_EVENT_TYPE);
expect(record.mock.calls[0][1]).toMatchObject(
expect.objectContaining({
version: '1.0.0',
event: 'click',
cssLocator: '[label="label1"]'
})
);
});

test('when listening to document click and two event targets have the same CSS selector, element CSS selector is used as CSS selector for both', async () => {
// Init
document.body.innerHTML =
'<button label="label1"></button> <button label="label1"></button>';
const plugin: DomEventPlugin = new DomEventPlugin({
events: [{ event: 'click', cssLocator: '[label="label1"]' }]
});

// Run
plugin.load(context);
let elementList: NodeListOf<HTMLElement> = document.querySelectorAll(
'[label="label1"]'
) as NodeListOf<HTMLElement>;
for (let i = 0; i < elementList.length; i++) {
elementList[i].click();
}
plugin.disable();

// Assert
expect(record).toHaveBeenCalledTimes(2);
for (let i = 0; i < record.mock.calls.length; i++) {
expect(record.mock.calls[i][0]).toEqual(DOM_EVENT_TYPE);
expect(record.mock.calls[i][1]).toMatchObject(
expect.objectContaining({
version: '1.0.0',
event: 'click',
cssLocator: '[label="label1"]'
})
);
}
});

test('when listening to document click and CSS selector is not specified, CSS selector field not recorded as part of event data', async () => {
// Init
document.body.innerHTML = '<button/>';
const plugin: DomEventPlugin = new DomEventPlugin({
events: [{ event: 'click', element: document as any }]
});

// Run
plugin.load(context);
document.getElementsByTagName('button')[0].click();
plugin.disable();

// Assert
expect(record).toHaveBeenCalledTimes(1);
expect(record.mock.calls[0][0]).toEqual(DOM_EVENT_TYPE);
expect(record.mock.calls[0][1]).toMatchObject(
expect.objectContaining({
version: '1.0.0',
event: 'click'
})
);
expect('cssLocator' in record.mock.calls[0][1]).toEqual(false);
});

test('when listening to document click and both element ID and CSS selector is specified, only event for element identified by CSS selector is recorded', async () => {
// Init
document.body.innerHTML =
'<button id="button1"></button> <button id = "button2" label="label1"></button>';
const plugin: DomEventPlugin = new DomEventPlugin({
events: [
{
event: 'click',
elementId: 'button1',
cssLocator: '[label="label1"]'
}
]
});

// Run
plugin.load(context);
document.getElementById('button1').click();
let element: HTMLElement = document.querySelector(
'[label="label1"]'
) as HTMLElement;
element.click();
plugin.disable();

// Assert
expect(record).toHaveBeenCalledTimes(1);
// Assert
expect(record).toHaveBeenCalledTimes(1);
expect(record.mock.calls[0][0]).toEqual(DOM_EVENT_TYPE);
expect(record.mock.calls[0][1]).toMatchObject(
expect.objectContaining({
version: '1.0.0',
event: 'click',
cssLocator: '[label="label1"]'
})
);
});

test('when listening to document click and both element ID and element is specified, only event for element identified by ID is recorded', async () => {
// Init
document.body.innerHTML =
'<button id="button1"></button> <button id = "button2"></button>';
const plugin: DomEventPlugin = new DomEventPlugin({
events: [
{
event: 'click',
elementId: 'button1',
element: document as any
}
]
});

// Run
plugin.load(context);
document.getElementById('button1').click();
document.getElementById('button2').click();
plugin.disable();

// Assert
expect(record).toHaveBeenCalledTimes(1);
// Assert
expect(record).toHaveBeenCalledTimes(1);
expect(record.mock.calls[0][0]).toEqual(DOM_EVENT_TYPE);
expect(record.mock.calls[0][1]).toMatchObject(
expect.objectContaining({
version: '1.0.0',
event: 'click',
elementId: 'button1'
})
);
});
});

0 comments on commit 1c911e0

Please sign in to comment.