diff --git a/packages/core/src/tools/experimentalFeatures.ts b/packages/core/src/tools/experimentalFeatures.ts index 7c83cd79ca..3c5f7b16d5 100644 --- a/packages/core/src/tools/experimentalFeatures.ts +++ b/packages/core/src/tools/experimentalFeatures.ts @@ -18,8 +18,6 @@ export enum ExperimentalFeature { FEATURE_OPERATION_VITAL = 'feature_operation_vital', START_STOP_ACTION = 'start_stop_action', START_STOP_RESOURCE = 'start_stop_resource', - USE_CHANGE_RECORDS = 'use_change_records', - USE_INCREMENTAL_CHANGE_RECORDS = 'use_incremental_change_records', TOO_MANY_REQUESTS_INVESTIGATION = 'too_many_requests_investigation', TRACK_RESOURCE_HEADERS = 'track_resource_headers', } diff --git a/packages/rum/src/boot/datadogRecorder.spec.ts b/packages/rum/src/boot/datadogRecorder.spec.ts index b220387b31..4dc3c36771 100644 --- a/packages/rum/src/boot/datadogRecorder.spec.ts +++ b/packages/rum/src/boot/datadogRecorder.spec.ts @@ -1,13 +1,5 @@ import type { TimeStamp, HttpRequest, HttpRequestEvent, Telemetry } from '@datadog/browser-core' -import { - PageExitReason, - DefaultPrivacyLevel, - noop, - DeflateEncoderStreamId, - Observable, - ExperimentalFeature, - addExperimentalFeatures, -} from '@datadog/browser-core' +import { PageExitReason, DefaultPrivacyLevel, noop, DeflateEncoderStreamId, Observable } from '@datadog/browser-core' import type { ViewCreatedEvent } from '@datadog/browser-rum-core' import { LifeCycle, LifeCycleEventType, startViewHistory } from '@datadog/browser-rum-core' import type { SessionManagerMock } from '@datadog/browser-core/test' @@ -111,35 +103,6 @@ describe('startRecording', () => { }) }) - it('sends recorded segments with valid context when Change records are enabled', async () => { - addExperimentalFeatures([ExperimentalFeature.USE_CHANGE_RECORDS]) - setupStartRecording() - flushSegment(lifeCycle) - - const requests = await readSentRequests(1) - expect(requests[0].segment).toEqual(jasmine.any(Object)) - expect(requests[0].event).toEqual({ - application: { - id: 'appId', - }, - creation_reason: 'init', - end: jasmine.stringMatching(/^\d{13}$/), - has_full_snapshot: true, - records_count: recordsPerFullSnapshot(), - session: { - id: MOCK_SESSION_ID, - }, - start: jasmine.any(Number), - raw_segment_size: jasmine.any(Number), - compressed_segment_size: jasmine.any(Number), - view: { - id: 'view-id', - }, - index_in_view: 0, - source: 'browser', - }) - }) - it('flushes the segment when its compressed data reaches the segment bytes limit', async () => { setupStartRecording() const inputCount = 150 @@ -196,7 +159,7 @@ describe('startRecording', () => { const requests = await readSentRequests(2) const firstSegment = requests[0].segment - expect(firstSegment.records[firstSegment.records.length - 2].type).toBe(RecordType.IncrementalSnapshot) + expect(firstSegment.records[firstSegment.records.length - 2].type).toBe(RecordType.Change) expect(firstSegment.records[firstSegment.records.length - 1].type).toBe(RecordType.ViewEnd) const secondSegment = requests[1].segment diff --git a/packages/rum/src/domain/record/index.ts b/packages/rum/src/domain/record/index.ts index db82a1d51d..c31d7480e3 100644 --- a/packages/rum/src/domain/record/index.ts +++ b/packages/rum/src/domain/record/index.ts @@ -5,8 +5,6 @@ export { aggregateSerializationStats, createChangeDecoder, createSerializationStats, - isFullSnapshotChangeRecordsEnabled, - isIncrementalSnapshotChangeRecordsEnabled, serializeNode, } from './serialization' export { createElementsScrollPositions } from './elementsScrollPositions' diff --git a/packages/rum/src/domain/record/internalApi.spec.ts b/packages/rum/src/domain/record/internalApi.spec.ts index 9d6ad46ed9..3764b7f1b4 100644 --- a/packages/rum/src/domain/record/internalApi.spec.ts +++ b/packages/rum/src/domain/record/internalApi.spec.ts @@ -1,6 +1,8 @@ -import { NodeType, RecordType, SnapshotFormat } from '../../types' +import type { BrowserChangeRecord, BrowserFullSnapshotChangeRecord, BrowserRecord } from '../../types' +import { ChangeType, RecordType, SnapshotFormat } from '../../types' import { appendElement } from '../../../../rum-core/test' import { takeFullSnapshot, takeNodeSnapshot } from './internalApi' +import { createChangeDecoder } from './serialization' describe('takeFullSnapshot', () => { it('should produce Meta, Focus, and FullSnapshot records', () => { @@ -23,14 +25,8 @@ describe('takeFullSnapshot', () => { timestamp: jasmine.any(Number), }, { - data: { - node: jasmine.any(Object), - initialOffset: { - left: jasmine.any(Number), - top: jasmine.any(Number), - }, - }, - format: SnapshotFormat.V1, + data: jasmine.any(Object), + format: SnapshotFormat.Change, type: RecordType.FullSnapshot, timestamp: jasmine.any(Number), }, @@ -56,35 +52,30 @@ describe('takeFullSnapshot', () => { }) describe('takeNodeSnapshot', () => { + function decodeSnapshot( + record: BrowserRecord | undefined + ): BrowserChangeRecord | BrowserFullSnapshotChangeRecord | undefined { + if (!record) { + return undefined + } + if (record.type !== RecordType.FullSnapshot) { + throw new Error(`Unexpected record type ${record.type}`) + } + if (record.format !== SnapshotFormat.Change) { + throw new Error(`Unexpected record format ${record.format}`) + } + + const decoder = createChangeDecoder() + return decoder.decode(record) + } + it('should serialize nodes', () => { const node = appendElement('
Hello world
', document.body) - expect(takeNodeSnapshot(node)).toEqual({ - type: NodeType.Element, - id: 0, - tagName: 'div', - isSVG: undefined, - attributes: {}, - childNodes: [ - { - type: NodeType.Text, - id: 1, - textContent: 'Hello ', - }, - { - type: NodeType.Element, - id: 2, - tagName: 'b', - isSVG: undefined, - attributes: {}, - childNodes: [ - { - type: NodeType.Text, - id: 3, - textContent: 'world', - }, - ], - }, - ], + expect(decodeSnapshot(takeNodeSnapshot(node))).toEqual({ + type: RecordType.FullSnapshot, + format: SnapshotFormat.Change, + data: [[ChangeType.AddNode, [null, 'DIV'], [1, '#text', 'Hello '], [0, 'B'], [1, '#text', 'world']]], + timestamp: jasmine.any(Number), }) }) @@ -92,32 +83,11 @@ describe('takeNodeSnapshot', () => { const node = appendElement('
Hello
', document.body) const shadowRoot = node.attachShadow({ mode: 'open' }) shadowRoot.appendChild(document.createTextNode('world')) - expect(takeNodeSnapshot(node)).toEqual({ - type: NodeType.Element, - id: 0, - tagName: 'div', - isSVG: undefined, - attributes: {}, - childNodes: [ - { - type: NodeType.Text, - id: 1, - textContent: 'Hello', - }, - { - type: NodeType.DocumentFragment, - id: 2, - isShadowRoot: true, - adoptedStyleSheets: undefined, - childNodes: [ - { - type: NodeType.Text, - id: 3, - textContent: 'world', - }, - ], - }, - ], + expect(decodeSnapshot(takeNodeSnapshot(node))).toEqual({ + type: RecordType.FullSnapshot, + format: SnapshotFormat.Change, + data: [[ChangeType.AddNode, [null, 'DIV'], [1, '#text', 'Hello'], [0, '#shadow-root'], [1, '#text', 'world']]], + timestamp: jasmine.any(Number), }) }) }) diff --git a/packages/rum/src/domain/record/internalApi.ts b/packages/rum/src/domain/record/internalApi.ts index dc10e7808c..7006c92459 100644 --- a/packages/rum/src/domain/record/internalApi.ts +++ b/packages/rum/src/domain/record/internalApi.ts @@ -1,15 +1,20 @@ import { noop, timeStampNow } from '@datadog/browser-core' import type { RumConfiguration } from '@datadog/browser-rum-core' import { getNodePrivacyLevel, NodePrivacyLevel } from '@datadog/browser-rum-core' -import type { BrowserRecord, SerializedNodeWithId } from '../../types' +import type { BrowserRecord } from '../../types' import { takeFullSnapshot as doTakeFullSnapshot } from './startFullSnapshots' import type { ShadowRootsController } from './shadowRootsController' import type { RecordingScope } from './recordingScope' import { createRecordingScope } from './recordingScope' import { createElementsScrollPositions } from './elementsScrollPositions' import type { EmitRecordCallback } from './record.types' -import type { SerializationTransaction } from './serialization' -import { SerializationKind, serializeInTransaction, serializeNode } from './serialization' +import type { ChangeSerializationTransaction } from './serialization' +import { + createRootInsertionCursor, + SerializationKind, + serializeChangesInTransaction, + serializeNodeAsChange, +} from './serialization' /** * Take a full snapshot of the document, generating the same records that the browser SDK @@ -47,24 +52,29 @@ export function takeFullSnapshot({ export function takeNodeSnapshot( node: Node, { configuration }: { configuration?: Partial } = {} -): SerializedNodeWithId | null { - let serializedNode: SerializedNodeWithId | null = null +): BrowserRecord | undefined { + let nodeSnapshotRecord: BrowserRecord | undefined + const emitRecord = (record: BrowserRecord) => { + nodeSnapshotRecord = record + } - serializeInTransaction( + serializeChangesInTransaction( SerializationKind.INITIAL_FULL_SNAPSHOT, - noop, + emitRecord, noop, createTemporaryRecordingScope(configuration), - (transaction: SerializationTransaction): void => { + timeStampNow(), + (transaction: ChangeSerializationTransaction): void => { const privacyLevel = getNodePrivacyLevel(node, transaction.scope.configuration.defaultPrivacyLevel) if (privacyLevel === NodePrivacyLevel.HIDDEN || privacyLevel === NodePrivacyLevel.IGNORE) { return } - serializedNode = serializeNode(node, privacyLevel, transaction) + const cursor = createRootInsertionCursor(transaction.scope.nodeIds) + serializeNodeAsChange(cursor, node, privacyLevel, transaction) } ) - return serializedNode + return nodeSnapshotRecord } function createTemporaryRecordingScope(configuration?: Partial): RecordingScope { diff --git a/packages/rum/src/domain/record/record.spec.ts b/packages/rum/src/domain/record/record.spec.ts index a7ddac9c22..6a119e3b13 100644 --- a/packages/rum/src/domain/record/record.spec.ts +++ b/packages/rum/src/domain/record/record.spec.ts @@ -2,21 +2,23 @@ import { DefaultPrivacyLevel, findLast, noop } from '@datadog/browser-core' import type { RumConfiguration, ViewCreatedEvent } from '@datadog/browser-rum-core' import { LifeCycle, LifeCycleEventType } from '@datadog/browser-rum-core' import { createNewEvent, collectAsyncCalls, registerCleanupTask } from '@datadog/browser-core/test' -import { findElement, findFullSnapshotInFormat, findNode, recordsPerFullSnapshot } from '../../../test' +import { recordsPerFullSnapshot } from '../../../test' import type { + AddNodeChange, + BrowserChangeRecord, BrowserIncrementalSnapshotRecord, BrowserMutationData, BrowserRecord, - DocumentFragmentNode, - ElementNode, + Change, ScrollData, } from '../../types' -import { NodeType, RecordType, IncrementalSource, SnapshotFormat } from '../../types' +import { ChangeType, RecordType, IncrementalSource, SnapshotFormat } from '../../types' import { appendElement } from '../../../../rum-core/test' import { getReplayStats } from '../replayStats' import type { RecordAPI } from './record' import { record } from './record' import type { EmitRecordCallback } from './record.types' +import { createChangeDecoder } from './serialization' describe('record', () => { let recordApi: RecordAPI @@ -128,8 +130,8 @@ describe('record', () => { if (window.visualViewport) { expect(records[i++].type).toEqual(RecordType.VisualViewport) } - expect(records[i].type).toEqual(RecordType.IncrementalSnapshot) - expect((records[i++] as BrowserIncrementalSnapshotRecord).data.source).toEqual(IncrementalSource.Mutation) + expect(records[i].type).toEqual(RecordType.Change) + expect((records[i++] as BrowserChangeRecord).data.map((change) => change[0])).toContain(ChangeType.AddNode) expect(records[i++].type).toEqual(RecordType.Meta) expect(records[i++].type).toEqual(RecordType.Focus) expect(records[i++].type).toEqual(RecordType.FullSnapshot) @@ -139,109 +141,83 @@ describe('record', () => { it('should record a simple mutation inside a shadow root', () => { const element = appendElement('
', createShadow()) startRecording() - expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot()) + expect(getEmittedRecordCount()).toBe(recordsPerFullSnapshot()) element.className = 'titi' recordApi.flushMutations() - expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot() + 1) - const innerMutationData = getLastIncrementalSnapshotData( - getEmittedRecords(), - IncrementalSource.Mutation - ) - expect(innerMutationData.attributes[0].attributes.class).toBe('titi') + expect(getEmittedRecordCount()).toBe(recordsPerFullSnapshot() + 1) + const [, ...attributeMutations] = getLastChangeOfType(ChangeType.Attribute, getEmittedRecords()) + expect(attributeMutations[0][1]).toEqual(['class', 'titi']) }) it('should record a direct removal inside a shadow root', () => { const element = appendElement('
', createShadow()) startRecording() - expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot()) + expect(getEmittedRecordCount()).toBe(recordsPerFullSnapshot()) element.remove() - recordApi.flushMutations() - const fs = findFullSnapshotInFormat(SnapshotFormat.V1, { records: getEmittedRecords() })! - const shadowRootNode = findNode( - fs.data.node, - (node) => node.type === NodeType.DocumentFragment && node.isShadowRoot - )! - expect(shadowRootNode).toBeTruthy() - expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot() + 1) - const innerMutationData = getLastIncrementalSnapshotData( - getEmittedRecords(), - IncrementalSource.Mutation - ) - expect(innerMutationData.removes.length).toBe(1) - expect(innerMutationData.removes[0].parentId).toBe(shadowRootNode.id) + expect(getEmittedRecordCount()).toBe(recordsPerFullSnapshot() + 1) + + const records = getEmittedRecords() + const horizontalRuleId = findNodeId(records, (change) => change[1] === 'HR') + const [, ...removeMutations] = getLastChangeOfType(ChangeType.RemoveNode, records) + expect(removeMutations.length).toBe(1) + expect(removeMutations[0]).toBe(horizontalRuleId) }) it('should record a direct addition inside a shadow root', () => { const shadowRoot = createShadow() appendElement('
', shadowRoot) startRecording() - expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot()) + expect(getEmittedRecordCount()).toBe(recordsPerFullSnapshot()) appendElement('', shadowRoot) - recordApi.flushMutations() - expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot() + 1) - const fs = findFullSnapshotInFormat(SnapshotFormat.V1, { records: getEmittedRecords() })! - const shadowRootNode = findNode( - fs.data.node, - (node) => node.type === NodeType.DocumentFragment && node.isShadowRoot - )! - expect(shadowRootNode).toBeTruthy() - const innerMutationData = getLastIncrementalSnapshotData( - getEmittedRecords(), - IncrementalSource.Mutation - ) - expect(innerMutationData.adds.length).toBe(1) - expect(innerMutationData.adds[0].node.type).toBe(2) - expect(innerMutationData.adds[0].parentId).toBe(shadowRootNode.id) - const addedNode = innerMutationData.adds[0].node as ElementNode - expect(addedNode.tagName).toBe('span') + expect(getEmittedRecordCount()).toBe(recordsPerFullSnapshot() + 1) + + const records = getEmittedRecords() + const [, ...addMutations] = getLastChangeOfType(ChangeType.AddNode, records) + expect(addMutations.length).toBe(1) + expect(addMutations[0][1]).toBe('SPAN') }) it('should record mutation inside a shadow root added after the FS', () => { startRecording() - expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot()) + expect(getEmittedRecordCount()).toBe(recordsPerFullSnapshot()) // shadow DOM mutation const span = appendElement('', createShadow()) recordApi.flushMutations() - expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot() + 1) - const hostMutationData = getLastIncrementalSnapshotData( - getEmittedRecords(), - IncrementalSource.Mutation - ) - expect(hostMutationData.adds.length).toBe(1) - const hostNode = hostMutationData.adds[0].node as ElementNode - const shadowRoot = hostNode.childNodes[0] as DocumentFragmentNode - expect(shadowRoot.type).toBe(NodeType.DocumentFragment) - expect(shadowRoot.isShadowRoot).toBe(true) + expect(getEmittedRecordCount()).toBe(recordsPerFullSnapshot() + 1) + + const [, ...addMutations] = getLastChangeOfType(ChangeType.AddNode, getEmittedRecords()) + expect(addMutations.length).toBe(3) + expect(addMutations[0][1]).toBe('DIV') + expect(addMutations[1][1]).toBe('#shadow-root') + expect(addMutations[2][1]).toBe('SPAN') // inner mutation span.className = 'titi' recordApi.flushMutations() - expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot() + 2) - const innerMutationData = getLastIncrementalSnapshotData( - getEmittedRecords(), - IncrementalSource.Mutation - ) - expect(innerMutationData.attributes.length).toBe(1) - expect(innerMutationData.attributes[0].attributes.class).toBe('titi') + expect(getEmittedRecordCount()).toBe(recordsPerFullSnapshot() + 2) + + const [, ...attributeMutations] = getLastChangeOfType(ChangeType.Attribute, getEmittedRecords()) + expect(attributeMutations[0][1]).toEqual(['class', 'titi']) }) it('should record the change event inside a shadow root', () => { const radio = appendElement('', createShadow()) as HTMLInputElement startRecording() - expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot()) + expect(getEmittedRecordCount()).toBe(recordsPerFullSnapshot()) // inner mutation radio.checked = true radio.dispatchEvent(createNewEvent('change', { target: radio, composed: false })) recordApi.flushMutations() + const innerMutationData = getLastIncrementalSnapshotData( getEmittedRecords(), IncrementalSource.Input @@ -269,7 +245,7 @@ describe('record', () => { it('should record the scroll event inside a shadow root', () => { const div = appendElement('
', createShadow()) as HTMLDivElement startRecording() - expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot()) + expect(getEmittedRecordCount()).toBe(recordsPerFullSnapshot()) div.dispatchEvent(createNewEvent('scroll', { target: div, composed: false })) @@ -280,12 +256,16 @@ describe('record', () => { ) expect(scrollRecords.length).toBe(1) - const scrollData = getLastIncrementalSnapshotData(getEmittedRecords(), IncrementalSource.Scroll) - - const fs = findFullSnapshotInFormat(SnapshotFormat.V1, { records: getEmittedRecords() })! - const scrollableNode = findElement(fs.data.node, (node) => node.attributes['unique-selector'] === 'enabled')! + const records = getEmittedRecords() + const scrollableNodeId = findNodeId(records, (change) => { + const [, , ...attributes]: AddNodeChange = change + return attributes.some( + (attribute) => Array.isArray(attribute) && attribute[0] === 'unique-selector' && attribute[1] === 'enabled' + ) + }) - expect(scrollData.id).toBe(scrollableNode.id) + const scrollData = getLastIncrementalSnapshotData(getEmittedRecords(), IncrementalSource.Scroll) + expect(scrollData.id).toBe(scrollableNodeId) }) it('should clean the state once the shadow dom is removed to avoid memory leak', () => { @@ -294,17 +274,17 @@ describe('record', () => { startRecording() spyOn(recordApi.shadowRootsController, 'removeShadowRoot') - expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot()) + expect(getEmittedRecordCount()).toBe(recordsPerFullSnapshot()) expect(recordApi.shadowRootsController.removeShadowRoot).toHaveBeenCalledTimes(0) + shadowRoot.host.remove() recordApi.flushMutations() expect(recordApi.shadowRootsController.removeShadowRoot).toHaveBeenCalledTimes(1) - expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot() + 1) - const mutationData = getLastIncrementalSnapshotData( - getEmittedRecords(), - IncrementalSource.Mutation - ) - expect(mutationData.removes.length).toBe(1) + expect(getEmittedRecordCount()).toBe(recordsPerFullSnapshot() + 1) + + const records = getEmittedRecords() + const [, ...removeMutations] = getLastChangeOfType(ChangeType.RemoveNode, records) + expect(removeMutations.length).toBe(1) }) it('should clean the state when both the parent and the shadow host is removed to avoid memory leak', () => { @@ -321,19 +301,18 @@ describe('record', () => { startRecording() spyOn(recordApi.shadowRootsController, 'removeShadowRoot') - expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot()) + expect(getEmittedRecordCount()).toBe(recordsPerFullSnapshot()) expect(recordApi.shadowRootsController.removeShadowRoot).toHaveBeenCalledTimes(0) parent.remove() grandParent.remove() recordApi.flushMutations() expect(recordApi.shadowRootsController.removeShadowRoot).toHaveBeenCalledTimes(1) - expect(getEmittedRecords().length).toBe(recordsPerFullSnapshot() + 1) - const mutationData = getLastIncrementalSnapshotData( - getEmittedRecords(), - IncrementalSource.Mutation - ) - expect(mutationData.removes.length).toBe(1) + expect(getEmittedRecordCount()).toBe(recordsPerFullSnapshot() + 1) + + const records = getEmittedRecords() + const [, ...removeMutations] = getLastChangeOfType(ChangeType.RemoveNode, records) + expect(removeMutations.length).toBe(2) }) function createShadow() { @@ -451,8 +430,26 @@ describe('record', () => { } as ViewCreatedEvent) } - function getEmittedRecords() { - return emitSpy.calls.allArgs().map(([record]) => record) + function getEmittedRecordCount(): number { + return emitSpy.calls.allArgs().length + } + + function getEmittedRecords(): BrowserRecord[] { + const changeDecoder = createChangeDecoder() + + const decodedRecords: BrowserRecord[] = [] + for (const [record] of emitSpy.calls.allArgs()) { + if ( + record.type === RecordType.Change || + (record.type === RecordType.FullSnapshot && record.format === SnapshotFormat.Change) + ) { + decodedRecords.push(changeDecoder.decode(record)) + } else { + decodedRecords.push(record) + } + } + + return decodedRecords } }) @@ -468,3 +465,47 @@ export function getLastIncrementalSnapshotData(changeType: T, change: Change): change is Extract { + return change[0] === changeType +} + +export function getLastChangeOfType( + changeType: T, + records: BrowserRecord[] +): Extract { + for (let i = records.length - 1; i >= 0; i--) { + const record = records[i] + if (record.type !== RecordType.Change) { + continue + } + for (const change of record.data) { + if (isChangeOfType(changeType, change)) { + return change + } + } + } + throw new Error(`Could not find Change of type ${changeType} in ${records.length} records`) +} + +function findNodeId(records: BrowserRecord[], predicate: (change: AddNodeChange) => boolean): number { + for (const record of records) { + const isChangeRecord = + record.type === RecordType.Change || + (record.type === RecordType.FullSnapshot && record.format === SnapshotFormat.Change) + if (!isChangeRecord) { + continue + } + + for (const change of record.data) { + if (!isChangeOfType(ChangeType.AddNode, change)) { + continue + } + + const [, ...addMutations] = change + return addMutations.findIndex(predicate) + } + } + + return -1 +} diff --git a/packages/rum/src/domain/record/serialization/experimentalFeatures.ts b/packages/rum/src/domain/record/serialization/experimentalFeatures.ts deleted file mode 100644 index 606b8fc1dd..0000000000 --- a/packages/rum/src/domain/record/serialization/experimentalFeatures.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ExperimentalFeature, isExperimentalFeatureEnabled } from '@datadog/browser-core' - -export function isFullSnapshotChangeRecordsEnabled(): boolean { - // We don't want to have to support the case where full snapshots use the old format and - // incremental snapshots use the new one, so we should generate full snapshot Change - // records if either feature flag is enabled. - return ( - isExperimentalFeatureEnabled(ExperimentalFeature.USE_CHANGE_RECORDS) || - isExperimentalFeatureEnabled(ExperimentalFeature.USE_INCREMENTAL_CHANGE_RECORDS) - ) -} - -export function isIncrementalSnapshotChangeRecordsEnabled(): boolean { - return isExperimentalFeatureEnabled(ExperimentalFeature.USE_INCREMENTAL_CHANGE_RECORDS) -} diff --git a/packages/rum/src/domain/record/serialization/index.ts b/packages/rum/src/domain/record/serialization/index.ts index fc9ad88686..3c479496f5 100644 --- a/packages/rum/src/domain/record/serialization/index.ts +++ b/packages/rum/src/domain/record/serialization/index.ts @@ -5,7 +5,6 @@ export { createCopyingNodeIdRemapper, createIdentityNodeIdRemapper, } from './conversions' -export { isFullSnapshotChangeRecordsEnabled, isIncrementalSnapshotChangeRecordsEnabled } from './experimentalFeatures' export { createChildInsertionCursor, createRootInsertionCursor } from './insertionCursor' export { getElementInputValue } from './serializationUtils' export { serializeFullSnapshot } from './serializeFullSnapshot' diff --git a/packages/rum/src/domain/record/startFullSnapshots.spec.ts b/packages/rum/src/domain/record/startFullSnapshots.spec.ts index 0038de83f3..b59ed2e9bb 100644 --- a/packages/rum/src/domain/record/startFullSnapshots.spec.ts +++ b/packages/rum/src/domain/record/startFullSnapshots.spec.ts @@ -1,15 +1,14 @@ import type { ViewCreatedEvent } from '@datadog/browser-rum-core' import { LifeCycle, LifeCycleEventType } from '@datadog/browser-rum-core' import type { TimeStamp } from '@datadog/browser-core' -import { addExperimentalFeatures, ExperimentalFeature, noop } from '@datadog/browser-core' -import type { BrowserRecord } from '../../types' +import { noop } from '@datadog/browser-core' import { RecordType, SnapshotFormat } from '../../types' import { appendElement } from '../../../../rum-core/test' import { startFullSnapshots } from './startFullSnapshots' import type { EmitRecordCallback, EmitStatsCallback } from './record.types' import { createRecordingScopeForTesting } from './test/recordingScope.specHelper' -const describeStartFullSnapshotsWithExpectedSnapshot = (fullSnapshotRecord: jasmine.Expected) => { +describe('startFullSnapshots', () => { const viewStartClock = { relative: 1, timeStamp: 1 as TimeStamp } let lifeCycle: LifeCycle let emitRecordCallback: jasmine.Spy @@ -74,7 +73,12 @@ const describeStartFullSnapshotsWithExpectedSnapshot = (fullSnapshotRecord: jasm type: RecordType.Focus, timestamp: jasmine.any(Number), }, - fullSnapshotRecord, + { + data: jasmine.any(Array), + type: RecordType.FullSnapshot, + format: SnapshotFormat.Change, + timestamp: jasmine.any(Number), + }, ]) ) }) @@ -98,34 +102,4 @@ const describeStartFullSnapshotsWithExpectedSnapshot = (fullSnapshotRecord: jasm serializationDuration: jasmine.anything(), }) }) -} - -describe('startFullSnapshots', () => { - describe('when generating BrowserFullSnapshotV1Record', () => { - describeStartFullSnapshotsWithExpectedSnapshot({ - data: { - node: jasmine.any(Object), - initialOffset: { - left: jasmine.any(Number), - top: jasmine.any(Number), - }, - }, - format: SnapshotFormat.V1, - type: RecordType.FullSnapshot, - timestamp: jasmine.any(Number), - }) - }) - - describe('when generating BrowserFullSnapshotChangeRecord', () => { - beforeEach(() => { - addExperimentalFeatures([ExperimentalFeature.USE_CHANGE_RECORDS]) - }) - - describeStartFullSnapshotsWithExpectedSnapshot({ - data: jasmine.any(Array), - format: SnapshotFormat.Change, - type: RecordType.FullSnapshot, - timestamp: jasmine.any(Number), - }) - }) }) diff --git a/packages/rum/src/domain/record/startFullSnapshots.ts b/packages/rum/src/domain/record/startFullSnapshots.ts index bcecd44e21..bcee2dda51 100644 --- a/packages/rum/src/domain/record/startFullSnapshots.ts +++ b/packages/rum/src/domain/record/startFullSnapshots.ts @@ -3,34 +3,19 @@ import type { LifeCycle } from '@datadog/browser-rum-core' import { timeStampNow } from '@datadog/browser-core' import type { TimeStamp } from '@datadog/browser-core' import { RecordType } from '../../types' -import { - isFullSnapshotChangeRecordsEnabled, - SerializationKind, - serializeFullSnapshotAsChange, - serializeFullSnapshot, -} from './serialization' +import { SerializationKind, serializeFullSnapshotAsChange } from './serialization' import { getVisualViewport } from './viewports' import type { RecordingScope } from './recordingScope' import type { EmitRecordCallback, EmitStatsCallback } from './record.types' -export type SerializeFullSnapshotCallback = ( - timestamp: TimeStamp, - kind: SerializationKind, - document: Document, - emitRecord: EmitRecordCallback, - emitStats: EmitStatsCallback, - scope: RecordingScope -) => void - export function startFullSnapshots( lifeCycle: LifeCycle, emitRecord: EmitRecordCallback, emitStats: EmitStatsCallback, flushMutations: () => void, - scope: RecordingScope, - serialize: SerializeFullSnapshotCallback = defaultSerializeFullSnapshotCallback() + scope: RecordingScope ) { - takeFullSnapshot(timeStampNow(), SerializationKind.INITIAL_FULL_SNAPSHOT, emitRecord, emitStats, scope, serialize) + takeFullSnapshot(timeStampNow(), SerializationKind.INITIAL_FULL_SNAPSHOT, emitRecord, emitStats, scope) const { unsubscribe } = lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, (view) => { flushMutations() @@ -39,8 +24,7 @@ export function startFullSnapshots( SerializationKind.SUBSEQUENT_FULL_SNAPSHOT, emitRecord, emitStats, - scope, - serialize + scope ) }) @@ -54,8 +38,7 @@ export function takeFullSnapshot( kind: SerializationKind, emitRecord: EmitRecordCallback, emitStats: EmitStatsCallback, - scope: RecordingScope, - serialize: SerializeFullSnapshotCallback = defaultSerializeFullSnapshotCallback() + scope: RecordingScope ): void { const { width, height } = getViewportDimension() emitRecord({ @@ -76,7 +59,7 @@ export function takeFullSnapshot( timestamp, }) - serialize(timestamp, kind, document, emitRecord, emitStats, scope) + serializeFullSnapshotAsChange(timestamp, kind, document, emitRecord, emitStats, scope) if (window.visualViewport) { emitRecord({ @@ -86,7 +69,3 @@ export function takeFullSnapshot( }) } } - -function defaultSerializeFullSnapshotCallback(): SerializeFullSnapshotCallback { - return isFullSnapshotChangeRecordsEnabled() ? serializeFullSnapshotAsChange : serializeFullSnapshot -} diff --git a/packages/rum/src/domain/record/trackers/trackMutation.ts b/packages/rum/src/domain/record/trackers/trackMutation.ts index d60f508429..2e392f64b8 100644 --- a/packages/rum/src/domain/record/trackers/trackMutation.ts +++ b/packages/rum/src/domain/record/trackers/trackMutation.ts @@ -5,11 +5,7 @@ import { getMutationObserverConstructor } from '@datadog/browser-rum-core' import type { RecordingScope } from '../recordingScope' import { createMutationBatch } from '../mutationBatch' import type { EmitRecordCallback, EmitStatsCallback } from '../record.types' -import { - isIncrementalSnapshotChangeRecordsEnabled, - serializeMutations, - serializeMutationsAsChange, -} from '../serialization' +import { serializeMutationsAsChange } from '../serialization' import type { Tracker } from './tracker.types' export type MutationTracker = Tracker & { flush: () => void } @@ -30,7 +26,7 @@ export function trackMutation( emitRecord: EmitRecordCallback, emitStats: EmitStatsCallback, scope: RecordingScope, - serialize: SerializeMutationsCallback = defaultSerializeMutationsCallback() + serialize: SerializeMutationsCallback = serializeMutationsAsChange ): MutationTracker { const MutationObserver = getMutationObserverConstructor() if (!MutationObserver) { @@ -68,7 +64,3 @@ export function trackMutation( }, } } - -function defaultSerializeMutationsCallback(): SerializeMutationsCallback { - return isIncrementalSnapshotChangeRecordsEnabled() ? serializeMutationsAsChange : serializeMutations -} diff --git a/packages/rum/src/domain/segmentCollection/startSegmentTelemetry.spec.ts b/packages/rum/src/domain/segmentCollection/startSegmentTelemetry.spec.ts index 7d15cfb28f..d6fb2fcab8 100644 --- a/packages/rum/src/domain/segmentCollection/startSegmentTelemetry.spec.ts +++ b/packages/rum/src/domain/segmentCollection/startSegmentTelemetry.spec.ts @@ -1,9 +1,8 @@ import type { Telemetry, HttpRequestEvent, BandwidthStats } from '@datadog/browser-core' -import { addExperimentalFeatures, ExperimentalFeature, Observable } from '@datadog/browser-core' +import { Observable } from '@datadog/browser-core' import type { MockTelemetry } from '@datadog/browser-core/test' import { registerCleanupTask } from '@datadog/browser-core/test' import { startMockTelemetry } from '../../../../core/test' -import { isFullSnapshotChangeRecordsEnabled, isIncrementalSnapshotChangeRecordsEnabled } from '../record' import { startSegmentTelemetry } from './startSegmentTelemetry' import type { ReplayPayload } from './buildReplayPayload' @@ -50,60 +49,50 @@ describe('segmentTelemetry', () => { registerCleanupTask(stopSegmentTelemetry) } - for (const [featureFlag, description] of [ - [undefined, 'V1 records enabled'], - [ExperimentalFeature.USE_CHANGE_RECORDS, 'full snapshot Change records enabled'], - [ExperimentalFeature.USE_INCREMENTAL_CHANGE_RECORDS, 'incremental snapshot Change records enabled'], - ] as const) { - it(`with ${description}, should collect segment telemetry for all full snapshots`, async () => { - if (featureFlag) { - addExperimentalFeatures([featureFlag]) - } - - setupSegmentTelemetryCollection() + it('should collect segment telemetry for all full snapshots', async () => { + setupSegmentTelemetryCollection() - for (const result of ['failure', 'queue-full', 'success'] as const) { - generateReplayRequest({ result, isFullSnapshot: true }) + for (const result of ['failure', 'queue-full', 'success'] as const) { + generateReplayRequest({ result, isFullSnapshot: true }) - expect(await telemetry.getEvents()).toEqual([ - jasmine.objectContaining({ - type: 'log', - status: 'debug', - message: 'Segment network request metrics', - metrics: { - cssText: { - count: 2, - max: 300, - sum: 500, - }, - encoding: { - fullSnapshot: isFullSnapshotChangeRecordsEnabled() ? 'change' : 'v1', - incrementalSnapshot: isIncrementalSnapshotChangeRecordsEnabled() ? 'change' : 'v1', - }, - isFullSnapshot: true, - ongoingRequests: { - count: 2, - totalSize: 3000, - }, - recordCount: 3, - result, - size: { - compressed: 1000, - raw: 2000, - }, - serializationDuration: { - count: 3, - max: 65, - sum: 105, - }, + expect(await telemetry.getEvents()).toEqual([ + jasmine.objectContaining({ + type: 'log', + status: 'debug', + message: 'Segment network request metrics', + metrics: { + cssText: { + count: 2, + max: 300, + sum: 500, + }, + encoding: { + fullSnapshot: 'change', + incrementalSnapshot: 'change', + }, + isFullSnapshot: true, + ongoingRequests: { + count: 2, + totalSize: 3000, }, - }), - ]) + recordCount: 3, + result, + size: { + compressed: 1000, + raw: 2000, + }, + serializationDuration: { + count: 3, + max: 65, + sum: 105, + }, + }, + }), + ]) - telemetry.reset() - } - }) - } + telemetry.reset() + } + }) it('should collect segment telemetry for failed incremental mutation requests', async () => { setupSegmentTelemetryCollection() @@ -123,8 +112,8 @@ describe('segmentTelemetry', () => { sum: 500, }, encoding: { - fullSnapshot: 'v1', - incrementalSnapshot: 'v1', + fullSnapshot: 'change', + incrementalSnapshot: 'change', }, isFullSnapshot: false, ongoingRequests: { diff --git a/packages/rum/src/domain/segmentCollection/startSegmentTelemetry.ts b/packages/rum/src/domain/segmentCollection/startSegmentTelemetry.ts index ac6dbe6e68..72c3736ded 100644 --- a/packages/rum/src/domain/segmentCollection/startSegmentTelemetry.ts +++ b/packages/rum/src/domain/segmentCollection/startSegmentTelemetry.ts @@ -1,6 +1,5 @@ import type { BandwidthStats, Context, HttpRequestEvent, Observable, Telemetry } from '@datadog/browser-core' import { TelemetryMetrics, addTelemetryMetrics, noop } from '@datadog/browser-core' -import { isFullSnapshotChangeRecordsEnabled, isIncrementalSnapshotChangeRecordsEnabled } from '../record' import type { ReplayPayload } from './buildReplayPayload' interface SegmentMetrics extends Context { @@ -68,8 +67,8 @@ function createSegmentMetrics( sum: payload.cssText.sum, }, encoding: { - fullSnapshot: isFullSnapshotChangeRecordsEnabled() ? 'change' : 'v1', - incrementalSnapshot: isIncrementalSnapshotChangeRecordsEnabled() ? 'change' : 'v1', + fullSnapshot: 'change', + incrementalSnapshot: 'change', }, isFullSnapshot: payload.isFullSnapshot, ongoingRequests: { diff --git a/test/e2e/scenario/recorder/recorder.scenario.ts b/test/e2e/scenario/recorder/recorder.scenario.ts index dd3ab4e108..621d025bbf 100644 --- a/test/e2e/scenario/recorder/recorder.scenario.ts +++ b/test/e2e/scenario/recorder/recorder.scenario.ts @@ -1,5 +1,5 @@ import type { InputData, StyleSheetRuleData, ScrollData } from '@datadog/browser-rum/src/types' -import { NodeType, IncrementalSource, SnapshotFormat, ChangeType, RecordType } from '@datadog/browser-rum/src/types' +import { IncrementalSource, ChangeType, RecordType } from '@datadog/browser-rum/src/types' import { DefaultPrivacyLevel } from '@datadog/browser-core' @@ -8,15 +8,12 @@ import { getElementIdsFromFullSnapshot, getScrollPositionsFromFullSnapshot, } from '@datadog/browser-rum/test/record/elements' -import { findElement, findElementWithIdAttribute, findTextContent } from '@datadog/browser-rum/test/record/nodes' import { findFullSnapshot, findIncrementalSnapshot, findAllIncrementalSnapshots, findMeta, - findFullSnapshotInFormat, } from '@datadog/browser-rum/test/record/segments' -import { createMutationPayloadValidatorFromSegment } from '@datadog/browser-rum/test/record/mutationPayloadValidator' import { test, expect } from '@playwright/test' import { wait } from '@datadog/browser-core/test/wait' import { createTest, html } from '../../lib/framework' @@ -77,85 +74,43 @@ test.describe('recorder', () => { }) test.describe('full snapshot', () => { - test.describe('obfuscate elements', () => { - const body = html`
displayed
-

hidden

- hidden - - ` - - createTest('V1') - .withRum() - .withBody(body) - .run(async ({ intakeRegistry, flushEvents }) => { - await flushEvents() - - expect(intakeRegistry.replaySegments).toHaveLength(1) - - const fullSnapshot = findFullSnapshotInFormat(SnapshotFormat.V1, intakeRegistry.replaySegments[0])! - - const node = findElementWithIdAttribute(fullSnapshot.data.node, 'not-obfuscated') - expect(node).toBeTruthy() - expect(findTextContent(node!)).toBe('displayed') - - 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 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 inputIgnored = findElementWithIdAttribute(fullSnapshot.data.node, 'input-not-obfuscated') - expect(inputIgnored).toBeTruthy() - expect(inputIgnored!.attributes.value).toBe('displayed') - - const inputMasked = findElementWithIdAttribute(fullSnapshot.data.node, 'input-masked') - expect(inputMasked).toBeTruthy() - expect(inputMasked!.attributes.value).toBe('***') - }) + createTest('obfuscate elements') + .withRum() + .withBody( + html`
displayed
+

hidden

+ hidden + + ` + ) + .run(async ({ intakeRegistry, flushEvents }) => { + await flushEvents() + expect(intakeRegistry.replaySegments).toHaveLength(1) - createTest('Change') - .withRum({ - enableExperimentalFeatures: ['use_incremental_change_records'], - }) - .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]], - ]) - }) - }) + 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]], + ]) + }) }) test.describe('mutations observer', () => { @@ -166,546 +121,282 @@ test.describe('recorder', () => { ` - 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 } - ) + createTest('record mutations') + .withRum() + .withBody(body) + .run(async ({ intakeRegistry, page, flushEvents }) => { + await page.evaluate(() => { + const li = document.createElement('li') + const ul = document.querySelector('ul') as HTMLUListElement - validate({ - adds: [ - { - parent: expectInitialNode({ tag: 'p' }), - node: expectNewNode({ type: NodeType.Element, tagName: 'span' }), - }, - ], - removes: [ - { - parent: expectInitialNode({ tag: 'body' }), - node: expectInitialNode({ tag: 'ul' }), - }, - ], - }) - }) + // Make sure mutations occurring in a removed element are not reported + ul.appendChild(li) + document.body.removeChild(ul) - createTest('Change') - .withRum({ - enableExperimentalFeatures: ['use_incremental_change_records'], + const p = document.querySelector('p') as HTMLParagraphElement + p.appendChild(document.createElement('span')) }) - .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], - ]) - }) - }) + await flushEvents() - 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 } - ) + const records = findChangeRecords(intakeRegistry.replaySegments[0].records) + expect(decodeChangeRecords(records).at(-1)!.data).toEqual([ + [ChangeType.AddNode, [8, 'SPAN']], + [ChangeType.RemoveNode, 9], + ]) + }) - 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('record character data mutations') + .withRum() + .withBody(body) + .run(async ({ intakeRegistry, page, flushEvents }) => { + await page.evaluate(() => { + const li = document.createElement('li') + const ul = document.querySelector('ul') as HTMLUListElement - 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], - ]) + // 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() - 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 records = findChangeRecords(intakeRegistry.replaySegments[0].records) + expect(decodeChangeRecords(records).at(-1)!.data).toEqual([ + [ChangeType.AddNode, [8, '#text', 'mutated']], + [ChangeType.RemoveNode, 9, 7], + ]) + }) - validate({ - attributes: [ - { - node: expectInitialNode({ tag: 'body' }), - attributes: { test: 'true' }, - }, - ], - removes: [ - { - parent: expectInitialNode({ tag: 'body' }), - node: expectInitialNode({ tag: 'ul' }), - }, - ], - }) - }) + createTest('record attributes mutations') + .withRum() + .withBody(body) + .run(async ({ intakeRegistry, page, flushEvents }) => { + await page.evaluate(() => { + const li = document.createElement('li') + const ul = document.querySelector('ul') as HTMLUListElement - 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']]], - ]) + // 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() - test.describe("don't record hidden elements mutations", () => { - const hiddenBody = html` + 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`
- ` - - 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) - }) - - createTest('Change') - .withRum({ - enableExperimentalFeatures: ['use_incremental_change_records'], - }) - .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) + `) + .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')) }) - }) + await flushEvents() - 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' }), - expectInitialNode({ tag: 'b' }).withChildren(expectInitialNode({ text: 'e' })), - expectInitialNode({ text: 'f' }) - ), - expectInitialNode({ text: 'g' }) - ), - }, - ], - removes: [ - { - parent: expectInitialNode({ tag: 'body' }), - node: expectInitialNode({ tag: 'span' }), - }, - ], - }) - }) + expect(intakeRegistry.replaySegments).toHaveLength(1) + const records = findChangeRecords(intakeRegistry.replaySegments[0].records) + expect(records).toHaveLength(1) + expect(records[0].type === RecordType.FullSnapshot) + }) - 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], - ]) + 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) }) - }) - - 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 } - ) + await flushEvents() - 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' }), - }, - ], - }) - }) + 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], + ]) + }) - 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], - ]) + 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() - 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 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 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, - }, - ], - }) + 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) + } }) + await flushEvents() - 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']], - ]) - }) - }) + const records = findChangeRecords(intakeRegistry.replaySegments[0].records) + expect(decodeChangeRecords(records).at(-1)!.data).toEqual([ + [ChangeType.AddNode, [3, 'LI'], [4, 'LI'], [5, 'LI']], + ]) + }) }) 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) - } - }) - } + 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') - createTestVariation('V1', []) - createTestVariation('Change', ['use_incremental_change_records']) - }) + 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) + } + }) createTest("don't record ignored input interactions") .withRum({ @@ -846,125 +537,117 @@ test.describe('recorder', () => { }) test.describe('scroll positions', () => { - 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 } - ) - } - - await page.evaluate(() => { - document.getElementsByTagName('html')[0].setAttribute('id', 'html') - }) + createTest('should be recorded across view changes') + .withRum({ + // 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] + await flushEvents() - { - const firstFullSnapshot = findFullSnapshot(firstSegment)! - const elementIds = getElementIdsFromFullSnapshot(firstFullSnapshot) - const scrollPositions = getScrollPositionsFromFullSnapshot(firstFullSnapshot) + expect(intakeRegistry.replaySegments).toHaveLength(2) + const firstSegment = intakeRegistry.replaySegments[0] - const htmlId = elementIds.get('html') - expect(htmlId).not.toBeUndefined() - expect(scrollPositions.get(htmlId!)).toEqual({ left: 0, top: 100 }) + { + const firstFullSnapshot = findFullSnapshot(firstSegment)! + const elementIds = getElementIdsFromFullSnapshot(firstFullSnapshot) + const scrollPositions = getScrollPositionsFromFullSnapshot(firstFullSnapshot) - const containerId = elementIds.get('container') - expect(containerId).not.toBeUndefined() - expect(scrollPositions.get(containerId!)).toEqual({ left: 10, top: 0 }) + const htmlId = elementIds.get('html') + expect(htmlId).not.toBeUndefined() + expect(scrollPositions.get(htmlId!)).toEqual({ left: 0, top: 100 }) - 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 containerId = elementIds.get('container') + expect(containerId).not.toBeUndefined() + expect(scrollPositions.get(containerId!)).toEqual({ left: 10, top: 0 }) - { - const secondFullSnapshot = findFullSnapshot(intakeRegistry.replaySegments.at(-1)!)! - const elementIds = getElementIdsFromFullSnapshot(secondFullSnapshot) - const scrollPositions = getScrollPositionsFromFullSnapshot(secondFullSnapshot) + 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 htmlId = elementIds.get('html') - expect(htmlId).not.toBeUndefined() - expect(scrollPositions.get(htmlId!)).toEqual({ left: 0, top: 150 }) + { + const secondFullSnapshot = findFullSnapshot(intakeRegistry.replaySegments.at(-1)!)! + const elementIds = getElementIdsFromFullSnapshot(secondFullSnapshot) + const scrollPositions = getScrollPositionsFromFullSnapshot(secondFullSnapshot) - const containerId = elementIds.get('container') - expect(containerId).not.toBeUndefined() - expect(scrollPositions.get(containerId!)).toEqual({ left: 20, top: 0 }) - } - }) - } + const htmlId = elementIds.get('html') + expect(htmlId).not.toBeUndefined() + expect(scrollPositions.get(htmlId!)).toEqual({ left: 0, top: 150 }) - createTestVariation('V1', []) - createTestVariation('Change', ['use_incremental_change_records']) - }) + const containerId = elementIds.get('container') + expect(containerId).not.toBeUndefined() + expect(scrollPositions.get(containerId!)).toEqual({ left: 20, top: 0 }) + } + }) }) 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 301c91c266..6ce980f9eb 100644 --- a/test/e2e/scenario/recorder/shadowDom.scenario.ts +++ b/test/e2e/scenario/recorder/shadowDom.scenario.ts @@ -1,27 +1,8 @@ -import type { - DocumentFragmentNode, - MouseInteractionData, - ScrollData, - SerializedNodeWithId, -} from '@datadog/browser-rum/src/types' -import { - ChangeType, - IncrementalSource, - MouseInteractionType, - NodeType, - SnapshotFormat, -} from '@datadog/browser-rum/src/types' +import type { MouseInteractionData, ScrollData } from '@datadog/browser-rum/src/types' +import { ChangeType, IncrementalSource, MouseInteractionType } from '@datadog/browser-rum/src/types' -import { createMutationPayloadValidatorFromSegment } from '@datadog/browser-rum/test/record/mutationPayloadValidator' -import { - findElementWithIdAttribute, - findNode, - findTextContent, - findTextNode, -} from '@datadog/browser-rum/test/record/nodes' import { findFullSnapshot, - findFullSnapshotInFormat, findIncrementalSnapshot, findMouseInteractionRecords, } from '@datadog/browser-rum/test/record/segments' @@ -174,352 +155,193 @@ class DivWithStyle extends HTMLElement { ` test.describe('recorder with shadow DOM', () => { - test.describe('can record fullsnapshot with the detail inside the shadow root', () => { - const body = html` + createTest('can record fullsnapshot with the detail inside the shadow root') + .withRum({ defaultPrivacyLevel: 'allow' }) + .withBody(html` ${divShadowDom} - ` - - createTest('V1') - .withRum({ - defaultPrivacyLevel: 'allow', - }) - .withBody(body) - .run(async ({ flushEvents, intakeRegistry }) => { - await flushEvents() - expect(intakeRegistry.replaySegments).toHaveLength(1) - - const fullSnapshot = findFullSnapshotInFormat(SnapshotFormat.V1, intakeRegistry.replaySegments[0])! - expect(fullSnapshot).toBeTruthy() - - const textNode = findTextNode(fullSnapshot.data.node, 'toto') - expect(textNode).toBeTruthy() - expect(textNode?.textContent).toBe('toto') - }) - - createTest('Change') - .withRum({ - defaultPrivacyLevel: 'allow', - enableExperimentalFeatures: ['use_incremental_change_records'], - }) - .withBody(body) - .run(async ({ flushEvents, intakeRegistry }) => { - 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, '#text', '\n '], - [0, '#text', '\n \n '], - [0, 'MY-DIV'], - [1, '#text', '\n '], - [0, '#shadow-root'], - [1, 'DIV', ['id', 'shadow-child']], - [1, '#text', 'toto'], - ], - [ChangeType.ScrollPosition, [0, 0, 0]], - ]) - }) - }) - - test.describe('can record fullsnapshot with adoptedStylesheet', () => { - const body = html` + `) + .run(async ({ flushEvents, intakeRegistry }) => { + 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, '#text', '\n '], + [0, '#text', '\n \n '], + [0, 'MY-DIV'], + [1, '#text', '\n '], + [0, '#shadow-root'], + [1, 'DIV', ['id', 'shadow-child']], + [1, '#text', 'toto'], + ], + [ChangeType.ScrollPosition, [0, 0, 0]], + ]) + }) + + createTest('can record fullsnapshot with adoptedStylesheet') + .withRum() + .withBody(html` ${divWithStyleShadowDom} - ` - - async function skipIfAdoptedStyleSheetsNotSupported(page: Page): Promise { - const isAdoptedStyleSheetsSupported = await page.evaluate(() => document.adoptedStyleSheets !== undefined) - test.skip(!isAdoptedStyleSheetsSupported, 'adoptedStyleSheets is not supported in this browser') - } - - createTest('V1') - .withRum() - .withBody(body) - .run(async ({ flushEvents, intakeRegistry, page }) => { - await skipIfAdoptedStyleSheetsNotSupported(page) - - await flushEvents() - expect(intakeRegistry.replaySegments).toHaveLength(1) - - const fullSnapshot = findFullSnapshotInFormat(SnapshotFormat.V1, intakeRegistry.replaySegments[0])! - expect(fullSnapshot).toBeTruthy() - const shadowRoot = findNode( - fullSnapshot.data.node, - (node) => node.type === NodeType.DocumentFragment - ) as DocumentFragmentNode - expect(shadowRoot.isShadowRoot).toBe(true) - expect(shadowRoot.adoptedStyleSheets).toEqual([{ cssRules: ['div { width: 100%; }'] }]) - }) - - createTest('Change') - .withRum({ - enableExperimentalFeatures: ['use_incremental_change_records'], - }) - .withBody(body) - .run(async ({ flushEvents, intakeRegistry, page }) => { - await skipIfAdoptedStyleSheetsNotSupported(page) - - await flushEvents() - expect(intakeRegistry.replaySegments).toHaveLength(1) - - const records = findChangeRecords(intakeRegistry.replaySegments[0].records) - expect(decodeChangeRecords(records).at(-1)!.data).toEqual([ - [ - ChangeType.AddNode, - [null, '#document'], - [1, '#doctype', 'html', '', ''], - [0, 'HTML'], - [1, 'HEAD'], - [0, 'BODY'], - [1, '#text', '\n '], - [0, '#text', '\n\n '], - [0, 'DIV-WITH-STYLE'], - [1, '#text', '\n '], - [0, '#shadow-root'], - [1, 'DIV'], - [1, '#text', 'toto'], - ], - [ChangeType.ScrollPosition, [0, 0, 0]], - [ChangeType.AddStyleSheet, [['div { width: 100%; }']]], - [ChangeType.AttachedStyleSheets, [9, 0]], - ]) - }) - }) - - test.describe('can apply privacy level set from outside or inside the shadow DOM', () => { - const body = html` + `) + .run(async ({ flushEvents, intakeRegistry, page }) => { + await skipIfAdoptedStyleSheetsNotSupported(page) + + await flushEvents() + expect(intakeRegistry.replaySegments).toHaveLength(1) + + const records = findChangeRecords(intakeRegistry.replaySegments[0].records) + expect(decodeChangeRecords(records).at(-1)!.data).toEqual([ + [ + ChangeType.AddNode, + [null, '#document'], + [1, '#doctype', 'html', '', ''], + [0, 'HTML'], + [1, 'HEAD'], + [0, 'BODY'], + [1, '#text', '\n '], + [0, '#text', '\n\n '], + [0, 'DIV-WITH-STYLE'], + [1, '#text', '\n '], + [0, '#shadow-root'], + [1, 'DIV'], + [1, '#text', 'toto'], + ], + [ChangeType.ScrollPosition, [0, 0, 0]], + [ChangeType.AddStyleSheet, [['div { width: 100%; }']]], + [ChangeType.AttachedStyleSheets, [9, 0]], + ]) + }) + + createTest('can apply privacy level set from outside or inside the shadow DOM') + .withRum({ defaultPrivacyLevel: 'allow' }) + .withBody(html` ${inputShadowDom}
- ` - - createTest('V1') - .withRum({ - defaultPrivacyLevel: 'allow', - }) - .withBody(body) - .run(async ({ flushEvents, intakeRegistry }) => { - await flushEvents() - expect(intakeRegistry.replaySegments).toHaveLength(1) - - const fullSnapshot = findFullSnapshotInFormat(SnapshotFormat.V1, intakeRegistry.replaySegments[0])! - expect(fullSnapshot).toBeTruthy() - - const { - input: outsideInput, - shadowRoot: outsideShadowRoot, - textContent: outsideTextContent, - } = findElementsInShadowDom(fullSnapshot.data.node, 'privacy-set-outside') - expect(outsideShadowRoot?.isShadowRoot).toBe(true) - expect(outsideInput?.attributes.value).toBe('***') - expect(outsideTextContent).toBe('field privacy-set-outside: ') - - const { - input: insideInput, - shadowRoot: insideShadowRoot, - textContent: insideTextContent, - } = findElementsInShadowDom(fullSnapshot.data.node, 'privacy-set-inside') - expect(insideShadowRoot?.isShadowRoot).toBe(true) - expect(insideInput?.attributes.value).toBe('***') - expect(insideTextContent).toBe('field privacy-set-inside: ') - }) - - createTest('Change') - .withRum({ - defaultPrivacyLevel: 'allow', - enableExperimentalFeatures: ['use_incremental_change_records'], - }) - .withBody(body) - .run(async ({ flushEvents, intakeRegistry }) => { - 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, '#text', '\n '], - [0, '#text', '\n \n '], - [0, 'DIV', ['data-dd-privacy', 'mask-user-input']], - [1, 'MY-INPUT-FIELD', ['id', 'privacy-set-outside']], - [1, '#shadow-root'], - [1, 'DIV'], - [1, 'LABEL', ['id', 'label-privacy-set-outside']], - [1, '#text', 'field privacy-set-outside: '], - [3, 'INPUT', ['id', 'input-privacy-set-outside'], ['value', '***']], - [10, '#text', '\n '], - [0, 'MY-INPUT-FIELD', ['privacy', 'mask-user-input'], ['id', 'privacy-set-inside']], - [1, '#text', '\n '], - [0, '#shadow-root'], - [1, 'DIV', ['data-dd-privacy', 'mask-user-input']], - [1, 'LABEL', ['id', 'label-privacy-set-inside']], - [1, '#text', 'field privacy-set-inside: '], - [3, 'INPUT', ['id', 'input-privacy-set-inside'], ['value', '***']], - ], - [ChangeType.ScrollPosition, [0, 0, 0]], - ]) - }) - }) - - test.describe('can record click with target from inside the shadow root', () => { - function createTestVariation(name: string, enableExperimentalFeatures: string[]): void { - createTest(name) - .withRum({ enableExperimentalFeatures }) - .withBody(html` - ${divShadowDom} - - `) - .run(async ({ flushEvents, intakeRegistry, page }) => { - const div = page.locator('my-div #shadow-child') - await div.click() - await flushEvents() - expect(intakeRegistry.replaySegments).toHaveLength(1) - - const fullSnapshot = findFullSnapshot({ records: intakeRegistry.replayRecords })! - const elementIds = getElementIdsFromFullSnapshot(fullSnapshot) - const shadowChildId = elementIds.get('shadow-child') - - const mouseInteraction = findMouseInteractionRecords( - intakeRegistry.replaySegments[0], - MouseInteractionType.Click - )[0] - expect(mouseInteraction).toBeTruthy() - expect((mouseInteraction.data as MouseInteractionData).id).toBe(shadowChildId) - }) - } - - createTestVariation('V1', []) - createTestVariation('Change', ['use_incremental_change_records']) - }) - - test.describe('can record mutation from inside the shadow root', () => { - const body = html` + `) + .run(async ({ flushEvents, intakeRegistry }) => { + 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, '#text', '\n '], + [0, '#text', '\n \n '], + [0, 'DIV', ['data-dd-privacy', 'mask-user-input']], + [1, 'MY-INPUT-FIELD', ['id', 'privacy-set-outside']], + [1, '#shadow-root'], + [1, 'DIV'], + [1, 'LABEL', ['id', 'label-privacy-set-outside']], + [1, '#text', 'field privacy-set-outside: '], + [3, 'INPUT', ['id', 'input-privacy-set-outside'], ['value', '***']], + [10, '#text', '\n '], + [0, 'MY-INPUT-FIELD', ['privacy', 'mask-user-input'], ['id', 'privacy-set-inside']], + [1, '#text', '\n '], + [0, '#shadow-root'], + [1, 'DIV', ['data-dd-privacy', 'mask-user-input']], + [1, 'LABEL', ['id', 'label-privacy-set-inside']], + [1, '#text', 'field privacy-set-inside: '], + [3, 'INPUT', ['id', 'input-privacy-set-inside'], ['value', '***']], + ], + [ChangeType.ScrollPosition, [0, 0, 0]], + ]) + }) + + createTest('can record click with target from inside the shadow root') + .withRum() + .withBody(html` + ${divShadowDom} + + `) + .run(async ({ flushEvents, intakeRegistry, page }) => { + const div = page.locator('my-div #shadow-child') + await div.click() + await flushEvents() + expect(intakeRegistry.replaySegments).toHaveLength(1) + + const fullSnapshot = findFullSnapshot({ records: intakeRegistry.replayRecords })! + const elementIds = getElementIdsFromFullSnapshot(fullSnapshot) + const shadowChildId = elementIds.get('shadow-child') + + const mouseInteraction = findMouseInteractionRecords( + intakeRegistry.replaySegments[0], + MouseInteractionType.Click + )[0] + expect(mouseInteraction).toBeTruthy() + expect((mouseInteraction.data as MouseInteractionData).id).toBe(shadowChildId) + }) + + createTest('can record mutation from inside the shadow root') + .withRum({ defaultPrivacyLevel: 'allow' }) + .withBody(html` ${divShadowDom} - ` - - const mutate = () => { - const host = document.body.querySelector('#host') as HTMLElement - const div = host.shadowRoot!.querySelector('div') as HTMLElement - div.innerText = 'titi' - } - - createTest('V1') - .withRum({ - defaultPrivacyLevel: 'allow', - }) - .withBody(body) - .run(async ({ flushEvents, intakeRegistry, page }) => { - await page.evaluate(mutate) - await flushEvents() - expect(intakeRegistry.replaySegments).toHaveLength(1) - - const { validate, expectInitialNode, expectNewNode } = createMutationPayloadValidatorFromSegment( - intakeRegistry.replaySegments[0], - { expect } - ) - validate({ - adds: [ - { - parent: expectInitialNode({ tag: 'div' }), - node: expectNewNode({ type: NodeType.Text, textContent: 'titi' }), - }, - ], - removes: [ - { - parent: expectInitialNode({ tag: 'div' }), - node: expectInitialNode({ text: 'toto' }), - }, - ], - }) + `) + .run(async ({ flushEvents, intakeRegistry, page }) => { + await page.evaluate(() => { + const host = document.body.querySelector('#host') as HTMLElement + const div = host.shadowRoot!.querySelector('div') as HTMLElement + div.innerText = 'titi' }) - - createTest('Change') - .withRum({ - defaultPrivacyLevel: 'allow', - enableExperimentalFeatures: ['use_incremental_change_records'], - }) - .withBody(body) - .run(async ({ flushEvents, intakeRegistry, page }) => { - await page.evaluate(mutate) - await flushEvents() - expect(intakeRegistry.replaySegments).toHaveLength(1) - - const records = findChangeRecords(intakeRegistry.replaySegments[0].records) - expect(decodeChangeRecords(records).at(-1)!.data).toEqual([ - [ChangeType.AddNode, [2, '#text', 'titi']], - [ChangeType.RemoveNode, 11], - ]) - }) - }) - - test.describe('can record scroll from inside the shadow root', () => { - function createTestVariation(name: string, enableExperimentalFeatures: string[]): void { - createTest(name) - .withRum({ enableExperimentalFeatures }) - .withBody(html` - ${scrollableDivShadowDom} - - `) - .run(async ({ flushEvents, intakeRegistry, page }) => { - const button = page.locator('my-scrollable-div button') - - // Triggering scrollTo from the test itself is not allowed - // Thus, a callback to scroll the div was added to the button 'click' event - await button.click() - - await flushEvents() - expect(intakeRegistry.replaySegments).toHaveLength(1) - - const firstSegment = intakeRegistry.replaySegments[0] - const fullSnapshot = findFullSnapshot(firstSegment)! - const elementIds = getElementIdsFromFullSnapshot(fullSnapshot) - - const divId = elementIds.get('scrollable-div') - expect(divId).not.toBeUndefined() - - const scrollRecord = findIncrementalSnapshot(firstSegment, IncrementalSource.Scroll) - expect(scrollRecord).toBeTruthy() - - const scrollData = scrollRecord?.data as ScrollData - expect(scrollData.id).toBe(divId) - expect(scrollData.y).toBe(250) - }) - } - - createTestVariation('V1', []) - createTestVariation('Change', ['use_incremental_change_records']) - }) + await flushEvents() + expect(intakeRegistry.replaySegments).toHaveLength(1) + + const records = findChangeRecords(intakeRegistry.replaySegments[0].records) + expect(decodeChangeRecords(records).at(-1)!.data).toEqual([ + [ChangeType.AddNode, [2, '#text', 'titi']], + [ChangeType.RemoveNode, 11], + ]) + }) + + createTest('can record scroll from inside the shadow root') + .withRum() + .withBody(html` + ${scrollableDivShadowDom} + + `) + .run(async ({ flushEvents, intakeRegistry, page }) => { + const button = page.locator('my-scrollable-div button') + + // Triggering scrollTo from the test itself is not allowed + // Thus, a callback to scroll the div was added to the button 'click' event + await button.click() + + await flushEvents() + expect(intakeRegistry.replaySegments).toHaveLength(1) + + const firstSegment = intakeRegistry.replaySegments[0] + const fullSnapshot = findFullSnapshot(firstSegment)! + const elementIds = getElementIdsFromFullSnapshot(fullSnapshot) + + const divId = elementIds.get('scrollable-div') + expect(divId).not.toBeUndefined() + + const scrollRecord = findIncrementalSnapshot(firstSegment, IncrementalSource.Scroll) + expect(scrollRecord).toBeTruthy() + + const scrollData = scrollRecord?.data as ScrollData + expect(scrollData.id).toBe(divId) + expect(scrollData.y).toBe(250) + }) }) -function findElementsInShadowDom(node: SerializedNodeWithId, id: string) { - const shadowHost = findElementWithIdAttribute(node, id) - expect(shadowHost).toBeTruthy() - - const shadowRoot = shadowHost!.childNodes.find( - (node) => node.type === NodeType.DocumentFragment && node.isShadowRoot - ) as DocumentFragmentNode - expect(shadowRoot).toBeTruthy() - - const input = findElementWithIdAttribute(node, `input-${id}`) - expect(input).toBeTruthy() - - const text = findElementWithIdAttribute(node, `label-${id}`)! - expect(text).toBeTruthy() - const textContent = findTextContent(text) - expect(textContent).toBeTruthy() - return { shadowHost, shadowRoot, input, text, textContent } +async function skipIfAdoptedStyleSheetsNotSupported(page: Page): Promise { + const isAdoptedStyleSheetsSupported = await page.evaluate(() => document.adoptedStyleSheets !== undefined) + test.skip(!isAdoptedStyleSheetsSupported, 'adoptedStyleSheets is not supported in this browser') }