diff --git a/test/e2e/lib/helpers/recorder.ts b/test/e2e/lib/helpers/recorder.ts index d2ddd738ca..7ba5397632 100644 --- a/test/e2e/lib/helpers/recorder.ts +++ b/test/e2e/lib/helpers/recorder.ts @@ -1,9 +1,9 @@ import { NodeType, SerializedNode, - DocumentNode, ElementNode, TextNode, + SerializedNodeWithId, } from '@datadog/browser-rum-recorder/cjs/domain/rrweb-snapshot/types' import { Segment, @@ -12,7 +12,9 @@ import { MetaRecord, IncrementalSnapshotRecord, IncrementalSource, + MutationData, } from '@datadog/browser-rum-recorder/cjs/types' +import { EventRegistry } from '../framework' // Returns this first MetaRecord in a Segment, if any. export function findMeta(segment: Segment): MetaRecord | null { @@ -40,41 +42,199 @@ export function findAllIncrementalSnapshots(segment: Segment, source: Incrementa ) as IncrementalSnapshotRecord[] } -// Retrns the textContent of a ElementNode, if any. +// Returns the textContent of a ElementNode, if any. export function findTextContent(elem: ElementNode): string | null { const text = elem.childNodes.find((child) => child.type === NodeType.Text) as TextNode return text ? text.textContent : null } -// Returns the first ElementNode with the given ID from a -// FullSnapshotRecord, if any. -export function findNodeWithId(fullSnapshot: FullSnapshotRecord, id: string): ElementNode | null { - return recFindNodeWithId(fullSnapshot.data.node as DocumentNode, id) +// Returns the first ElementNode with the given ID attribute from a FullSnapshotRecord, if any. +export function findElementWithIdAttribute(fullSnapshot: FullSnapshotRecord, id: string) { + return findElement(fullSnapshot.data.node, (node) => node.attributes.id === id) +} + +// Returns the first ElementNode with the given tag name from a FullSnapshotRecord, if any. +export function findElementWithTagName(fullSnapshot: FullSnapshotRecord, tagName: string) { + return findElement(fullSnapshot.data.node, (node) => node.tagName === tagName) +} + +// Returns the first TextNode with the given content from a FullSnapshotRecord, if any. +export function findTextNode(fullSnapshot: FullSnapshotRecord, textContent: string) { + return findNode(fullSnapshot.data.node, (node) => isTextNode(node) && node.textContent === textContent) as + | (TextNode & { id: number }) + | null +} + +// Returns the first ElementNode matching the predicate +export function findElement(root: SerializedNodeWithId, predicate: (node: ElementNode) => boolean) { + return findNode(root, (node) => isElementNode(node) && predicate(node)) as (ElementNode & { id: number }) | null +} + +// Returns the first SerializedNodeWithId matching the predicate +export function findNode( + node: SerializedNodeWithId, + predicate: (node: SerializedNodeWithId) => boolean +): SerializedNodeWithId | null { + if (predicate(node)) { + return node + } + + if ('childNodes' in node) { + for (const child of node.childNodes) { + const node = findNode(child, predicate) + if (node !== null) { + return node + } + } + } + return null } function isElementNode(node: SerializedNode): node is ElementNode { return node.type === NodeType.Element } -function recFindNodeWithId(node: DocumentNode | ElementNode | null, id: string): ElementNode | null { - if (node === null) { - return null +function isTextNode(node: SerializedNode): node is TextNode { + return node.type === NodeType.Text +} + +interface NodeSelector { + // Select the first node with the given tag name from the initial full snapshot + tag?: string + // Select the first node with the given id attribute from the initial full snapshot + idAttribute?: string + // Select the first node with the given text content from the initial full snapshot + text?: string + // Select a node created by a previous 'AddedNodeMutation' (0 being the first node created, 1 the + // second one, etc.) + created?: number +} + +interface ExpectedTextMutation { + // Reference to the node where the mutation happens + node: NodeSelector + // New text value + value: string +} + +interface ExpectedAttributeMutation { + // Reference to the node where the mutation happens + node: NodeSelector + // Updated attributes + attributes: { + [key: string]: string | null } +} - if (isElementNode(node) && node.attributes.id === id) { - return node +interface ExpectedRemoveMutation { + // Reference to the removed node + node: NodeSelector + // Reference to the parent of the removed node + parent: NodeSelector +} + +interface ExpectedAddMutation { + // Partially check for the added node properties. The 'id' is always checked automatically. If + // 'from' is specified, it will base the assertion on a node from the initial full snapshot or a + // previously created node. Else, it will consider this node as a newly created node. + node: { from?: NodeSelector } & Partial + // Reference to the parent of the added node + parent: NodeSelector + // Reference to the sibling of the added node + next?: NodeSelector +} + +/** + * Validate the first and only mutation record of a segment against the expected text, attribute, + * add and remove mutations. + */ +export function validateMutations( + events: EventRegistry, + expected: { + texts?: ExpectedTextMutation[] + attributes?: ExpectedAttributeMutation[] + removes?: ExpectedRemoveMutation[] + adds?: ExpectedAddMutation[] } +) { + expect(events.sessionReplay.length).toBe(1) + const segment = events.sessionReplay[0].segment.data + const fullSnapshot = findFullSnapshot(segment)! + + const mutations = findAllIncrementalSnapshots(segment, IncrementalSource.Mutation) as Array<{ + data: MutationData + }> + + expect(mutations.length).toBe(1) - for (const child of node.childNodes) { - if (!isElementNode(child)) { - continue + const createdNodes: SerializedNodeWithId[] = [] + const maxNodeIdFromFullSnapshot = findMaxNodeId(fullSnapshot.data.node) + expect(mutations[0].data.adds).toEqual( + (expected.adds || []).map(({ node: { from, ...partialNode }, parent, next }, index) => { + let expectedNode: SerializedNodeWithId | jasmine.ObjectContaining> + + if (from) { + // Add a previously created node + expectedNode = { ...selectNode(from), ...partialNode } as SerializedNodeWithId + } else { + // Add a new node + expectedNode = jasmine.objectContaining>({ + ...partialNode, + id: maxNodeIdFromFullSnapshot + createdNodes.length + 1, + }) + // Register the newly created node for future reference + createdNodes.push(mutations[0].data.adds[index].node) + } + + return { + node: expectedNode, + parentId: selectNode(parent).id, + nextId: next ? selectNode(next).id : null, + } + }) + ) + expect(mutations[0].data.texts).toEqual( + (expected.texts || []).map(({ node, value }) => ({ id: selectNode(node).id, value })) + ) + expect(mutations[0].data.removes).toEqual( + (expected.removes || []).map(({ node, parent }) => ({ + id: selectNode(node).id, + parentId: selectNode(parent).id, + })) + ) + expect(mutations[0].data.attributes).toEqual( + (expected.attributes || []).map(({ node, attributes }) => ({ + id: selectNode(node).id, + attributes, + })) + ) + + function selectNode(selector: NodeSelector) { + let node + if (selector.text) { + node = findTextNode(fullSnapshot, selector.text) + } else if (selector.idAttribute) { + node = findElementWithIdAttribute(fullSnapshot, selector.idAttribute) + } else if (selector.tag) { + node = findElementWithTagName(fullSnapshot, selector.tag) + } else if (selector.created !== undefined) { + node = createdNodes[selector.created] + } else { + throw new Error('Empty selector') } - const node = recFindNodeWithId(child, id) - if (node !== null) { - return node + if (!node) { + throw new Error(`Cannot find node from selector ${JSON.stringify(selector)}`) } + + return node } - return null + function findMaxNodeId(root: SerializedNodeWithId): number { + if ('childNodes' in root) { + return Math.max(root.id, ...root.childNodes.map((child) => findMaxNodeId(child))) + } + + return root.id + } } diff --git a/test/e2e/lib/helpers/sdk.ts b/test/e2e/lib/helpers/sdk.ts index c77e2421cd..fe3c4bfaa5 100644 --- a/test/e2e/lib/helpers/sdk.ts +++ b/test/e2e/lib/helpers/sdk.ts @@ -10,7 +10,22 @@ export async function flushEvents() { ) await waitForServersIdle() const servers = await getTestServers() - await browser.url(`${servers.base.url}/empty`) + + // TODO: use /empty instead of /ok + // + // The rum-recorder code uses a Web Worker to format the request data to be sent to the intake. + // Because all Worker communication is asynchronous, it cannot send its request during the + // "beforeunload" event, but a few milliseconds after. Thus, when navigating, if the future page + // loads very quickly, the page unload may occur before rum-recorder have time to send its last + // segment. + // + // To avoid flaky e2e tests, we currently use /ok with a duration, to allow a bit more time to + // send requests to intakes when the "beforeunload" event is dispatched. + // + // The issue mainly occurs with local e2e tests (not browserstack), because the network latency is + // very low (same machine), so the request resolves very quickly. In real life conditions, this + // issue is mitigated, because requests will likely take a few milliseconds to reach the server. + await browser.url(`${servers.base.url}/ok?duration=200`) await waitForServersIdle() } diff --git a/test/e2e/scenario/recorder.scenario.ts b/test/e2e/scenario/recorder.scenario.ts index 9a731a702f..421f24fe86 100644 --- a/test/e2e/scenario/recorder.scenario.ts +++ b/test/e2e/scenario/recorder.scenario.ts @@ -1,16 +1,18 @@ -import { CreationReason, IncrementalSource } from '@datadog/browser-rum-recorder/cjs/types' -import { InputData } from '@datadog/browser-rum-recorder/cjs/domain/rrweb/types' +import { CreationReason, IncrementalSource, Segment } from '@datadog/browser-rum-recorder/cjs/types' +import { InputData, StyleSheetRuleData } from '@datadog/browser-rum-recorder/cjs/domain/rrweb/types' +import { NodeType } from '@datadog/browser-rum-recorder/cjs/domain/rrweb-snapshot' import { createTest, bundleSetup, html } from '../lib/framework' import { browserExecute } from '../lib/helpers/browser' import { flushEvents } from '../lib/helpers/sdk' import { - findNodeWithId, + findElementWithIdAttribute, findFullSnapshot, findIncrementalSnapshot, findAllIncrementalSnapshots, findMeta, findTextContent, + validateMutations, } from '../lib/helpers/recorder' const INTEGER_RE = /^\d+$/ @@ -79,16 +81,16 @@ describe('recorder', () => { const fullSnapshot = findFullSnapshot(events.sessionReplay[0].segment.data)! - const fooNode = findNodeWithId(fullSnapshot, 'foo') + const fooNode = findElementWithIdAttribute(fullSnapshot, 'foo') expect(fooNode).toBeTruthy() expect(findTextContent(fooNode!)).toBe('foo') - const barNode = findNodeWithId(fullSnapshot, 'bar') + const barNode = findElementWithIdAttribute(fullSnapshot, 'bar') expect(barNode).toBeTruthy() expect(barNode!.attributes['data-dd-privacy']).toBe('hidden') expect(barNode!.childNodes.length).toBe(0) - const bazNode = findNodeWithId(fullSnapshot, 'baz') + const bazNode = findElementWithIdAttribute(fullSnapshot, 'baz') expect(bazNode).toBeTruthy() expect(bazNode!.attributes.class).toBe('dd-privacy-hidden baz') expect(bazNode!.attributes['data-dd-privacy']).toBe('hidden') @@ -96,8 +98,432 @@ describe('recorder', () => { }) }) + describe('mutations observer', () => { + createTest('record mutations') + .withSetup(bundleSetup) + .withRumRecorder() + .withBody( + html` +

mutation observer

+
    +
  • +
+ ` + ) + .run(async ({ events }) => { + await browserExecute(() => { + const li = document.createElement('li') + const ul = document.querySelector('ul') as HTMLUListElement + + // Make sure mutations occurring in a removed element are not reported + ul.appendChild(li) + document.body.removeChild(ul) + + const p = document.querySelector('p') as HTMLParagraphElement + p.appendChild(document.createElement('span')) + }) + + await flushEvents() + + validateMutations(events, { + adds: [ + { + parent: { tag: 'p' }, + node: { tagName: 'span' }, + }, + ], + removes: [ + { + parent: { tag: 'body' }, + node: { tag: 'ul' }, + }, + ], + }) + }) + + createTest('record character data mutations') + .withSetup(bundleSetup) + .withRumRecorder() + .withBody( + html` +

mutation observer

+
    +
  • +
+ ` + ) + .run(async ({ events }) => { + await browserExecute(() => { + const li = document.createElement('li') + const ul = document.querySelector('ul') as HTMLUListElement + + // Make sure mutations occurring in a removed element are not reported + ul.appendChild(li) + li.innerText = 'new list item' + li.innerText = 'new list item edit' + document.body.removeChild(ul) + + const p = document.querySelector('p') as HTMLParagraphElement + p.innerText = 'mutated' + }) + + await flushEvents() + + validateMutations(events, { + adds: [ + { + parent: { tag: 'p' }, + node: { type: NodeType.Text, textContent: 'mutated' }, + }, + ], + removes: [ + { + parent: { tag: 'body' }, + node: { tag: 'ul' }, + }, + { + parent: { tag: 'p' }, + node: { text: 'mutation observer' }, + }, + ], + }) + }) + + createTest('record attributes mutations') + .withSetup(bundleSetup) + .withRumRecorder() + .withBody( + html` +

mutation observer

+
    +
  • +
+ ` + ) + .run(async ({ events }) => { + await browserExecute(() => { + const li = document.createElement('li') + const ul = document.querySelector('ul') as HTMLUListElement + + // Make sure mutations occurring in a removed element are not reported + ul.appendChild(li) + li.setAttribute('foo', 'bar') + document.body.removeChild(ul) + + document.body.setAttribute('test', 'true') + }) + + await flushEvents() + + validateMutations(events, { + attributes: [ + { + node: { tag: 'body' }, + attributes: { test: 'true' }, + }, + ], + removes: [ + { + parent: { tag: 'body' }, + node: { tag: 'ul' }, + }, + ], + }) + }) + + createTest("don't record hidden elements mutations") + .withSetup(bundleSetup) + .withRumRecorder() + .withBody( + html` +
+
    +
  • +
+
+ ` + ) + .run(async ({ events }) => { + await browserExecute(() => { + document.querySelector('div')!.setAttribute('foo', 'bar') + document.querySelector('li')!.textContent = 'hop' + document.querySelector('div')!.appendChild(document.createElement('p')) + }) + + await flushEvents() + + expect(events.sessionReplay.length).toBe(1) + const segment = events.sessionReplay[0].segment.data + + expect(findAllIncrementalSnapshots(segment, IncrementalSource.Mutation)).toEqual([]) + }) + + createTest('record DOM node movement 1') + .withSetup(bundleSetup) + .withRumRecorder() + .withBody( + // prettier-ignore + html` +
a

b
+ cdefg + ` + ) + .run(async ({ events }) => { + await browserExecute(() => { + const div = document.querySelector('div')! + const p = document.querySelector('p')! + const span = document.querySelector('span')! + document.body.removeChild(span) + p.appendChild(span) + p.removeChild(span) + div.appendChild(span) + }) + + await flushEvents() + + validateMutations(events, { + adds: [ + { + parent: { tag: 'div' }, + node: { from: { tag: 'span' }, childNodes: [] }, + }, + { + next: { tag: 'i' }, + parent: { tag: 'span' }, + node: { from: { text: 'c' } }, + }, + { + next: { text: 'g' }, + parent: { tag: 'span' }, + node: { from: { tag: 'i' }, childNodes: [] }, + }, + { + next: { tag: 'b' }, + parent: { tag: 'i' }, + node: { from: { text: 'd' } }, + }, + { + next: { text: 'f' }, + parent: { tag: 'i' }, + node: { from: { tag: 'b' }, childNodes: [] }, + }, + { + parent: { tag: 'b' }, + node: { from: { text: 'e' } }, + }, + { + parent: { tag: 'i' }, + node: { from: { text: 'f' } }, + }, + { + parent: { tag: 'span' }, + node: { from: { text: 'g' } }, + }, + ], + removes: [ + { + parent: { tag: 'body' }, + node: { tag: 'span' }, + }, + ], + }) + }) + + createTest('record DOM node movement 2') + .withSetup(bundleSetup) + .withRumRecorder() + .withBody( + // prettier-ignore + html` + cdefg + ` + ) + .run(async ({ events }) => { + await browserExecute(() => { + const div = document.createElement('div') + const span = document.querySelector('span')! + document.body.appendChild(div) + div.appendChild(span) + }) + + await flushEvents() + + validateMutations(events, { + adds: [ + { + next: { tag: 'i' }, + parent: { tag: 'span' }, + node: { from: { text: 'c' } }, + }, + { + next: { text: 'g' }, + parent: { tag: 'span' }, + node: { from: { tag: 'i' }, childNodes: [] }, + }, + { + next: { tag: 'b' }, + parent: { tag: 'i' }, + node: { from: { text: 'd' } }, + }, + { + next: { text: 'f' }, + parent: { tag: 'i' }, + node: { from: { tag: 'b' }, childNodes: [] }, + }, + { + parent: { tag: 'b' }, + node: { from: { text: 'e' } }, + }, + { + parent: { tag: 'i' }, + node: { from: { text: 'f' } }, + }, + { + parent: { tag: 'span' }, + node: { from: { text: 'g' } }, + }, + { + parent: { tag: 'body' }, + node: { tagName: 'div' }, + }, + { + parent: { created: 0 }, + node: { from: { tag: 'span' }, childNodes: [] }, + }, + ], + removes: [ + { + parent: { tag: 'body' }, + node: { tag: 'span' }, + }, + ], + }) + }) + + createTest('serialize node before record') + .withSetup(bundleSetup) + .withRumRecorder() + .withBody( + // prettier-ignore + html` +
+ ` + ) + .run(async ({ events }) => { + await browserExecute(() => { + const ul = document.querySelector('ul') as HTMLUListElement + let count = 3 + while (count > 0) { + count-- + const li = document.createElement('li') + ul.appendChild(li) + } + }) + + await flushEvents() + + validateMutations(events, { + adds: [ + { + parent: { tag: 'ul' }, + node: { tagName: 'li' }, + }, + { + next: { created: 0 }, + parent: { tag: 'ul' }, + node: { tagName: 'li' }, + }, + { + next: { created: 1 }, + parent: { tag: 'ul' }, + node: { tagName: 'li' }, + }, + ], + }) + }) + }) + describe('input observers', () => { - createTest('record input when not to be ignored') + createTest('record input interactions') + .withSetup(bundleSetup) + .withRumRecorder() + .withBody( + html` +
+ + + + + +
+ ` + ) + .run(async ({ events }) => { + const textInput = await $('#text-input') + await textInput.setValue('test') + + const radioInput = await $('#radio-input') + await radioInput.click() + + const checkboxInput = await $('#checkbox-input') + await checkboxInput.click() + + const textarea = await $('#textarea') + await textarea.setValue('textarea test') + + const select = await $('#select') + await select.selectByAttribute('value', '2') + + await flushEvents() + + expect(events.sessionReplay.length).toBe(1) + + const segment = events.sessionReplay[0].segment.data + + const textInputRecords = filterRecordsByIdAttribute(segment, 'text-input') + expect(textInputRecords.length).toBeGreaterThanOrEqual(4) + expect(textInputRecords[textInputRecords.length - 1].data.text).toBe('test') + + const radioInputRecords = filterRecordsByIdAttribute(segment, 'radio-input') + expect(radioInputRecords.length).toBe(1) + expect(radioInputRecords[0].data.text).toBe('on') + expect(radioInputRecords[0].data.isChecked).toBe(true) + + const checkboxInputRecords = filterRecordsByIdAttribute(segment, 'checkbox-input') + expect(checkboxInputRecords.length).toBe(1) + expect(checkboxInputRecords[0].data.text).toBe('on') + expect(checkboxInputRecords[0].data.isChecked).toBe(true) + + const textareaRecords = filterRecordsByIdAttribute(segment, 'textarea') + expect(textareaRecords.length).toBeGreaterThanOrEqual(4) + expect(textareaRecords[textareaRecords.length - 1].data.text).toBe('textarea test') + + const selectRecords = filterRecordsByIdAttribute(segment, 'select') + expect(selectRecords.length).toBe(1) + expect(selectRecords[0].data.text).toBe('2') + + function filterRecordsByIdAttribute(segment: Segment, idAttribute: string) { + const fullSnapshot = findFullSnapshot(segment)! + const id = findElementWithIdAttribute(fullSnapshot, idAttribute)!.id + const records = findAllIncrementalSnapshots(segment, IncrementalSource.Input) as Array<{ data: InputData }> + return records.filter((record) => record.data.id === id) + } + }) + + createTest("don't record ignored input interactions") .withSetup(bundleSetup) .withRumRecorder() .withBody( @@ -132,4 +558,40 @@ describe('recorder', () => { expect((inputRecords[inputRecords.length - 1].data as InputData).text).toBe('foo') }) }) + + describe('stylesheet rules observer', () => { + createTest('record dynamic CSS changes') + .withSetup(bundleSetup) + .withRumRecorder() + .withBody( + html` + + ` + ) + .run(async ({ events }) => { + await browserExecute(() => { + document.styleSheets[0].deleteRule(0) + document.styleSheets[0].insertRule('.added {}', 0) + }) + + await flushEvents() + + expect(events.sessionReplay.length).toBe(1) + + const segment = events.sessionReplay[0].segment.data + + const styleSheetRules = findAllIncrementalSnapshots(segment, IncrementalSource.StyleSheetRule) as Array<{ + data: StyleSheetRuleData + }> + + expect(styleSheetRules.length).toBe(2) + expect(styleSheetRules[0].data.removes).toEqual([{ index: 0 }]) + expect(styleSheetRules[1].data.adds).toEqual([{ rule: '.added {}', index: 0 }]) + }) + }) })