diff --git a/packages/rum/src/domain/record/index.ts b/packages/rum/src/domain/record/index.ts index 22e958f40d..db82a1d51d 100644 --- a/packages/rum/src/domain/record/index.ts +++ b/packages/rum/src/domain/record/index.ts @@ -1,8 +1,9 @@ export { takeFullSnapshot, takeNodeSnapshot } from './internalApi' export { record } from './record' -export type { SerializationMetric, SerializationStats } from './serialization' +export type { ChangeDecoder, SerializationMetric, SerializationStats } from './serialization' export { aggregateSerializationStats, + createChangeDecoder, createSerializationStats, isFullSnapshotChangeRecordsEnabled, isIncrementalSnapshotChangeRecordsEnabled, diff --git a/packages/rum/src/domain/record/serialization/index.ts b/packages/rum/src/domain/record/serialization/index.ts index c1eeebaa3c..fc9ad88686 100644 --- a/packages/rum/src/domain/record/serialization/index.ts +++ b/packages/rum/src/domain/record/serialization/index.ts @@ -1,5 +1,10 @@ -export type { ChangeConverter, MutationLog, NodeIdRemapper } from './conversions' -export { createChangeConverter, createCopyingNodeIdRemapper, createIdentityNodeIdRemapper } from './conversions' +export type { ChangeConverter, ChangeDecoder, MutationLog, NodeIdRemapper } from './conversions' +export { + createChangeConverter, + createChangeDecoder, + createCopyingNodeIdRemapper, + createIdentityNodeIdRemapper, +} from './conversions' export { isFullSnapshotChangeRecordsEnabled, isIncrementalSnapshotChangeRecordsEnabled } from './experimentalFeatures' export { createChildInsertionCursor, createRootInsertionCursor } from './insertionCursor' export { getElementInputValue } from './serializationUtils' diff --git a/packages/rum/test/record/changes.ts b/packages/rum/test/record/changes.ts new file mode 100644 index 0000000000..fdc8b806d8 --- /dev/null +++ b/packages/rum/test/record/changes.ts @@ -0,0 +1,27 @@ +import type { BrowserChangeRecord, BrowserFullSnapshotChangeRecord, BrowserRecord } from '../../src/types' +import { RecordType, SnapshotFormat } from '../../src/types' +import { createChangeDecoder } from '../../src/domain/record' + +export function decodeChangeRecords( + records: Array +): Array { + const changeDecoder = createChangeDecoder() + return records.map((record) => changeDecoder.decode(record)) +} + +export function decodeFullSnapshotChangeRecord( + record: BrowserFullSnapshotChangeRecord +): BrowserFullSnapshotChangeRecord { + const changeDecoder = createChangeDecoder() + return changeDecoder.decode(record) as BrowserFullSnapshotChangeRecord +} + +export function findChangeRecords( + records: BrowserRecord[] +): Array { + return records.filter( + (record) => + record.type === RecordType.Change || + (record.type === RecordType.FullSnapshot && record.format === SnapshotFormat.Change) + ) +} diff --git a/packages/rum/test/record/elements.ts b/packages/rum/test/record/elements.ts new file mode 100644 index 0000000000..b9791dea11 --- /dev/null +++ b/packages/rum/test/record/elements.ts @@ -0,0 +1,135 @@ +import type { + AddElementNodeChange, + AddNodeChange, + BrowserFullSnapshotChangeRecord, + BrowserFullSnapshotRecord, + BrowserFullSnapshotV1Record, + ScrollPositionChange, + SerializedNodeWithId, +} from '../../src/types' +import { ChangeType, NodeType, SnapshotFormat } from '../../src/types' +import { decodeFullSnapshotChangeRecord } from './changes' + +/** + * Given a full snapshot, locates elements with HTML id attributes and returns a map from + * each id attribute value to the corresponding element's node id. + */ +export function getElementIdsFromFullSnapshot(record: BrowserFullSnapshotRecord): Map { + if (record.format === SnapshotFormat.Change) { + return getElementIdsFromFullSnapshotChange(record) + } + return getElementIdsFromFullSnapshotV1(record) +} + +function getElementIdsFromFullSnapshotChange(rawRecord: BrowserFullSnapshotChangeRecord): Map { + const elementIds = new Map() + + let nextId = 0 + for (const change of decodeFullSnapshotChangeRecord(rawRecord).data) { + if (change[0] !== ChangeType.AddNode) { + continue + } + + for (let i = 1; i < change.length; i++) { + const id = nextId++ + const addedNode = change[i] as AddNodeChange + const nodeName = addedNode[1] + + switch (nodeName) { + case '#cdata-section': + case '#doctype': + case '#document': + case '#document-fragment': + case '#shadow-root': + case '#text': + continue + + default: { + const [, , ...attributeAssignments] = addedNode as AddElementNodeChange + for (const [name, value] of attributeAssignments) { + if (name === 'id') { + elementIds.set(String(value), id) + } + } + } + } + } + } + + return elementIds +} + +function getElementIdsFromFullSnapshotV1(record: BrowserFullSnapshotV1Record): Map { + const elementIds = new Map() + + const collectIds = (node: SerializedNodeWithId) => { + if (node.type === NodeType.Element && node.attributes.id) { + elementIds.set(String(node.attributes.id), node.id) + } + + if ('childNodes' in node) { + for (const child of node.childNodes) { + collectIds(child) + } + } + } + + collectIds(record.data.node) + return elementIds +} + +export interface ScrollPosition { + left: number + top: number +} + +/** + * Given a full snapshot, locates elements with non-zero scroll positions and returns a + * map from each node id to the corresponding element's scroll position. + */ +export function getScrollPositionsFromFullSnapshot(record: BrowserFullSnapshotRecord): Map { + if (record.format === SnapshotFormat.Change) { + return getScrollPositionsFromFullSnapshotChange(record) + } + return getScrollPositionsFromFullSnapshotV1(record) +} + +function getScrollPositionsFromFullSnapshotChange( + record: BrowserFullSnapshotChangeRecord +): Map { + const scrollPositions = new Map() + + for (const change of record.data) { + if (change[0] !== ChangeType.ScrollPosition) { + continue + } + + for (let i = 1; i < change.length; i++) { + const [nodeId, left, top] = change[i] as ScrollPositionChange + scrollPositions.set(nodeId, { left, top }) + } + } + + return scrollPositions +} + +function getScrollPositionsFromFullSnapshotV1(record: BrowserFullSnapshotV1Record): Map { + const scrollPositions = new Map() + + const collectScrollPositions = (node: SerializedNodeWithId) => { + if (node.type === NodeType.Element && (node.attributes.rr_scrollLeft || node.attributes.rr_scrollTop)) { + const left = node.attributes.rr_scrollLeft ?? 0 + const top = node.attributes.rr_scrollTop ?? 0 + scrollPositions.set(node.id, { left: Number(left), top: Number(top) }) + } + + if ('childNodes' in node) { + for (const child of node.childNodes) { + collectScrollPositions(child) + } + } + } + + collectScrollPositions(record.data.node) + return scrollPositions +} diff --git a/packages/rum/test/record/index.ts b/packages/rum/test/record/index.ts index 1ae58938dd..90a9f89a42 100644 --- a/packages/rum/test/record/index.ts +++ b/packages/rum/test/record/index.ts @@ -1,3 +1,5 @@ +export * from './changes' +export * from './elements' export * from './recordsPerFullSnapshot' export * from './mutationPayloadValidator' export * from './nodes' diff --git a/test/e2e/scenario/recorder/recorder.scenario.ts b/test/e2e/scenario/recorder/recorder.scenario.ts index 9df3e07525..dd3ab4e108 100644 --- a/test/e2e/scenario/recorder/recorder.scenario.ts +++ b/test/e2e/scenario/recorder/recorder.scenario.ts @@ -1,14 +1,14 @@ import type { InputData, StyleSheetRuleData, ScrollData } from '@datadog/browser-rum/src/types' -import { NodeType, IncrementalSource, SnapshotFormat } from '@datadog/browser-rum/src/types' +import { NodeType, IncrementalSource, SnapshotFormat, ChangeType, RecordType } from '@datadog/browser-rum/src/types' import { DefaultPrivacyLevel } from '@datadog/browser-core' +import { decodeChangeRecords, findChangeRecords } from '@datadog/browser-rum/test/record/changes' import { - findElement, - findElementWithIdAttribute, - findTextContent, - findElementWithTagName, -} from '@datadog/browser-rum/test/record/nodes' + getElementIdsFromFullSnapshot, + getScrollPositionsFromFullSnapshot, +} from '@datadog/browser-rum/test/record/elements' +import { findElement, findElementWithIdAttribute, findTextContent } from '@datadog/browser-rum/test/record/nodes' import { findFullSnapshot, findIncrementalSnapshot, @@ -77,291 +77,351 @@ test.describe('recorder', () => { }) test.describe('full snapshot', () => { - createTest('obfuscate elements') - .withRum() - .withBody(html` -
displayed
+ test.describe('obfuscate elements', () => { + const body = html`
displayed

hidden

hidden - - `) - .run(async ({ intakeRegistry, flushEvents }) => { - await flushEvents() + ` - expect(intakeRegistry.replaySegments).toHaveLength(1) + createTest('V1') + .withRum() + .withBody(body) + .run(async ({ intakeRegistry, flushEvents }) => { + await flushEvents() - const fullSnapshot = findFullSnapshotInFormat(SnapshotFormat.V1, intakeRegistry.replaySegments[0])! + expect(intakeRegistry.replaySegments).toHaveLength(1) - const node = findElementWithIdAttribute(fullSnapshot.data.node, 'not-obfuscated') - expect(node).toBeTruthy() - expect(findTextContent(node!)).toBe('displayed') + const fullSnapshot = findFullSnapshotInFormat(SnapshotFormat.V1, intakeRegistry.replaySegments[0])! - const hiddenNodeByAttribute = findElement(fullSnapshot.data.node, (node) => node.tagName === 'p') - expect(hiddenNodeByAttribute).toBeTruthy() - expect(hiddenNodeByAttribute!.attributes['data-dd-privacy']).toBe('hidden') - expect(hiddenNodeByAttribute!.childNodes).toHaveLength(0) + const node = findElementWithIdAttribute(fullSnapshot.data.node, 'not-obfuscated') + expect(node).toBeTruthy() + expect(findTextContent(node!)).toBe('displayed') - const hiddenNodeByClassName = findElement(fullSnapshot.data.node, (node) => node.tagName === 'span') - expect(hiddenNodeByClassName).toBeTruthy() - expect(hiddenNodeByClassName!.attributes.class).toBeUndefined() - expect(hiddenNodeByClassName!.attributes['data-dd-privacy']).toBe('hidden') - expect(hiddenNodeByClassName!.childNodes).toHaveLength(0) + const hiddenNodeByAttribute = findElement(fullSnapshot.data.node, (node) => node.tagName === 'p') + expect(hiddenNodeByAttribute).toBeTruthy() + expect(hiddenNodeByAttribute!.attributes['data-dd-privacy']).toBe('hidden') + expect(hiddenNodeByAttribute!.childNodes).toHaveLength(0) - const inputIgnored = findElementWithIdAttribute(fullSnapshot.data.node, 'input-not-obfuscated') - expect(inputIgnored).toBeTruthy() - expect(inputIgnored!.attributes.value).toBe('displayed') + const hiddenNodeByClassName = findElement(fullSnapshot.data.node, (node) => node.tagName === 'span') + expect(hiddenNodeByClassName).toBeTruthy() + expect(hiddenNodeByClassName!.attributes.class).toBeUndefined() + expect(hiddenNodeByClassName!.attributes['data-dd-privacy']).toBe('hidden') + expect(hiddenNodeByClassName!.childNodes).toHaveLength(0) - const inputMasked = findElementWithIdAttribute(fullSnapshot.data.node, 'input-masked') - expect(inputMasked).toBeTruthy() - expect(inputMasked!.attributes.value).toBe('***') - }) - }) + const inputIgnored = findElementWithIdAttribute(fullSnapshot.data.node, 'input-not-obfuscated') + expect(inputIgnored).toBeTruthy() + expect(inputIgnored!.attributes.value).toBe('displayed') - test.describe('mutations observer', () => { - createTest('record mutations') - .withRum() - .withBody(html` -

mutation observer

-
    -
  • -
- `) - .run(async ({ intakeRegistry, page, flushEvents }) => { - await page.evaluate(() => { - 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')) + const inputMasked = findElementWithIdAttribute(fullSnapshot.data.node, 'input-masked') + expect(inputMasked).toBeTruthy() + expect(inputMasked!.attributes.value).toBe('***') }) - await flushEvents() - - const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidatorFromSegment( - intakeRegistry.replaySegments[0], - { expect } - ) - - validate({ - adds: [ - { - parent: expectInitialNode({ tag: 'p' }), - node: expectNewNode({ type: NodeType.Element, tagName: 'span' }), - }, - ], - removes: [ - { - parent: expectInitialNode({ tag: 'body' }), - node: expectInitialNode({ tag: 'ul' }), - }, - ], + createTest('Change') + .withRum({ + enableExperimentalFeatures: ['use_incremental_change_records'], }) - }) - - createTest('record character data mutations') - .withRum() - .withBody(html` -

mutation observer

-
    -
  • -
- `) - .run(async ({ intakeRegistry, page, flushEvents }) => { - await page.evaluate(() => { - 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' + .withBody(body) + .run(async ({ intakeRegistry, flushEvents }) => { + await flushEvents() + expect(intakeRegistry.replaySegments).toHaveLength(1) + + const records = findChangeRecords(intakeRegistry.replaySegments[0].records) + expect(decodeChangeRecords(records).at(0)!.data).toEqual([ + [ + ChangeType.AddNode, + [null, '#document'], + [1, '#doctype', 'html', '', ''], + [0, 'HTML'], + [1, 'HEAD'], + [0, 'BODY'], + [1, 'DIV', ['id', 'not-obfuscated']], + [1, '#text', 'displayed'], + [3, '#text', '\n '], + [0, 'P', ['data-dd-privacy', 'hidden']], + [0, '#text', '\n '], + [0, 'SPAN', ['data-dd-privacy', 'hidden']], + [0, '#text', '\n '], + [0, 'INPUT', ['id', 'input-not-obfuscated'], ['value', 'displayed']], + [0, '#text', '\n '], + [0, 'INPUT', ['id', 'input-masked'], ['data-dd-privacy', 'mask'], ['value', '***']], + ], + [ + ChangeType.Size, + [8, expect.any(Number), expect.any(Number)], + [10, expect.any(Number), expect.any(Number)], + ], + [ChangeType.ScrollPosition, [0, 0, 0]], + ]) }) + }) + }) - await flushEvents() + test.describe('mutations observer', () => { + const body = html` +

mutation observer

+
    +
  • +
+ ` + + test.describe('record mutations', () => { + const mutate = () => { + 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')) + } + + createTest('V1') + .withRum() + .withBody(body) + .run(async ({ intakeRegistry, page, flushEvents }) => { + await page.evaluate(mutate) + await flushEvents() + + const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidatorFromSegment( + intakeRegistry.replaySegments[0], + { expect } + ) - const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidatorFromSegment( - intakeRegistry.replaySegments[0], - { expect } - ) + validate({ + adds: [ + { + parent: expectInitialNode({ tag: 'p' }), + node: expectNewNode({ type: NodeType.Element, tagName: 'span' }), + }, + ], + removes: [ + { + parent: expectInitialNode({ tag: 'body' }), + node: expectInitialNode({ tag: 'ul' }), + }, + ], + }) + }) - validate({ - adds: [ - { - parent: expectInitialNode({ tag: 'p' }), - node: expectNewNode({ type: NodeType.Text, textContent: 'mutated' }), - }, - ], - removes: [ - { - parent: expectInitialNode({ tag: 'body' }), - node: expectInitialNode({ tag: 'ul' }), - }, - { - parent: expectInitialNode({ tag: 'p' }), - node: expectInitialNode({ text: 'mutation observer' }), - }, - ], + createTest('Change') + .withRum({ + enableExperimentalFeatures: ['use_incremental_change_records'], }) - }) + .withBody(body) + .run(async ({ intakeRegistry, page, flushEvents }) => { + await page.evaluate(mutate) + await flushEvents() + + const records = findChangeRecords(intakeRegistry.replaySegments[0].records) + expect(decodeChangeRecords(records).at(-1)!.data).toEqual([ + [ChangeType.AddNode, [8, 'SPAN']], + [ChangeType.RemoveNode, 9], + ]) + }) + }) - createTest('record attributes mutations') - .withRum() - .withBody(html` -

mutation observer

-
    -
  • -
- `) - .run(async ({ intakeRegistry, page, flushEvents }) => { - await page.evaluate(() => { - const li = document.createElement('li') - const ul = document.querySelector('ul') as HTMLUListElement + test.describe('record character data mutations', () => { + const mutate = () => { + 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' + } + + createTest('V1') + .withRum() + .withBody(body) + .run(async ({ intakeRegistry, page, flushEvents }) => { + await page.evaluate(mutate) + await flushEvents() + + const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidatorFromSegment( + intakeRegistry.replaySegments[0], + { expect } + ) - // Make sure mutations occurring in a removed element are not reported - ul.appendChild(li) - li.setAttribute('foo', 'bar') - document.body.removeChild(ul) + validate({ + adds: [ + { + parent: expectInitialNode({ tag: 'p' }), + node: expectNewNode({ type: NodeType.Text, textContent: 'mutated' }), + }, + ], + removes: [ + { + parent: expectInitialNode({ tag: 'body' }), + node: expectInitialNode({ tag: 'ul' }), + }, + { + parent: expectInitialNode({ tag: 'p' }), + node: expectInitialNode({ text: 'mutation observer' }), + }, + ], + }) + }) - document.body.setAttribute('test', 'true') + createTest('Change') + .withRum({ + enableExperimentalFeatures: ['use_incremental_change_records'], + }) + .withBody(body) + .run(async ({ intakeRegistry, page, flushEvents }) => { + await page.evaluate(mutate) + await flushEvents() + + const records = findChangeRecords(intakeRegistry.replaySegments[0].records) + expect(decodeChangeRecords(records).at(-1)!.data).toEqual([ + [ChangeType.AddNode, [8, '#text', 'mutated']], + [ChangeType.RemoveNode, 9, 7], + ]) }) + }) - await flushEvents() + test.describe('record attributes mutations', () => { + const mutate = () => { + 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') + } + + createTest('V1') + .withRum() + .withBody(body) + .run(async ({ intakeRegistry, page, flushEvents }) => { + await page.evaluate(mutate) + await flushEvents() + + const { validate, expectInitialNode } = createMutationPayloadValidatorFromSegment( + intakeRegistry.replaySegments[0], + { expect } + ) - const { validate, expectInitialNode } = createMutationPayloadValidatorFromSegment( - intakeRegistry.replaySegments[0], - { expect } - ) + validate({ + attributes: [ + { + node: expectInitialNode({ tag: 'body' }), + attributes: { test: 'true' }, + }, + ], + removes: [ + { + parent: expectInitialNode({ tag: 'body' }), + node: expectInitialNode({ tag: 'ul' }), + }, + ], + }) + }) - validate({ - attributes: [ - { - node: expectInitialNode({ tag: 'body' }), - attributes: { test: 'true' }, - }, - ], - removes: [ - { - parent: expectInitialNode({ tag: 'body' }), - node: expectInitialNode({ tag: 'ul' }), - }, - ], + createTest('Change') + .withRum({ + enableExperimentalFeatures: ['use_incremental_change_records'], }) - }) + .withBody(body) + .run(async ({ intakeRegistry, page, flushEvents }) => { + await page.evaluate(mutate) + await flushEvents() + + const records = findChangeRecords(intakeRegistry.replaySegments[0].records) + expect(decodeChangeRecords(records).at(-1)!.data).toEqual([ + [ChangeType.RemoveNode, 9], + [ChangeType.Attribute, [4, ['test', 'true']]], + ]) + }) + }) - createTest("don't record hidden elements mutations") - .withRum() - .withBody(html` + test.describe("don't record hidden elements mutations", () => { + const hiddenBody = html`
- `) - .run(async ({ intakeRegistry, page, flushEvents }) => { - await page.evaluate(() => { - document.querySelector('div')!.setAttribute('foo', 'bar') - document.querySelector('li')!.textContent = 'hop' - document.querySelector('div')!.appendChild(document.createElement('p')) + ` + + const mutate = () => { + document.querySelector('div')!.setAttribute('foo', 'bar') + document.querySelector('li')!.textContent = 'hop' + document.querySelector('div')!.appendChild(document.createElement('p')) + } + + createTest('V1') + .withRum() + .withBody(hiddenBody) + .run(async ({ intakeRegistry, page, flushEvents }) => { + await page.evaluate(mutate) + await flushEvents() + + expect(intakeRegistry.replaySegments).toHaveLength(1) + const segment = intakeRegistry.replaySegments[0] + expect(findAllIncrementalSnapshots(segment, IncrementalSource.Mutation)).toHaveLength(0) }) - await flushEvents() - - expect(intakeRegistry.replaySegments).toHaveLength(1) - const segment = intakeRegistry.replaySegments[0] - - expect(findAllIncrementalSnapshots(segment, IncrementalSource.Mutation)).toHaveLength(0) - }) - - createTest('record DOM node movement 1') - .withRum() - .withBody( - // prettier-ignore - html` -
a

b
- cdefg - ` - ) - .run(async ({ intakeRegistry, page, flushEvents }) => { - await page.evaluate(() => { - 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) + createTest('Change') + .withRum({ + enableExperimentalFeatures: ['use_incremental_change_records'], }) - - await flushEvents() - - const { validate, expectInitialNode } = createMutationPayloadValidatorFromSegment( - intakeRegistry.replaySegments[0], - { expect } - ) - validate({ - adds: [ - { - parent: expectInitialNode({ tag: 'div' }), - node: expectInitialNode({ tag: 'span' }).withChildren( - expectInitialNode({ text: 'c' }), - expectInitialNode({ tag: 'i' }).withChildren( - expectInitialNode({ text: 'd' }), - expectInitialNode({ tag: 'b' }).withChildren(expectInitialNode({ text: 'e' })), - expectInitialNode({ text: 'f' }) - ), - expectInitialNode({ text: 'g' }) - ), - }, - ], - removes: [ - { - parent: expectInitialNode({ tag: 'body' }), - node: expectInitialNode({ tag: 'span' }), - }, - ], + .withBody(hiddenBody) + .run(async ({ intakeRegistry, page, flushEvents }) => { + await page.evaluate(mutate) + await flushEvents() + + expect(intakeRegistry.replaySegments).toHaveLength(1) + const records = findChangeRecords(intakeRegistry.replaySegments[0].records) + expect(records).toHaveLength(1) + expect(records[0].type === RecordType.FullSnapshot) }) - }) - - createTest('record DOM node movement 2') - .withRum() - .withBody( - // prettier-ignore - html` - cdefg - ` - ) - .run(async ({ intakeRegistry, page, flushEvents }) => { - await page.evaluate(() => { - const div = document.createElement('div') - const span = document.querySelector('span')! - document.body.appendChild(div) - div.appendChild(span) - }) - - await flushEvents() - - const { validate, expectInitialNode, expectNewNode } = createMutationPayloadValidatorFromSegment( - intakeRegistry.replaySegments[0], - { expect } - ) - - const div = expectNewNode({ type: NodeType.Element, tagName: 'div' }) + }) - validate({ - adds: [ - { - parent: expectInitialNode({ tag: 'body' }), - node: div.withChildren( - expectInitialNode({ tag: 'span' }).withChildren( + test.describe('record DOM node movement 1', () => { + // prettier-ignore + const body = html` +
a

b
+ cdefg + ` + + const mutate = () => { + 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) + } + + createTest('V1') + .withRum() + .withBody(body) + .run(async ({ intakeRegistry, page, flushEvents }) => { + await page.evaluate(mutate) + await flushEvents() + + const { validate, expectInitialNode } = createMutationPayloadValidatorFromSegment( + intakeRegistry.replaySegments[0], + { expect } + ) + validate({ + adds: [ + { + parent: expectInitialNode({ tag: 'div' }), + node: expectInitialNode({ tag: 'span' }).withChildren( expectInitialNode({ text: 'c' }), expectInitialNode({ tag: 'i' }).withChildren( expectInitialNode({ text: 'd' }), @@ -369,148 +429,283 @@ test.describe('recorder', () => { expectInitialNode({ text: 'f' }) ), expectInitialNode({ text: 'g' }) - ) - ), - }, - ], - removes: [ - { - parent: expectInitialNode({ tag: 'body' }), - node: expectInitialNode({ tag: 'span' }), - }, - ], + ), + }, + ], + removes: [ + { + parent: expectInitialNode({ tag: 'body' }), + node: expectInitialNode({ tag: 'span' }), + }, + ], + }) }) - }) - createTest('serialize node before record') - .withRum() - .withBody( - // prettier-ignore - html` -
- ` - ) - .run(async ({ intakeRegistry, page, flushEvents }) => { - await page.evaluate(() => { - const ul = document.querySelector('ul') as HTMLUListElement - let count = 3 - while (count > 0) { - count-- - const li = document.createElement('li') - ul.appendChild(li) - } + createTest('Change') + .withRum({ + enableExperimentalFeatures: ['use_incremental_change_records'], }) + .withBody(body) + .run(async ({ intakeRegistry, page, flushEvents }) => { + await page.evaluate(mutate) + await flushEvents() + + const records = findChangeRecords(intakeRegistry.replaySegments[0].records) + expect(decodeChangeRecords(records).at(-1)!.data).toEqual([ + [ + ChangeType.AddNode, + [14, 'SPAN'], + [1, '#text', 'c'], + [0, 'I'], + [1, '#text', 'd'], + [0, 'B'], + [1, '#text', 'e'], + [4, '#text', 'f'], + [7, '#text', 'g'], + ], + [ChangeType.RemoveNode, 11], + ]) + }) + }) - await flushEvents() - - const { validate, expectInitialNode, expectNewNode } = createMutationPayloadValidatorFromSegment( - intakeRegistry.replaySegments[0], - { expect } - ) - - const ul = expectInitialNode({ tag: 'ul' }) - const li1 = expectNewNode({ type: NodeType.Element, tagName: 'li' }) - const li2 = expectNewNode({ type: NodeType.Element, tagName: 'li' }) - const li3 = expectNewNode({ type: NodeType.Element, tagName: 'li' }) + test.describe('record DOM node movement 2', () => { + // prettier-ignore + const body = html` + cdefg + ` + + const mutate = () => { + const div = document.createElement('div') + const span = document.querySelector('span')! + document.body.appendChild(div) + div.appendChild(span) + } + + createTest('V1') + .withRum() + .withBody(body) + .run(async ({ intakeRegistry, page, flushEvents }) => { + await page.evaluate(mutate) + await flushEvents() + + const { validate, expectInitialNode, expectNewNode } = createMutationPayloadValidatorFromSegment( + intakeRegistry.replaySegments[0], + { expect } + ) - validate({ - adds: [ - { - parent: ul, - node: li1, - }, - { - next: li1, - parent: ul, - node: li2, - }, - { - next: li2, - parent: ul, - node: li3, - }, - ], + const div = expectNewNode({ type: NodeType.Element, tagName: 'div' }) + + validate({ + adds: [ + { + parent: expectInitialNode({ tag: 'body' }), + node: div.withChildren( + expectInitialNode({ tag: 'span' }).withChildren( + expectInitialNode({ text: 'c' }), + expectInitialNode({ tag: 'i' }).withChildren( + expectInitialNode({ text: 'd' }), + expectInitialNode({ tag: 'b' }).withChildren(expectInitialNode({ text: 'e' })), + expectInitialNode({ text: 'f' }) + ), + expectInitialNode({ text: 'g' }) + ) + ), + }, + ], + removes: [ + { + parent: expectInitialNode({ tag: 'body' }), + node: expectInitialNode({ tag: 'span' }), + }, + ], + }) }) - }) - }) - - test.describe('input observers', () => { - createTest('record input interactions') - .withRum({ - defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW, - }) - .withBody(html` -
- - - - - -
- `) - .run(async ({ intakeRegistry, page, flushEvents }) => { - const textInput = page.locator('#text-input') - await textInput.pressSequentially('test') - const radioInput = page.locator('#radio-input') - await radioInput.click() + createTest('Change') + .withRum({ + enableExperimentalFeatures: ['use_incremental_change_records'], + }) + .withBody(body) + .run(async ({ intakeRegistry, page, flushEvents }) => { + await page.evaluate(mutate) + await flushEvents() + + const records = findChangeRecords(intakeRegistry.replaySegments[0].records) + expect(decodeChangeRecords(records).at(-1)!.data).toEqual([ + [ + ChangeType.AddNode, + [11, 'DIV'], + [1, 'SPAN'], + [1, '#text', 'c'], + [0, 'I'], + [1, '#text', 'd'], + [0, 'B'], + [1, '#text', 'e'], + [4, '#text', 'f'], + [7, '#text', 'g'], + ], + [ChangeType.RemoveNode, 6], + ]) + }) + }) - const checkboxInput = page.locator('#checkbox-input') - await checkboxInput.click() + test.describe('serialize node before record', () => { + // prettier-ignore + const body = html` +
+ ` + + const mutate = () => { + const ul = document.querySelector('ul') as HTMLUListElement + let count = 3 + while (count > 0) { + count-- + const li = document.createElement('li') + ul.appendChild(li) + } + } + + createTest('V1') + .withRum() + .withBody(body) + .run(async ({ intakeRegistry, page, flushEvents }) => { + await page.evaluate(mutate) + await flushEvents() + + const { validate, expectInitialNode, expectNewNode } = createMutationPayloadValidatorFromSegment( + intakeRegistry.replaySegments[0], + { expect } + ) - const textarea = page.locator('#textarea') - await textarea.pressSequentially('textarea test') + const ul = expectInitialNode({ tag: 'ul' }) + const li1 = expectNewNode({ type: NodeType.Element, tagName: 'li' }) + const li2 = expectNewNode({ type: NodeType.Element, tagName: 'li' }) + const li3 = expectNewNode({ type: NodeType.Element, tagName: 'li' }) + + validate({ + adds: [ + { + parent: ul, + node: li1, + }, + { + next: li1, + parent: ul, + node: li2, + }, + { + next: li2, + parent: ul, + node: li3, + }, + ], + }) + }) - const select = page.locator('#select') - await select.selectOption({ value: '2' }) + createTest('Change') + .withRum({ + enableExperimentalFeatures: ['use_incremental_change_records'], + }) + .withBody(body) + .run(async ({ intakeRegistry, page, flushEvents }) => { + await page.evaluate(mutate) + await flushEvents() + + const records = findChangeRecords(intakeRegistry.replaySegments[0].records) + expect(decodeChangeRecords(records).at(-1)!.data).toEqual([ + [ChangeType.AddNode, [3, 'LI'], [4, 'LI'], [5, 'LI']], + ]) + }) + }) + }) - await flushEvents() + test.describe('input observers', () => { + test.describe('record input interactions', () => { + function createTestVariation(name: string, enableExperimentalFeatures: string[]): void { + createTest(name) + .withRum({ + defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW, + enableExperimentalFeatures, + }) + .withBody(html` +
+ + + + + +
+ `) + .run(async ({ intakeRegistry, page, flushEvents }) => { + const textInput = page.locator('#text-input') + await textInput.pressSequentially('test') + + const radioInput = page.locator('#radio-input') + await radioInput.click() + + const checkboxInput = page.locator('#checkbox-input') + await checkboxInput.click() + + const textarea = page.locator('#textarea') + await textarea.pressSequentially('textarea test') + + const select = page.locator('#select') + await select.selectOption({ value: '2' }) + + await flushEvents() + + const fullSnapshot = findFullSnapshot({ records: intakeRegistry.replayRecords })! + const elementIds = getElementIdsFromFullSnapshot(fullSnapshot) + + const textInputRecords = filterRecordsByIdAttribute('text-input') + expect(textInputRecords.length).toBeGreaterThanOrEqual(4) + expect((textInputRecords[textInputRecords.length - 1].data as { text?: string }).text).toBe('test') + + const radioInputRecords = filterRecordsByIdAttribute('radio-input') + expect(radioInputRecords).toHaveLength(1) + expect((radioInputRecords[0].data as { text?: string }).text).toBe(undefined) + expect((radioInputRecords[0].data as { isChecked?: boolean }).isChecked).toBe(true) + + const checkboxInputRecords = filterRecordsByIdAttribute('checkbox-input') + expect(checkboxInputRecords).toHaveLength(1) + expect((checkboxInputRecords[0].data as { text?: string }).text).toBe(undefined) + expect((checkboxInputRecords[0].data as { isChecked?: boolean }).isChecked).toBe(true) + + const textareaRecords = filterRecordsByIdAttribute('textarea') + expect(textareaRecords.length).toBeGreaterThanOrEqual(4) + expect((textareaRecords[textareaRecords.length - 1].data as { text?: string }).text).toBe('textarea test') + + const selectRecords = filterRecordsByIdAttribute('select') + expect(selectRecords).toHaveLength(1) + expect((selectRecords[0].data as { text?: string }).text).toBe('2') + + function filterRecordsByIdAttribute(idAttribute: string) { + const id = elementIds.get(idAttribute) + const records = findAllIncrementalSnapshots( + { records: intakeRegistry.replayRecords }, + IncrementalSource.Input + ) as Array<{ data: InputData }> + return records.filter((record) => record.data.id === id) + } + }) + } - const textInputRecords = filterRecordsByIdAttribute('text-input') - expect(textInputRecords.length).toBeGreaterThanOrEqual(4) - expect((textInputRecords[textInputRecords.length - 1].data as { text?: string }).text).toBe('test') - - const radioInputRecords = filterRecordsByIdAttribute('radio-input') - expect(radioInputRecords).toHaveLength(1) - expect((radioInputRecords[0].data as { text?: string }).text).toBe(undefined) - expect((radioInputRecords[0].data as { isChecked?: boolean }).isChecked).toBe(true) - - const checkboxInputRecords = filterRecordsByIdAttribute('checkbox-input') - expect(checkboxInputRecords).toHaveLength(1) - expect((checkboxInputRecords[0].data as { text?: string }).text).toBe(undefined) - expect((checkboxInputRecords[0].data as { isChecked?: boolean }).isChecked).toBe(true) - - const textareaRecords = filterRecordsByIdAttribute('textarea') - expect(textareaRecords.length).toBeGreaterThanOrEqual(4) - expect((textareaRecords[textareaRecords.length - 1].data as { text?: string }).text).toBe('textarea test') - - const selectRecords = filterRecordsByIdAttribute('select') - expect(selectRecords).toHaveLength(1) - expect((selectRecords[0].data as { text?: string }).text).toBe('2') - - function filterRecordsByIdAttribute(idAttribute: string) { - const fullSnapshot = findFullSnapshotInFormat(SnapshotFormat.V1, { records: intakeRegistry.replayRecords })! - const id = findElementWithIdAttribute(fullSnapshot.data.node, idAttribute)!.id - const records = findAllIncrementalSnapshots( - { records: intakeRegistry.replayRecords }, - IncrementalSource.Input - ) as Array<{ data: InputData }> - return records.filter((record) => record.data.id === id) - } - }) + createTestVariation('V1', []) + createTestVariation('Change', ['use_incremental_change_records']) + }) createTest("don't record ignored input interactions") .withRum({ @@ -651,95 +846,125 @@ test.describe('recorder', () => { }) test.describe('scroll positions', () => { - createTest('should be recorded across navigation') - // to control initial position before recording - .withRum({ startSessionReplayRecordingManually: true }) - .withBody(html` - -
-
I'm bigger than the container
-
-
- `) - .run(async ({ intakeRegistry, page, flushEvents }) => { - function scroll({ windowY, containerX }: { windowY: number; containerX: number }) { - return page.evaluate( - ({ windowY, containerX }) => - new Promise((resolve) => { - let scrollCount = 0 - - document.addEventListener( - 'scroll', - () => { - scrollCount++ - if (scrollCount === 2) { - // ensure to bypass observer throttling - setTimeout(resolve, 100) - } - }, - { capture: true, passive: true } - ) - - window.scrollTo(0, windowY) - document.getElementById('container')!.scrollTo(containerX, 0) - }), - { windowY, containerX } - ) - } + test.describe('should be recorded across view changes', () => { + function createTestVariation(name: string, enableExperimentalFeatures: string[]): void { + createTest(name) + .withRum({ + enableExperimentalFeatures, + // to control initial position before recording + startSessionReplayRecordingManually: true, + }) + .withBody(html` + +
+
I'm bigger than the container
+
+
+ `) + .run(async ({ intakeRegistry, page, flushEvents }) => { + function scroll({ windowY, containerX }: { windowY: number; containerX: number }) { + return page.evaluate( + ({ windowY, containerX }) => + new Promise((resolve) => { + let scrollCount = 0 + + document.addEventListener( + 'scroll', + () => { + scrollCount++ + if (scrollCount === 2) { + // ensure to bypass observer throttling + setTimeout(resolve, 100) + } + }, + { capture: true, passive: true } + ) + + window.scrollTo(0, windowY) + document.getElementById('container')!.scrollTo(containerX, 0) + }), + { windowY, containerX } + ) + } - // initial scroll positions - await scroll({ windowY: 100, containerX: 10 }) + await page.evaluate(() => { + document.getElementsByTagName('html')[0].setAttribute('id', 'html') + }) - await page.evaluate(() => { - window.DD_RUM!.startSessionReplayRecording() - }) + // initial scroll positions + await scroll({ windowY: 100, containerX: 10 }) - // wait for recorder to be properly started - await wait(100) + await page.evaluate(() => { + window.DD_RUM!.startSessionReplayRecording() + }) - // update scroll positions - await scroll({ windowY: 150, containerX: 20 }) + // wait for recorder to be properly started + await wait(100) - // trigger new full snapshot - await page.evaluate(() => { - window.DD_RUM!.startView() - }) + // update scroll positions + await scroll({ windowY: 150, containerX: 20 }) - await flushEvents() + // trigger new full snapshot + await page.evaluate(() => { + window.DD_RUM!.startView() + }) - expect(intakeRegistry.replaySegments).toHaveLength(2) - const firstSegment = intakeRegistry.replaySegments[0] - - const firstFullSnapshot = findFullSnapshotInFormat(SnapshotFormat.V1, firstSegment)! - let htmlElement = findElementWithTagName(firstFullSnapshot.data.node, 'html')! - expect(htmlElement.attributes.rr_scrollTop).toBe(100) - let containerElement = findElementWithIdAttribute(firstFullSnapshot.data.node, 'container')! - expect(containerElement.attributes.rr_scrollLeft).toBe(10) - - const scrollRecords = findAllIncrementalSnapshots(firstSegment, IncrementalSource.Scroll) - expect(scrollRecords).toHaveLength(2) - const [windowScrollData, containerScrollData] = scrollRecords.map((record) => record.data as ScrollData) - expect(windowScrollData.y).toEqual(150) - expect(containerScrollData.x).toEqual(20) - - const secondFullSnapshot = findFullSnapshotInFormat(SnapshotFormat.V1, intakeRegistry.replaySegments.at(-1)!)! - htmlElement = findElementWithTagName(secondFullSnapshot.data.node, 'html')! - expect(htmlElement.attributes.rr_scrollTop).toBe(150) - containerElement = findElementWithIdAttribute(secondFullSnapshot.data.node, 'container')! - expect(containerElement.attributes.rr_scrollLeft).toBe(20) - }) + await flushEvents() + + expect(intakeRegistry.replaySegments).toHaveLength(2) + const firstSegment = intakeRegistry.replaySegments[0] + + { + const firstFullSnapshot = findFullSnapshot(firstSegment)! + const elementIds = getElementIdsFromFullSnapshot(firstFullSnapshot) + const scrollPositions = getScrollPositionsFromFullSnapshot(firstFullSnapshot) + + const htmlId = elementIds.get('html') + expect(htmlId).not.toBeUndefined() + expect(scrollPositions.get(htmlId!)).toEqual({ left: 0, top: 100 }) + + const containerId = elementIds.get('container') + expect(containerId).not.toBeUndefined() + expect(scrollPositions.get(containerId!)).toEqual({ left: 10, top: 0 }) + + const scrollRecords = findAllIncrementalSnapshots(firstSegment, IncrementalSource.Scroll) + expect(scrollRecords).toHaveLength(2) + const [windowScrollData, containerScrollData] = scrollRecords.map((record) => record.data as ScrollData) + expect(windowScrollData.y).toEqual(150) + expect(containerScrollData.x).toEqual(20) + } + + { + const secondFullSnapshot = findFullSnapshot(intakeRegistry.replaySegments.at(-1)!)! + const elementIds = getElementIdsFromFullSnapshot(secondFullSnapshot) + const scrollPositions = getScrollPositionsFromFullSnapshot(secondFullSnapshot) + + const htmlId = elementIds.get('html') + expect(htmlId).not.toBeUndefined() + expect(scrollPositions.get(htmlId!)).toEqual({ left: 0, top: 150 }) + + const containerId = elementIds.get('container') + expect(containerId).not.toBeUndefined() + expect(scrollPositions.get(containerId!)).toEqual({ left: 20, top: 0 }) + } + }) + } + + createTestVariation('V1', []) + createTestVariation('Change', ['use_incremental_change_records']) + }) }) test.describe('recording of sampled out sessions', () => { diff --git a/test/e2e/scenario/recorder/shadowDom.scenario.ts b/test/e2e/scenario/recorder/shadowDom.scenario.ts index 1f774fb97d..301c91c266 100644 --- a/test/e2e/scenario/recorder/shadowDom.scenario.ts +++ b/test/e2e/scenario/recorder/shadowDom.scenario.ts @@ -4,23 +4,32 @@ import type { ScrollData, SerializedNodeWithId, } from '@datadog/browser-rum/src/types' -import { IncrementalSource, MouseInteractionType, NodeType, SnapshotFormat } from '@datadog/browser-rum/src/types' +import { + ChangeType, + IncrementalSource, + MouseInteractionType, + NodeType, + SnapshotFormat, +} from '@datadog/browser-rum/src/types' import { createMutationPayloadValidatorFromSegment } from '@datadog/browser-rum/test/record/mutationPayloadValidator' import { findElementWithIdAttribute, - findElementWithTagName, findNode, findTextContent, findTextNode, } from '@datadog/browser-rum/test/record/nodes' import { + findFullSnapshot, findFullSnapshotInFormat, findIncrementalSnapshot, findMouseInteractionRecords, } from '@datadog/browser-rum/test/record/segments' +import type { Page } from '@playwright/test' import { test, expect } from '@playwright/test' +import { decodeChangeRecords, findChangeRecords } from '@datadog/browser-rum/test/record/changes' +import { getElementIdsFromFullSnapshot } from '@datadog/browser-rum/test/record/elements' import { createTest, html } from '../../lib/framework' /** @@ -82,6 +91,7 @@ const divShadowDom = `