diff --git a/packages/core/src/tools/utils.spec.ts b/packages/core/src/tools/utils.spec.ts index 8f9c97a209..6e022a17f5 100644 --- a/packages/core/src/tools/utils.spec.ts +++ b/packages/core/src/tools/utils.spec.ts @@ -2,6 +2,7 @@ import type { Clock } from '../../test/specHelper' import { mockClock } from '../../test/specHelper' import { combine, + cssEscape, deepClone, findCommaSeparatedValue, getType, @@ -569,3 +570,12 @@ describe('startWith', () => { expect(startsWith('barfoo', 'foo')).toEqual(false) }) }) + +describe('cssEscape', () => { + it('should escape a string', () => { + expect(cssEscape('.foo#bar')).toEqual('\\.foo\\#bar') + expect(cssEscape('()[]{}')).toEqual('\\(\\)\\[\\]\\{\\}') + expect(cssEscape('--a')).toEqual('--a') + expect(cssEscape('\0')).toEqual('\ufffd') + }) +}) diff --git a/packages/core/src/tools/utils.ts b/packages/core/src/tools/utils.ts index 31273a9b6d..47d0e0f998 100644 --- a/packages/core/src/tools/utils.ts +++ b/packages/core/src/tools/utils.ts @@ -208,6 +208,14 @@ export function includes(candidate: string | unknown[], search: any) { return candidate.indexOf(search) !== -1 } +export function arrayFrom(arrayLike: ArrayLike): T[] { + const array = [] + for (let i = 0; i < arrayLike.length; i++) { + array.push(arrayLike[i]) + } + return array +} + export function find( array: T[], predicate: (item: T, index: number, array: T[]) => item is S @@ -635,3 +643,24 @@ export function removeDuplicates(array: T[]) { export function matchList(list: Array, value: string) { return list.some((item) => item === value || (item instanceof RegExp && item.test(value))) } + +// https://github.com/jquery/jquery/blob/a684e6ba836f7c553968d7d026ed7941e1a612d8/src/selector/escapeSelector.js +export function cssEscape(str: string) { + if (window.CSS && window.CSS.escape) { + return window.CSS.escape(str) + } + + // eslint-disable-next-line no-control-regex + return str.replace(/([\0-\x1f\x7f]|^-?\d)|^-$|[^\x80-\uFFFF\w-]/g, function (ch, asCodePoint) { + if (asCodePoint) { + // U+0000 NULL becomes U+FFFD REPLACEMENT CHARACTER + if (ch === '\0') { + return '\uFFFD' + } + // Control characters and (dependent upon position) numbers get escaped as code points + return `${ch.slice(0, -1)}\\${ch.charCodeAt(ch.length - 1).toString(16)} ` + } + // Other potentially-special ASCII characters get backslash-escaped + return `\\${ch}` + }) +} diff --git a/packages/core/test/specHelper.ts b/packages/core/test/specHelper.ts index 35c933dc84..ce0383bc90 100644 --- a/packages/core/test/specHelper.ts +++ b/packages/core/test/specHelper.ts @@ -311,7 +311,7 @@ class StubXhr extends StubEventEmitter { } } -export function createNewEvent(eventName: 'click', properties?: { [name: string]: unknown }): MouseEvent +export function createNewEvent

>(eventName: 'click', properties?: P): MouseEvent & P export function createNewEvent(eventName: string, properties?: { [name: string]: unknown }): Event export function createNewEvent(eventName: string, properties: { [name: string]: unknown } = {}) { let event: Event diff --git a/packages/rum-core/src/domain/rumEventsCollection/action/actionCollection.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/action/actionCollection.spec.ts index 73d43e4068..10a8a9e331 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/action/actionCollection.spec.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/action/actionCollection.spec.ts @@ -25,7 +25,8 @@ describe('actionCollection', () => { }) it('should create action from auto action', () => { const { lifeCycle, rawRumEvents } = setupBuilder.build() - const event = createNewEvent('click') + + const event = createNewEvent('click', { target: document.createElement('button') }) lifeCycle.notify(LifeCycleEventType.AUTO_ACTION_COMPLETED, { counts: { errorCount: 10, @@ -39,6 +40,12 @@ describe('actionCollection', () => { startClocks: { relative: 1234 as RelativeTime, timeStamp: 123456789 as TimeStamp }, type: ActionType.CLICK, event, + target: { + selector: '#foo', + width: 1, + height: 2, + }, + position: { x: 1, y: 2 }, }) expect(rawRumEvents[0].startTime).toBe(1234 as RelativeTime) @@ -60,6 +67,13 @@ describe('actionCollection', () => { }, target: { name: 'foo', + selector: '#foo', + width: 1, + height: 2, + }, + position: { + x: 1, + y: 2, }, type: ActionType.CLICK, }, diff --git a/packages/rum-core/src/domain/rumEventsCollection/action/actionCollection.ts b/packages/rum-core/src/domain/rumEventsCollection/action/actionCollection.ts index 189f81bb03..84881ae768 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/action/actionCollection.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/action/actionCollection.ts @@ -60,6 +60,8 @@ function processAction( ? { action: { id: action.id, + target: action.target, + position: action.position, loading_time: toServerDuration(action.duration), frustration: { type: action.frustrationTypes, diff --git a/packages/rum-core/src/domain/rumEventsCollection/action/getActionNameFromElement.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/action/getActionNameFromElement.spec.ts index 8cb7c4609a..fe048c4d00 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/action/getActionNameFromElement.spec.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/action/getActionNameFromElement.spec.ts @@ -1,65 +1,54 @@ +import { createIsolatedDOM } from '../../../../test/createIsolatedDom' import { getActionNameFromElement } from './getActionNameFromElement' describe('getActionNameFromElement', () => { - const iframes: HTMLIFrameElement[] = [] - - function element(s: TemplateStringsArray) { - // Simply using a DOMParser does not fit here, because script tags created this way are - // considered as normal markup, so they are not ignored when getting the textual content of the - // target via innerText - - const iframe = document.createElement('iframe') - iframes.push(iframe) - document.body.appendChild(iframe) - const doc = iframe.contentDocument! - doc.open() - doc.write(`${s[0]}`) - doc.close() - return doc.querySelector('[target]') || doc.body.children[0] - } + let isolatedDOM: ReturnType + + beforeEach(() => { + isolatedDOM = createIsolatedDOM() + }) afterEach(() => { - iframes.forEach((iframe) => iframe.parentNode!.removeChild(iframe)) - iframes.length = 0 + isolatedDOM.clear() }) it('extracts the textual content of an element', () => { - expect(getActionNameFromElement(element`

Foo
bar
`)).toBe('Foo bar') + expect(getActionNameFromElement(isolatedDOM.element`
Foo
bar
`)).toBe('Foo bar') }) it('extracts the text of an input button', () => { - expect(getActionNameFromElement(element``)).toBe('Click') + expect(getActionNameFromElement(isolatedDOM.element``)).toBe('Click') }) it('extracts the alt text of an image', () => { - expect(getActionNameFromElement(element`bar`)).toBe('bar') + expect(getActionNameFromElement(isolatedDOM.element`bar`)).toBe('bar') }) it('extracts the title text of an image', () => { - expect(getActionNameFromElement(element``)).toBe('foo') + expect(getActionNameFromElement(isolatedDOM.element``)).toBe('foo') }) it('extracts the text of an aria-label attribute', () => { - expect(getActionNameFromElement(element``)).toBe('Foo') + expect(getActionNameFromElement(isolatedDOM.element``)).toBe('Foo') }) it('gets the parent element textual content if everything else fails', () => { - expect(getActionNameFromElement(element`
Foo
`)).toBe('Foo') + expect(getActionNameFromElement(isolatedDOM.element`
Foo
`)).toBe('Foo') }) it("doesn't get the value of a text input", () => { - expect(getActionNameFromElement(element``)).toBe('') + expect(getActionNameFromElement(isolatedDOM.element``)).toBe('') }) it("doesn't get the value of a password input", () => { - expect(getActionNameFromElement(element``)).toBe('') + expect(getActionNameFromElement(isolatedDOM.element``)).toBe('') }) it('limits the name length to a reasonable size', () => { expect( getActionNameFromElement( // eslint-disable-next-line max-len - element`
Foooooooooooooooooo baaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaar baaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaz
` + isolatedDOM.element`
Foooooooooooooooooo baaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaar baaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaz
` ) ).toBe( // eslint-disable-next-line max-len @@ -68,20 +57,20 @@ describe('getActionNameFromElement', () => { }) it('normalize white spaces', () => { - expect(getActionNameFromElement(element`
foo\tbar\n\n baz
`)).toBe('foo bar baz') + expect(getActionNameFromElement(isolatedDOM.element`
foo\tbar\n\n baz
`)).toBe('foo bar baz') }) it('ignores the inline script textual content', () => { - expect(getActionNameFromElement(element`
b
`)).toBe('b') + expect(getActionNameFromElement(isolatedDOM.element`
b
`)).toBe('b') }) it('extracts text from SVG elements', () => { - expect(getActionNameFromElement(element`foo bar`)).toBe('foo bar') + expect(getActionNameFromElement(isolatedDOM.element`foo bar`)).toBe('foo bar') }) it('extracts text from an associated label', () => { expect( - getActionNameFromElement(element` + getActionNameFromElement(isolatedDOM.element`
ignored
@@ -93,7 +82,7 @@ describe('getActionNameFromElement', () => { it('extracts text from a parent label', () => { expect( - getActionNameFromElement(element` + getActionNameFromElement(isolatedDOM.element`