From 67e51423088b306e9ad80255da8b6d727390ddc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt?= Date: Wed, 18 Feb 2026 18:28:46 +0100 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=85=20fix=20flaky=20tests=20related?= =?UTF-8?q?=20to=20experimental=20features=20(#4207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../configuration/configuration.spec.ts | 10 +------ .../src/domain/telemetry/telemetry.spec.ts | 6 +---- packages/core/src/index.ts | 1 - .../src/tools/experimentalFeatures.spec.ts | 6 ----- .../test/emulate/mockExperimentalFeatures.ts | 11 -------- packages/core/test/forEach.spec.ts | 2 ++ packages/core/test/index.ts | 1 - .../rum-core/src/boot/preStartRum.spec.ts | 4 +-- .../rum-core/src/boot/rumPublicApi.spec.ts | 16 +++++------ .../action/getActionNameFromElement.spec.ts | 13 +++++---- .../domain/action/trackClickActions.spec.ts | 7 ++--- .../domain/action/trackManualActions.spec.ts | 6 ++--- .../domain/contexts/sourceCodeContext.spec.ts | 6 ++--- .../src/domain/resource/resourceUtils.spec.ts | 5 ++-- .../domain/startCustomerDataTelemetry.spec.ts | 6 +---- .../src/domain/view/trackViews.spec.ts | 4 --- .../trackCumulativeLayoutShift.spec.ts | 6 +---- .../trackInteractionToNextPaint.spec.ts | 3 +-- .../trackLargestContentfulPaint.spec.ts | 9 +------ .../src/domain/vital/vitalCollection.spec.ts | 8 +++--- packages/rum/src/boot/startRecording.spec.ts | 11 +++----- .../domain/record/startFullSnapshots.spec.ts | 5 ++-- .../startSegmentTelemetry.spec.ts | 27 ++++++++----------- 23 files changed, 54 insertions(+), 119 deletions(-) delete mode 100644 packages/core/test/emulate/mockExperimentalFeatures.ts diff --git a/packages/core/src/domain/configuration/configuration.spec.ts b/packages/core/src/domain/configuration/configuration.spec.ts index 5344e567f4..aa4a74e4b2 100644 --- a/packages/core/src/domain/configuration/configuration.spec.ts +++ b/packages/core/src/domain/configuration/configuration.spec.ts @@ -2,11 +2,7 @@ import type { RumEvent } from '../../../../rum-core/src' import { EXHAUSTIVE_INIT_CONFIGURATION, SERIALIZED_EXHAUSTIVE_INIT_CONFIGURATION } from '../../../test' import type { ExtractTelemetryConfiguration, MapInitConfigurationKey } from '../../../test' import { DOCS_ORIGIN, MORE_DETAILS, display } from '../../tools/display' -import { - ExperimentalFeature, - isExperimentalFeatureEnabled, - resetExperimentalFeatures, -} from '../../tools/experimentalFeatures' +import { ExperimentalFeature, isExperimentalFeatureEnabled } from '../../tools/experimentalFeatures' import { SessionPersistence } from '../session/sessionConstants' import { TrackingConsent } from '../trackingConsent' import type { InitConfiguration } from './configuration' @@ -21,10 +17,6 @@ describe('validateAndBuildConfiguration', () => { displaySpy = spyOn(display, 'error') }) - afterEach(() => { - resetExperimentalFeatures() - }) - describe('experimentalFeatures', () => { const TEST_FEATURE_FLAG = 'foo' as ExperimentalFeature diff --git a/packages/core/src/domain/telemetry/telemetry.spec.ts b/packages/core/src/domain/telemetry/telemetry.spec.ts index 3c591d89b9..d98ad4d569 100644 --- a/packages/core/src/domain/telemetry/telemetry.spec.ts +++ b/packages/core/src/domain/telemetry/telemetry.spec.ts @@ -2,7 +2,7 @@ import type { TimeStamp } from '@datadog/browser-rum/internal' import { NO_ERROR_STACK_PRESENT_MESSAGE } from '../error/error' import { callMonitored } from '../../tools/monitor' import type { ExperimentalFeature } from '../../tools/experimentalFeatures' -import { resetExperimentalFeatures, addExperimentalFeatures } from '../../tools/experimentalFeatures' +import { addExperimentalFeatures } from '../../tools/experimentalFeatures' import { validateAndBuildConfiguration, type Configuration } from '../configuration' import { INTAKE_SITE_US1_FED, INTAKE_SITE_US1 } from '../intakeSites' import { @@ -87,10 +87,6 @@ describe('telemetry', () => { }) describe('addTelemetryConfiguration', () => { - afterEach(() => { - resetExperimentalFeatures() - }) - it('should collects configuration when sampled', async () => { const { getTelemetryEvents } = startAndSpyTelemetry({ telemetrySampleRate: 100, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 12603eb5d0..4c2e3dba14 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,7 +14,6 @@ export { TrackingConsent, createTrackingConsentState } from './domain/trackingCo export { isExperimentalFeatureEnabled, addExperimentalFeatures, - resetExperimentalFeatures, getExperimentalFeatures, initFeatureFlags, ExperimentalFeature, diff --git a/packages/core/src/tools/experimentalFeatures.spec.ts b/packages/core/src/tools/experimentalFeatures.spec.ts index 1726165ba5..0655d567b5 100644 --- a/packages/core/src/tools/experimentalFeatures.spec.ts +++ b/packages/core/src/tools/experimentalFeatures.spec.ts @@ -3,17 +3,12 @@ import { addExperimentalFeatures, initFeatureFlags, isExperimentalFeatureEnabled, - resetExperimentalFeatures, } from './experimentalFeatures' const TEST_FEATURE_FLAG_ONE = 'foo' as ExperimentalFeature const TEST_FEATURE_FLAG_TWO = 'bar' as ExperimentalFeature describe('experimentalFeatures', () => { - afterEach(() => { - resetExperimentalFeatures() - }) - it('initial state is empty', () => { expect(isExperimentalFeatureEnabled(TEST_FEATURE_FLAG_ONE)).toBeFalse() expect(isExperimentalFeatureEnabled(TEST_FEATURE_FLAG_TWO)).toBeFalse() @@ -41,7 +36,6 @@ describe('initFeatureFlags', () => { afterEach(() => { delete (ExperimentalFeature as any).FOO - resetExperimentalFeatures() }) it('ignores unknown experimental features', () => { diff --git a/packages/core/test/emulate/mockExperimentalFeatures.ts b/packages/core/test/emulate/mockExperimentalFeatures.ts deleted file mode 100644 index 0a1f003028..0000000000 --- a/packages/core/test/emulate/mockExperimentalFeatures.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { - resetExperimentalFeatures, - type ExperimentalFeature, - addExperimentalFeatures, -} from '../../src/tools/experimentalFeatures' -import { registerCleanupTask } from '../registerCleanupTask' - -export function mockExperimentalFeatures(enabledFeatures: ExperimentalFeature[]) { - addExperimentalFeatures(enabledFeatures) - registerCleanupTask(resetExperimentalFeatures) -} diff --git a/packages/core/test/forEach.spec.ts b/packages/core/test/forEach.spec.ts index 685b3810e0..26be9d3b9b 100644 --- a/packages/core/test/forEach.spec.ts +++ b/packages/core/test/forEach.spec.ts @@ -1,3 +1,4 @@ +import { resetExperimentalFeatures } from '../src/tools/experimentalFeatures' import { resetValueHistoryGlobals } from '../src/tools/valueHistory' import { resetFetchObservable } from '../src/browser/fetchObservable' import { resetConsoleObservable } from '../src/domain/console/consoleObservable' @@ -35,6 +36,7 @@ afterEach(() => { resetMonitor() resetTelemetry() resetInteractionCountPolyfill() + resetExperimentalFeatures() }) function clearAllCookies() { diff --git a/packages/core/test/index.ts b/packages/core/test/index.ts index 16322a6105..ab898056e9 100644 --- a/packages/core/test/index.ts +++ b/packages/core/test/index.ts @@ -15,7 +15,6 @@ export * from './emulate/mockVisibilityState' export * from './emulate/mockNavigator' export * from './emulate/mockEventBridge' export * from './emulate/mockFlushController' -export * from './emulate/mockExperimentalFeatures' export * from './emulate/mockFetch' export * from './emulate/mockXhr' export * from './emulate/mockEventTarget' diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index 875e9afc91..7f94bbc124 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -10,6 +10,7 @@ import { DefaultPrivacyLevel, ExperimentalFeature, startTelemetry, + addExperimentalFeatures, } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' import { @@ -18,7 +19,6 @@ import { mockClock, mockEventBridge, mockSyntheticsWorkerValues, - mockExperimentalFeatures, createFakeTelemetryObject, replaceMockableWithSpy, } from '@datadog/browser-core/test' @@ -686,7 +686,7 @@ describe('preStartRum', () => { }) it('startAction / stopAction', () => { - mockExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) + addExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) const startActionSpy = jasmine.createSpy() const stopActionSpy = jasmine.createSpy() diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index bb85c4cd84..b409237f0d 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -7,14 +7,10 @@ import { ExperimentalFeature, ResourceType, startTelemetry, + addExperimentalFeatures, } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' -import { - createFakeTelemetryObject, - mockClock, - mockExperimentalFeatures, - replaceMockableWithSpy, -} from '@datadog/browser-core/test' +import { createFakeTelemetryObject, mockClock, replaceMockableWithSpy } from '@datadog/browser-core/test' import { noopRecorderApi, noopProfilerApi } from '../../test' import { ActionType, VitalType } from '../rawRumEvent.types' import type { DurationVitalReference } from '../domain/vital/vitalCollection' @@ -742,7 +738,7 @@ describe('rum public api', () => { describe('startAction / stopAction', () => { it('should call startAction and stopAction on the strategy', () => { - mockExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) + addExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) const startActionSpy = jasmine.createSpy() const stopActionSpy = jasmine.createSpy() @@ -778,7 +774,7 @@ describe('rum public api', () => { }) it('should sanitize startAction and stopAction inputs', () => { - mockExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) + addExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) const startActionSpy = jasmine.createSpy() const { rumPublicApi } = makeRumPublicApiWithDefaults({ @@ -824,7 +820,7 @@ describe('rum public api', () => { describe('startResource / stopResource', () => { it('should call startResource and stopResource on the strategy', () => { - mockExperimentalFeatures([ExperimentalFeature.START_STOP_RESOURCE]) + addExperimentalFeatures([ExperimentalFeature.START_STOP_RESOURCE]) const startResourceSpy = jasmine.createSpy() const stopResourceSpy = jasmine.createSpy() @@ -864,7 +860,7 @@ describe('rum public api', () => { }) it('should sanitize startResource and stopResource inputs', () => { - mockExperimentalFeatures([ExperimentalFeature.START_STOP_RESOURCE]) + addExperimentalFeatures([ExperimentalFeature.START_STOP_RESOURCE]) const startResourceSpy = jasmine.createSpy() const { rumPublicApi } = makeRumPublicApiWithDefaults({ diff --git a/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts b/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts index 3def053547..98f20421f2 100644 --- a/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts +++ b/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts @@ -1,5 +1,4 @@ -import { ExperimentalFeature } from '@datadog/browser-core' -import { mockExperimentalFeatures } from '../../../../core/test' +import { addExperimentalFeatures, ExperimentalFeature } from '@datadog/browser-core' import { appendElement, mockRumConfiguration } from '../../../test' import { NodePrivacyLevel } from '../privacyConstants' import { getNodeSelfPrivacyLevel } from '../privacy' @@ -115,7 +114,7 @@ describe('getActionNameFromElement', () => { }) it('should introduce whitespace for block-level display values', () => { - mockExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME]) + addExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME]) const testCases = [ { display: 'block', expected: 'space' }, { display: 'inline-block', expected: 'no-space' }, @@ -484,7 +483,7 @@ describe('getActionNameFromElement', () => { }) it('removes only the child with programmatic action name in textual content', () => { - mockExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME]) + addExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME]) const { name, nameSource } = getActionNameFromElement( appendElement('
Foobar Baz
bar
'), defaultConfiguration @@ -512,7 +511,7 @@ describe('getActionNameFromElement', () => { } it('preserves privacy level of the element when defaultPrivacyLevel is mask-unless-allowlisted', () => { - mockExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME]) + addExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME]) const { name, nameSource } = getActionNameFromElement( appendElement(`
@@ -666,7 +665,7 @@ describe('getActionNameFromElement', () => { }, ] testCases.forEach(({ html, defaultPrivacyLevel, allowlist, expectedName, expectedNameSource }) => { - mockExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME]) + addExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME]) ;(window as BrowserWindow).$DD_ALLOW = new Set(allowlist) const target = appendElement(html) const { name, nameSource } = getActionNameFromElement( @@ -883,7 +882,7 @@ describe('getActionNameFromElement', () => { }) it('inherit privacy level and remove only the masked child', () => { - mockExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME]) + addExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME]) expect( getActionNameFromElement( appendElement(` diff --git a/packages/rum-core/src/domain/action/trackClickActions.spec.ts b/packages/rum-core/src/domain/action/trackClickActions.spec.ts index da0cd6b6f9..da4014a6f3 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.spec.ts @@ -8,9 +8,10 @@ import { Observable, ExperimentalFeature, PageExitReason, + addExperimentalFeatures, } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' -import { createNewEvent, mockClock, mockExperimentalFeatures } from '@datadog/browser-core/test' +import { createNewEvent, mockClock } from '@datadog/browser-core/test' import { createFakeClick, createMutationRecord, mockRumConfiguration } from '../../../test' import type { AssembledRumEvent } from '../../rawRumEvent.types' import { RumEventType, ActionType, FrustrationType } from '../../rawRumEvent.types' @@ -478,7 +479,7 @@ describe('trackClickActions', () => { }) it('should mask action name when defaultPrivacyLevel is mask_unless_allowlisted and not in allowlist', () => { - mockExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME]) + addExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME]) startClickActionsTracking({ defaultPrivacyLevel: DefaultPrivacyLevel.MASK_UNLESS_ALLOWLISTED, enablePrivacyForActionName: true, @@ -523,7 +524,7 @@ describe('trackClickActions', () => { }) it('should use allowlist masking when defaultPrivacyLevel is allow and node privacy level is mask-unless-allowlisted', () => { - mockExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME]) + addExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME]) button.setAttribute('data-dd-privacy', 'mask-unless-allowlisted') startClickActionsTracking({ defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW, diff --git a/packages/rum-core/src/domain/action/trackManualActions.spec.ts b/packages/rum-core/src/domain/action/trackManualActions.spec.ts index 58235059c5..bf69126453 100644 --- a/packages/rum-core/src/domain/action/trackManualActions.spec.ts +++ b/packages/rum-core/src/domain/action/trackManualActions.spec.ts @@ -1,7 +1,7 @@ import type { Duration, ServerDuration } from '@datadog/browser-core' -import { ExperimentalFeature, Observable } from '@datadog/browser-core' +import { addExperimentalFeatures, ExperimentalFeature, Observable } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' -import { mockClock, mockExperimentalFeatures, registerCleanupTask } from '@datadog/browser-core/test' +import { mockClock, registerCleanupTask } from '@datadog/browser-core/test' import { collectAndValidateRawRumEvents, mockRumConfiguration } from '../../../test' import type { RawRumActionEvent, RawRumEvent } from '../../rawRumEvent.types' import { RumEventType, ActionType, FrustrationType } from '../../rawRumEvent.types' @@ -21,7 +21,7 @@ describe('trackManualActions', () => { beforeEach(() => { clock = mockClock() - mockExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) + addExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) const domMutationObservable = new Observable() const windowOpenObservable = new Observable() diff --git a/packages/rum-core/src/domain/contexts/sourceCodeContext.spec.ts b/packages/rum-core/src/domain/contexts/sourceCodeContext.spec.ts index 2c7c0b3d7e..b7336b4119 100644 --- a/packages/rum-core/src/domain/contexts/sourceCodeContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sourceCodeContext.spec.ts @@ -1,8 +1,8 @@ -import { ExperimentalFeature, HookNames } from '@datadog/browser-core' +import { addExperimentalFeatures, ExperimentalFeature, HookNames } from '@datadog/browser-core' import type { RelativeTime } from '@datadog/browser-core' import type { AssembleHookParams, Hooks } from '../hooks' import { createHooks } from '../hooks' -import { mockExperimentalFeatures, registerCleanupTask } from '../../../../core/test' +import { registerCleanupTask } from '../../../../core/test' import type { RawRumLongAnimationFrameEvent } from '../../rawRumEvent.types' import type { BrowserWindow } from './sourceCodeContext' import { startSourceCodeContext } from './sourceCodeContext' @@ -59,7 +59,7 @@ describe('sourceCodeContext', () => { describe('assemble hook when FF enabled', () => { beforeEach(() => { - mockExperimentalFeatures([ExperimentalFeature.SOURCE_CODE_CONTEXT]) + addExperimentalFeatures([ExperimentalFeature.SOURCE_CODE_CONTEXT]) }) it('should add source code context matching the error stack first frame URL', () => { diff --git a/packages/rum-core/src/domain/resource/resourceUtils.spec.ts b/packages/rum-core/src/domain/resource/resourceUtils.spec.ts index 65623b9032..2e53855db0 100644 --- a/packages/rum-core/src/domain/resource/resourceUtils.spec.ts +++ b/packages/rum-core/src/domain/resource/resourceUtils.spec.ts @@ -1,6 +1,5 @@ -import { type Duration, type RelativeTime, type ServerDuration } from '@datadog/browser-core' +import { addExperimentalFeatures, type Duration, type RelativeTime, type ServerDuration } from '@datadog/browser-core' import { ExperimentalFeature } from '@datadog/browser-core' -import { mockExperimentalFeatures } from '@datadog/browser-core/test' import { RumPerformanceEntryType, type RumPerformanceResourceTiming } from '../../browser/performanceObservable' import { MAX_RESOURCE_VALUE_CHAR_LENGTH, @@ -296,7 +295,7 @@ describe('shouldTrackResource', () => { }) it('should allow requests on intake endpoints when TRACK_INTAKE_REQUESTS is enabled', () => { - mockExperimentalFeatures([ExperimentalFeature.TRACK_INTAKE_REQUESTS]) + addExperimentalFeatures([ExperimentalFeature.TRACK_INTAKE_REQUESTS]) expect(isAllowedRequestUrl(`https://rum-intake.com/v1/input/abcde?${intakeParameters}`)).toBe(true) }) diff --git a/packages/rum-core/src/domain/startCustomerDataTelemetry.spec.ts b/packages/rum-core/src/domain/startCustomerDataTelemetry.spec.ts index 7aa84901e1..5c35c3019e 100644 --- a/packages/rum-core/src/domain/startCustomerDataTelemetry.spec.ts +++ b/packages/rum-core/src/domain/startCustomerDataTelemetry.spec.ts @@ -1,5 +1,5 @@ import type { FlushEvent, Context, Telemetry } from '@datadog/browser-core' -import { Observable, resetExperimentalFeatures } from '@datadog/browser-core' +import { Observable } from '@datadog/browser-core' import type { Clock, MockTelemetry } from '@datadog/browser-core/test' import { mockClock, startMockTelemetry } from '@datadog/browser-core/test' import type { AssembledRumEvent } from '../rawRumEvent.types' @@ -52,10 +52,6 @@ describe('customerDataTelemetry', () => { clock = mockClock() }) - afterEach(() => { - resetExperimentalFeatures() - }) - it('should collect customer data telemetry', async () => { setupCustomerTelemetryCollection() diff --git a/packages/rum-core/src/domain/view/trackViews.spec.ts b/packages/rum-core/src/domain/view/trackViews.spec.ts index d8a57aa688..50a29d3c35 100644 --- a/packages/rum-core/src/domain/view/trackViews.spec.ts +++ b/packages/rum-core/src/domain/view/trackViews.spec.ts @@ -5,7 +5,6 @@ import { display, relativeToClocks, relativeNow, - resetExperimentalFeatures, addExperimentalFeatures, ExperimentalFeature, } from '@datadog/browser-core' @@ -432,7 +431,6 @@ describe('view metrics', () => { beforeEach(() => { addExperimentalFeatures([ExperimentalFeature.LCP_SUBPARTS]) - registerCleanupTask(resetExperimentalFeatures) clock = mockClock() ;({ notifyPerformanceEntries } = mockPerformanceObserver()) @@ -899,7 +897,6 @@ describe('view event count', () => { registerCleanupTask(() => { viewTest.stop() - resetExperimentalFeatures() }) }) @@ -1057,7 +1054,6 @@ describe('service and version', () => { registerCleanupTask(() => { viewTest.stop() - resetExperimentalFeatures() }) }) diff --git a/packages/rum-core/src/domain/view/viewMetrics/trackCumulativeLayoutShift.spec.ts b/packages/rum-core/src/domain/view/viewMetrics/trackCumulativeLayoutShift.spec.ts index aff6b0f2b7..8d03c0fa57 100644 --- a/packages/rum-core/src/domain/view/viewMetrics/trackCumulativeLayoutShift.spec.ts +++ b/packages/rum-core/src/domain/view/viewMetrics/trackCumulativeLayoutShift.spec.ts @@ -1,6 +1,6 @@ import type { RelativeTime } from '@datadog/browser-core' import { registerCleanupTask } from '@datadog/browser-core/test' -import { resetExperimentalFeatures, elapsed, ONE_SECOND } from '@datadog/browser-core' +import { elapsed, ONE_SECOND } from '@datadog/browser-core' import { appendElement, appendText, @@ -238,10 +238,6 @@ describe('trackCumulativeLayoutShift', () => { }) describe('cls target element', () => { - afterEach(() => { - resetExperimentalFeatures() - }) - it('should return the first target element selector amongst all the shifted nodes', () => { startCLSTracking() const textNode = appendText('text') diff --git a/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.spec.ts b/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.spec.ts index bdc4b769d0..83ad0e925f 100644 --- a/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.spec.ts +++ b/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.spec.ts @@ -1,5 +1,5 @@ import type { Duration, RelativeTime } from '@datadog/browser-core' -import { elapsed, relativeNow, resetExperimentalFeatures } from '@datadog/browser-core' +import { elapsed, relativeNow } from '@datadog/browser-core' import { registerCleanupTask } from '@datadog/browser-core/test' import { appendElement, @@ -53,7 +53,6 @@ describe('trackInteractionToNextPaint', () => { registerCleanupTask(() => { interactionToNextPaintTracking.stop() - resetExperimentalFeatures() interactionCountMock.clear() }) } diff --git a/packages/rum-core/src/domain/view/viewMetrics/trackLargestContentfulPaint.spec.ts b/packages/rum-core/src/domain/view/viewMetrics/trackLargestContentfulPaint.spec.ts index 9cab3e8543..db9537f95e 100644 --- a/packages/rum-core/src/domain/view/viewMetrics/trackLargestContentfulPaint.spec.ts +++ b/packages/rum-core/src/domain/view/viewMetrics/trackLargestContentfulPaint.spec.ts @@ -1,11 +1,5 @@ import type { RelativeTime } from '@datadog/browser-core' -import { - clocksOrigin, - DOM_EVENT, - ExperimentalFeature, - addExperimentalFeatures, - resetExperimentalFeatures, -} from '@datadog/browser-core' +import { clocksOrigin, DOM_EVENT, ExperimentalFeature, addExperimentalFeatures } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' import { setPageVisibility, @@ -86,7 +80,6 @@ describe('trackLargestContentfulPaint', () => { beforeEach(() => { addExperimentalFeatures([ExperimentalFeature.LCP_SUBPARTS]) - registerCleanupTask(resetExperimentalFeatures) lcpCallback = jasmine.createSpy() eventTarget = document.createElement('div') as unknown as Window diff --git a/packages/rum-core/src/domain/vital/vitalCollection.spec.ts b/packages/rum-core/src/domain/vital/vitalCollection.spec.ts index 302c5ae01a..c962af3caa 100644 --- a/packages/rum-core/src/domain/vital/vitalCollection.spec.ts +++ b/packages/rum-core/src/domain/vital/vitalCollection.spec.ts @@ -1,6 +1,6 @@ import type { Duration } from '@datadog/browser-core' -import { mockClock, mockExperimentalFeatures, type Clock } from '@datadog/browser-core/test' -import { clocksNow, ExperimentalFeature } from '@datadog/browser-core' +import { mockClock, type Clock } from '@datadog/browser-core/test' +import { addExperimentalFeatures, clocksNow, ExperimentalFeature } from '@datadog/browser-core' import { collectAndValidateRawRumEvents, mockPageStateHistory } from '../../../test' import type { RawRumEvent, RawRumVitalEvent } from '../../rawRumEvent.types' import { VitalType, RumEventType } from '../../rawRumEvent.types' @@ -236,7 +236,7 @@ describe('vitalCollection', () => { }) it('should collect raw rum event from operation step vital', () => { - mockExperimentalFeatures([ExperimentalFeature.FEATURE_OPERATION_VITAL]) + addExperimentalFeatures([ExperimentalFeature.FEATURE_OPERATION_VITAL]) vitalCollection.addOperationStepVital('foo', 'start') expect(rawRumEvents[0].startClocks.relative).toEqual(jasmine.any(Number)) @@ -274,7 +274,7 @@ describe('vitalCollection', () => { }) it('should create a operation step vital from add API', () => { - mockExperimentalFeatures([ExperimentalFeature.FEATURE_OPERATION_VITAL]) + addExperimentalFeatures([ExperimentalFeature.FEATURE_OPERATION_VITAL]) vitalCollection.addOperationStepVital( 'foo', 'end', diff --git a/packages/rum/src/boot/startRecording.spec.ts b/packages/rum/src/boot/startRecording.spec.ts index df715486af..f8ba823328 100644 --- a/packages/rum/src/boot/startRecording.spec.ts +++ b/packages/rum/src/boot/startRecording.spec.ts @@ -6,16 +6,11 @@ import { DeflateEncoderStreamId, Observable, ExperimentalFeature, + addExperimentalFeatures, } from '@datadog/browser-core' import type { ViewCreatedEvent } from '@datadog/browser-rum-core' import { LifeCycle, LifeCycleEventType, startViewHistory } from '@datadog/browser-rum-core' -import { - collectAsyncCalls, - createNewEvent, - mockEventBridge, - mockExperimentalFeatures, - registerCleanupTask, -} from '@datadog/browser-core/test' +import { collectAsyncCalls, createNewEvent, mockEventBridge, registerCleanupTask } from '@datadog/browser-core/test' import type { ViewEndedEvent } from 'packages/rum-core/src/domain/view/trackViews' import type { RumSessionManagerMock } from '../../../rum-core/test' import { appendElement, createRumSessionManagerMock, mockRumConfiguration } from '../../../rum-core/test' @@ -110,7 +105,7 @@ describe('startRecording', () => { }) it('sends recorded segments with valid context when Change records are enabled', async () => { - mockExperimentalFeatures([ExperimentalFeature.USE_CHANGE_RECORDS]) + addExperimentalFeatures([ExperimentalFeature.USE_CHANGE_RECORDS]) setupStartRecording() flushSegment(lifeCycle) diff --git a/packages/rum/src/domain/record/startFullSnapshots.spec.ts b/packages/rum/src/domain/record/startFullSnapshots.spec.ts index bb13677a13..6d7aee046c 100644 --- a/packages/rum/src/domain/record/startFullSnapshots.spec.ts +++ b/packages/rum/src/domain/record/startFullSnapshots.spec.ts @@ -1,8 +1,7 @@ import type { ViewCreatedEvent } from '@datadog/browser-rum-core' import { LifeCycle, LifeCycleEventType } from '@datadog/browser-rum-core' import type { TimeStamp } from '@datadog/browser-core' -import { ExperimentalFeature, noop } from '@datadog/browser-core' -import { mockExperimentalFeatures } from '@datadog/browser-core/test' +import { addExperimentalFeatures, ExperimentalFeature, noop } from '@datadog/browser-core' import type { BrowserRecord } from '../../types' import { RecordType } from '../../types' import { appendElement } from '../../../../rum-core/test' @@ -118,7 +117,7 @@ describe('startFullSnapshots', () => { describe('when generating BrowserChangeRecord', () => { beforeEach(() => { - mockExperimentalFeatures([ExperimentalFeature.USE_CHANGE_RECORDS]) + addExperimentalFeatures([ExperimentalFeature.USE_CHANGE_RECORDS]) }) describeStartFullSnapshotsWithExpectedSnapshot({ diff --git a/packages/rum/src/domain/segmentCollection/startSegmentTelemetry.spec.ts b/packages/rum/src/domain/segmentCollection/startSegmentTelemetry.spec.ts index aaa54a6c8e..088abb8e1b 100644 --- a/packages/rum/src/domain/segmentCollection/startSegmentTelemetry.spec.ts +++ b/packages/rum/src/domain/segmentCollection/startSegmentTelemetry.spec.ts @@ -1,10 +1,5 @@ import type { Telemetry, HttpRequestEvent, BandwidthStats } from '@datadog/browser-core' -import { - addExperimentalFeatures, - ExperimentalFeature, - Observable, - resetExperimentalFeatures, -} from '@datadog/browser-core' +import { addExperimentalFeatures, ExperimentalFeature, 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' @@ -54,15 +49,16 @@ describe('segmentTelemetry', () => { registerCleanupTask(stopSegmentTelemetry) } - it('should collect segment telemetry for all full snapshots', async () => { - setupSegmentTelemetryCollection() + for (const enableChangeRecords of [true, false] as const) { + const titlePrefix = enableChangeRecords ? 'with change records' : 'without change records' - for (const result of ['failure', 'queue-full', 'success'] as const) { - for (const enableChangeRecords of [true, false] as const) { - if (enableChangeRecords) { - addExperimentalFeatures([ExperimentalFeature.USE_CHANGE_RECORDS]) - } + it(`${titlePrefix}, should collect segment telemetry for all full snapshots`, async () => { + if (enableChangeRecords) { + addExperimentalFeatures([ExperimentalFeature.USE_CHANGE_RECORDS]) + } + setupSegmentTelemetryCollection() + for (const result of ['failure', 'queue-full', 'success'] as const) { generateReplayRequest({ result, isFullSnapshot: true }) expect(await telemetry.getEvents()).toEqual([ @@ -101,10 +97,9 @@ describe('segmentTelemetry', () => { ]) telemetry.reset() - resetExperimentalFeatures() } - } - }) + }) + } it('should collect segment telemetry for failed incremental mutation requests', async () => { setupSegmentTelemetryCollection() From bc0a9ce2a87a62d5b5bb213fab51a5f2fdb2fe76 Mon Sep 17 00:00:00 2001 From: Seth Fowler Date: Fri, 13 Feb 2026 15:41:11 +0000 Subject: [PATCH 2/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20[PANA-5982]=20Make=20t?= =?UTF-8?q?he=20serialization=20code=20more=20configurable=20and=20testabl?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/rum/src/domain/record/assembly.ts | 6 +- packages/rum/src/domain/record/index.ts | 2 +- .../src/domain/record/serialization/index.ts | 4 +- .../record/serialization/serializeDocument.ts | 14 - .../serialization/serializeFullSnapshot.ts | 38 + .../serializeFullSnapshotAsChange.ts | 33 + .../serialization/serializeMutations.spec.ts | 113 +++ .../serialization/serializeMutations.ts | 388 +++++++++ .../src/domain/record/startFullSnapshots.ts | 75 +- .../record/test/serialization.specHelper.ts | 24 +- .../record/trackers/trackMutation.spec.ts | 738 +++++++----------- .../domain/record/trackers/trackMutation.ts | 392 +--------- 12 files changed, 934 insertions(+), 893 deletions(-) delete mode 100644 packages/rum/src/domain/record/serialization/serializeDocument.ts create mode 100644 packages/rum/src/domain/record/serialization/serializeFullSnapshot.ts create mode 100644 packages/rum/src/domain/record/serialization/serializeFullSnapshotAsChange.ts create mode 100644 packages/rum/src/domain/record/serialization/serializeMutations.spec.ts create mode 100644 packages/rum/src/domain/record/serialization/serializeMutations.ts diff --git a/packages/rum/src/domain/record/assembly.ts b/packages/rum/src/domain/record/assembly.ts index 747d467a3e..ec76affda4 100644 --- a/packages/rum/src/domain/record/assembly.ts +++ b/packages/rum/src/domain/record/assembly.ts @@ -1,10 +1,12 @@ +import type { TimeStamp } from '@datadog/browser-core' import { timeStampNow } from '@datadog/browser-core' import type { BrowserIncrementalData, BrowserIncrementalSnapshotRecord } from '../../types' import { RecordType } from '../../types' export function assembleIncrementalSnapshot( source: Data['source'], - data: Omit + data: Omit, + timestamp: TimeStamp = timeStampNow() ): BrowserIncrementalSnapshotRecord { return { data: { @@ -12,6 +14,6 @@ export function assembleIncrementalSnapshot ...data, } as Data, type: RecordType.IncrementalSnapshot, - timestamp: timeStampNow(), + timestamp, } } diff --git a/packages/rum/src/domain/record/index.ts b/packages/rum/src/domain/record/index.ts index 4fd827cea3..aa832d2930 100644 --- a/packages/rum/src/domain/record/index.ts +++ b/packages/rum/src/domain/record/index.ts @@ -2,6 +2,6 @@ export { takeFullSnapshot, takeNodeSnapshot } from './internalApi' export { record } from './record' export type { SerializationMetric, SerializationStats } from './serialization' export { createSerializationStats, aggregateSerializationStats } from './serialization' -export { serializeNode, serializeDocument } from './serialization' +export { serializeNode } from './serialization' export { createElementsScrollPositions } from './elementsScrollPositions' export type { ShadowRootsController } from './shadowRootsController' diff --git a/packages/rum/src/domain/record/serialization/index.ts b/packages/rum/src/domain/record/serialization/index.ts index d3892ab490..789f2ae647 100644 --- a/packages/rum/src/domain/record/serialization/index.ts +++ b/packages/rum/src/domain/record/serialization/index.ts @@ -1,6 +1,8 @@ export { createRootInsertionCursor } from './insertionCursor' export { getElementInputValue } from './serializationUtils' -export { serializeDocument } from './serializeDocument' +export { serializeFullSnapshot } from './serializeFullSnapshot' +export { serializeFullSnapshotAsChange } from './serializeFullSnapshotAsChange' +export { serializeMutations } from './serializeMutations' export { serializeNode } from './serializeNode' export { serializeNodeAsChange } from './serializeNodeAsChange' export { serializeAttribute } from './serializeAttribute' diff --git a/packages/rum/src/domain/record/serialization/serializeDocument.ts b/packages/rum/src/domain/record/serialization/serializeDocument.ts deleted file mode 100644 index 4b8f19a81e..0000000000 --- a/packages/rum/src/domain/record/serialization/serializeDocument.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { DocumentNode, SerializedNodeWithId } from '../../../types' -import { serializeNode } from './serializeNode' -import type { SerializationTransaction } from './serializationTransaction' - -export function serializeDocument( - document: Document, - transaction: SerializationTransaction -): DocumentNode & SerializedNodeWithId { - const defaultPrivacyLevel = transaction.scope.configuration.defaultPrivacyLevel - const serializedNode = serializeNode(document, defaultPrivacyLevel, transaction) - - // We are sure that Documents are never ignored, so this function never returns null - return serializedNode as DocumentNode & SerializedNodeWithId -} diff --git a/packages/rum/src/domain/record/serialization/serializeFullSnapshot.ts b/packages/rum/src/domain/record/serialization/serializeFullSnapshot.ts new file mode 100644 index 0000000000..598da499af --- /dev/null +++ b/packages/rum/src/domain/record/serialization/serializeFullSnapshot.ts @@ -0,0 +1,38 @@ +import { getScrollX, getScrollY } from '@datadog/browser-rum-core' +import type { TimeStamp } from '@datadog/browser-core' +import type { BrowserFullSnapshotRecord } from '../../../types' +import { RecordType } from '../../../types' +import type { EmitRecordCallback, EmitStatsCallback } from '../record.types' +import type { RecordingScope } from '../recordingScope' +import { serializeNode } from './serializeNode' +import { serializeInTransaction } from './serializationTransaction' +import type { SerializationKind, SerializationTransaction } from './serializationTransaction' + +export function serializeFullSnapshot( + timestamp: TimeStamp, + kind: SerializationKind, + document: Document, + emitRecord: EmitRecordCallback, + emitStats: EmitStatsCallback, + scope: RecordingScope +): void { + serializeInTransaction(kind, emitRecord, emitStats, scope, (transaction: SerializationTransaction) => { + const defaultPrivacyLevel = transaction.scope.configuration.defaultPrivacyLevel + + // We are sure that Documents are never ignored, so this function never returns null. + const node = serializeNode(document, defaultPrivacyLevel, transaction)! + + const record: BrowserFullSnapshotRecord = { + data: { + node, + initialOffset: { + left: getScrollX(), + top: getScrollY(), + }, + }, + type: RecordType.FullSnapshot, + timestamp, + } + transaction.add(record) + }) +} diff --git a/packages/rum/src/domain/record/serialization/serializeFullSnapshotAsChange.ts b/packages/rum/src/domain/record/serialization/serializeFullSnapshotAsChange.ts new file mode 100644 index 0000000000..ff04ff6324 --- /dev/null +++ b/packages/rum/src/domain/record/serialization/serializeFullSnapshotAsChange.ts @@ -0,0 +1,33 @@ +import type { TimeStamp } from '@datadog/browser-core' +import type { EmitRecordCallback, EmitStatsCallback } from '../record.types' +import type { RecordingScope } from '../recordingScope' +import { serializeChangesInTransaction } from './serializationTransaction' +import type { ChangeSerializationTransaction, SerializationKind } from './serializationTransaction' +import { serializeNodeAsChange } from './serializeNodeAsChange' +import { createRootInsertionCursor } from './insertionCursor' + +export function serializeFullSnapshotAsChange( + timestamp: TimeStamp, + kind: SerializationKind, + document: Document, + emitRecord: EmitRecordCallback, + emitStats: EmitStatsCallback, + scope: RecordingScope +): void { + scope.resetIds() + serializeChangesInTransaction( + kind, + emitRecord, + emitStats, + scope, + timestamp, + (transaction: ChangeSerializationTransaction) => { + serializeNodeAsChange( + createRootInsertionCursor(scope.nodeIds), + document, + scope.configuration.defaultPrivacyLevel, + transaction + ) + } + ) +} diff --git a/packages/rum/src/domain/record/serialization/serializeMutations.spec.ts b/packages/rum/src/domain/record/serialization/serializeMutations.spec.ts new file mode 100644 index 0000000000..8d44b2d363 --- /dev/null +++ b/packages/rum/src/domain/record/serialization/serializeMutations.spec.ts @@ -0,0 +1,113 @@ +import type { RecordingScope } from '../recordingScope' +import { createRecordingScopeForTesting } from '../test/recordingScope.specHelper' +import { idsAreAssignedForNodeAndAncestors, sortAddedAndMovedNodes } from './serializeMutations' + +describe('idsAreAssignedForNodeAndAncestors', () => { + let scope: RecordingScope + + beforeEach(() => { + scope = createRecordingScopeForTesting() + }) + + it('returns false for DOM Nodes that have not been assigned an id', () => { + expect(idsAreAssignedForNodeAndAncestors(document.createElement('div'), scope.nodeIds)).toBe(false) + }) + + it('returns true for DOM Nodes that have been assigned an id', () => { + const node = document.createElement('div') + scope.nodeIds.getOrInsert(node) + expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(true) + }) + + it('returns false for DOM Nodes when an ancestor has not been assigned an id', () => { + const node = document.createElement('div') + scope.nodeIds.getOrInsert(node) + + const parent = document.createElement('div') + parent.appendChild(node) + scope.nodeIds.getOrInsert(parent) + + const grandparent = document.createElement('div') + grandparent.appendChild(parent) + + expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(false) + }) + + it('returns true for DOM Nodes when all ancestors have been assigned an id', () => { + const node = document.createElement('div') + scope.nodeIds.getOrInsert(node) + + const parent = document.createElement('div') + parent.appendChild(node) + scope.nodeIds.getOrInsert(parent) + + const grandparent = document.createElement('div') + grandparent.appendChild(parent) + scope.nodeIds.getOrInsert(grandparent) + + expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(true) + }) + + it('returns true for DOM Nodes in shadow subtrees', () => { + const node = document.createElement('div') + scope.nodeIds.getOrInsert(node) + + const parent = document.createElement('div') + parent.appendChild(node) + scope.nodeIds.getOrInsert(parent) + + const grandparent = document.createElement('div') + const shadowRoot = grandparent.attachShadow({ mode: 'open' }) + shadowRoot.appendChild(parent) + scope.nodeIds.getOrInsert(grandparent) + + expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(true) + }) +}) + +describe('sortAddedAndMovedNodes', () => { + let parent: Node + let a: Node + let aa: Node + let b: Node + let c: Node + let d: Node + + beforeEach(() => { + // Create a tree like this: + // parent + // / | \ \ + // a b c d + // | + // aa + a = document.createElement('a') + aa = document.createElement('aa') + b = document.createElement('b') + c = document.createElement('c') + d = document.createElement('d') + parent = document.createElement('parent') + parent.appendChild(a) + a.appendChild(aa) + parent.appendChild(b) + parent.appendChild(c) + parent.appendChild(d) + }) + + it('sorts siblings in reverse order', () => { + const nodes = [c, b, d, a] + sortAddedAndMovedNodes(nodes) + expect(nodes).toEqual([d, c, b, a]) + }) + + it('sorts parents', () => { + const nodes = [a, parent, aa] + sortAddedAndMovedNodes(nodes) + expect(nodes).toEqual([parent, a, aa]) + }) + + it('sorts parents first then siblings', () => { + const nodes = [c, aa, b, parent, d, a] + sortAddedAndMovedNodes(nodes) + expect(nodes).toEqual([parent, d, c, b, a, aa]) + }) +}) diff --git a/packages/rum/src/domain/record/serialization/serializeMutations.ts b/packages/rum/src/domain/record/serialization/serializeMutations.ts new file mode 100644 index 0000000000..284db5ba25 --- /dev/null +++ b/packages/rum/src/domain/record/serialization/serializeMutations.ts @@ -0,0 +1,388 @@ +import type { + NodePrivacyLevelCache, + RumMutationRecord, + RumChildListMutationRecord, + RumCharacterDataMutationRecord, + RumAttributesMutationRecord, +} from '@datadog/browser-rum-core' +import { + isNodeShadowHost, + getParentNode, + forEachChildNodes, + getNodePrivacyLevel, + getTextContent, + NodePrivacyLevel, + isNodeShadowRoot, +} from '@datadog/browser-rum-core' +import type { TimeStamp } from '@datadog/browser-core' +import { IncrementalSource } from '../../../types' +import type { + BrowserMutationData, + AddedNodeMutation, + AttributeMutation, + RemovedNodeMutation, + TextMutation, +} from '../../../types' +import type { RecordingScope } from '../recordingScope' +import type { RemoveShadowRootCallBack } from '../shadowRootsController' +import { assembleIncrementalSnapshot } from '../assembly' +import type { EmitRecordCallback, EmitStatsCallback } from '../record.types' +import type { NodeId, NodeIds } from '../itemIds' +import type { SerializationTransaction } from './serializationTransaction' +import { SerializationKind, serializeInTransaction } from './serializationTransaction' +import { serializeNode } from './serializeNode' +import { serializeAttribute } from './serializeAttribute' +import { getElementInputValue } from './serializationUtils' + +export type NodeWithSerializedNode = Node & { __brand: 'NodeWithSerializedNode' } +type WithSerializedTarget = T & { target: NodeWithSerializedNode } + +export function serializeMutations( + timestamp: TimeStamp, + mutations: RumMutationRecord[], + emitRecord: EmitRecordCallback, + emitStats: EmitStatsCallback, + scope: RecordingScope +): void { + serializeInTransaction( + SerializationKind.INCREMENTAL_SNAPSHOT, + emitRecord, + emitStats, + scope, + (transaction: SerializationTransaction) => processMutations(timestamp, mutations, transaction) + ) +} + +function processMutations( + timestamp: TimeStamp, + mutations: RumMutationRecord[], + transaction: SerializationTransaction +): void { + const nodePrivacyLevelCache: NodePrivacyLevelCache = new Map() + + mutations + .filter((mutation): mutation is RumChildListMutationRecord => mutation.type === 'childList') + .forEach((mutation) => { + mutation.removedNodes.forEach((removedNode) => { + traverseRemovedShadowDom(removedNode, transaction.scope.shadowRootsController.removeShadowRoot) + }) + }) + + // Discard any mutation with a 'target' node that: + // * isn't injected in the current document or isn't known/serialized yet: those nodes are likely + // part of a mutation occurring in a parent Node + // * should be hidden or ignored + const filteredMutations = mutations.filter( + (mutation): mutation is WithSerializedTarget => + mutation.target.isConnected && + idsAreAssignedForNodeAndAncestors(mutation.target, transaction.scope.nodeIds) && + getNodePrivacyLevel( + mutation.target, + transaction.scope.configuration.defaultPrivacyLevel, + nodePrivacyLevelCache + ) !== NodePrivacyLevel.HIDDEN + ) + + const { adds, removes, hasBeenSerialized } = processChildListMutations( + filteredMutations.filter( + (mutation): mutation is WithSerializedTarget => mutation.type === 'childList' + ), + nodePrivacyLevelCache, + transaction + ) + + const texts = processCharacterDataMutations( + filteredMutations.filter( + (mutation): mutation is WithSerializedTarget => + mutation.type === 'characterData' && !hasBeenSerialized(mutation.target) + ), + nodePrivacyLevelCache, + transaction + ) + + const attributes = processAttributesMutations( + filteredMutations.filter( + (mutation): mutation is WithSerializedTarget => + mutation.type === 'attributes' && !hasBeenSerialized(mutation.target) + ), + nodePrivacyLevelCache, + transaction + ) + + if (!texts.length && !attributes.length && !removes.length && !adds.length) { + return + } + + const record = assembleIncrementalSnapshot( + IncrementalSource.Mutation, + { + adds, + removes, + texts, + attributes, + }, + timestamp + ) + transaction.add(record) +} + +function processChildListMutations( + mutations: Array>, + nodePrivacyLevelCache: NodePrivacyLevelCache, + transaction: SerializationTransaction +) { + // First, we iterate over mutations to collect: + // + // * nodes that have been added in the document and not removed by a subsequent mutation + // * nodes that have been removed from the document but were not added in a previous mutation + // + // For this second category, we also collect their previous parent (mutation.target) because we'll + // need it to emit a 'remove' mutation. + // + // Those two categories may overlap: if a node moved from a position to another, it is reported as + // two mutation records, one with a "removedNodes" and the other with "addedNodes". In this case, + // the node will be in both sets. + const addedAndMovedNodes = new Set() + const removedNodes = new Map() + for (const mutation of mutations) { + mutation.addedNodes.forEach((node) => { + addedAndMovedNodes.add(node) + }) + mutation.removedNodes.forEach((node) => { + if (!addedAndMovedNodes.has(node)) { + removedNodes.set(node, mutation.target) + } + addedAndMovedNodes.delete(node) + }) + } + + // Then, we sort nodes that are still in the document by topological order, for two reasons: + // + // * We will serialize each added nodes with their descendants. We don't want to serialize a node + // twice, so we need to iterate over the parent nodes first and skip any node that is contained in + // a precedent node. + // + // * To emit "add" mutations, we need references to the parent and potential next sibling of each + // added node. So we need to iterate over the parent nodes first, and when multiple nodes are + // siblings, we want to iterate from last to first. This will ensure that any "next" node is + // already serialized and have an id. + const sortedAddedAndMovedNodes = Array.from(addedAndMovedNodes) + sortAddedAndMovedNodes(sortedAddedAndMovedNodes) + + // Then, we iterate over our sorted node sets to emit mutations. We collect the newly serialized + // node ids in a set to be able to skip subsequent related mutations. + transaction.serializedNodeIds = new Set() + + const addedNodeMutations: AddedNodeMutation[] = [] + for (const node of sortedAddedAndMovedNodes) { + if (hasBeenSerialized(node)) { + continue + } + + const parentNodePrivacyLevel = getNodePrivacyLevel( + node.parentNode!, + transaction.scope.configuration.defaultPrivacyLevel, + nodePrivacyLevelCache + ) + if (parentNodePrivacyLevel === NodePrivacyLevel.HIDDEN || parentNodePrivacyLevel === NodePrivacyLevel.IGNORE) { + continue + } + + const serializedNode = serializeNode(node, parentNodePrivacyLevel, transaction) + if (!serializedNode) { + continue + } + + const parentNode = getParentNode(node)! + addedNodeMutations.push({ + nextId: getNextSibling(node), + parentId: transaction.scope.nodeIds.get(parentNode)!, + node: serializedNode, + }) + } + // Finally, we emit remove mutations. + const removedNodeMutations: RemovedNodeMutation[] = [] + removedNodes.forEach((parent, node) => { + const parentId = transaction.scope.nodeIds.get(parent) + const id = transaction.scope.nodeIds.get(node) + if (parentId !== undefined && id !== undefined) { + removedNodeMutations.push({ parentId, id }) + } + }) + + return { adds: addedNodeMutations, removes: removedNodeMutations, hasBeenSerialized } + + function hasBeenSerialized(node: Node) { + const id = transaction.scope.nodeIds.get(node) + return id !== undefined && transaction.serializedNodeIds?.has(id) + } + + function getNextSibling(node: Node): null | number { + let nextSibling = node.nextSibling + while (nextSibling) { + const id = transaction.scope.nodeIds.get(nextSibling) + if (id !== undefined) { + return id + } + nextSibling = nextSibling.nextSibling + } + + return null + } +} + +function processCharacterDataMutations( + mutations: Array>, + nodePrivacyLevelCache: NodePrivacyLevelCache, + transaction: SerializationTransaction +) { + const textMutations: TextMutation[] = [] + + // Deduplicate mutations based on their target node + const handledNodes = new Set() + const filteredMutations = mutations.filter((mutation) => { + if (handledNodes.has(mutation.target)) { + return false + } + handledNodes.add(mutation.target) + return true + }) + + // Emit mutations + for (const mutation of filteredMutations) { + const value = mutation.target.textContent + if (value === mutation.oldValue) { + continue + } + + const id = transaction.scope.nodeIds.get(mutation.target) + if (id === undefined) { + continue + } + + const parentNodePrivacyLevel = getNodePrivacyLevel( + getParentNode(mutation.target)!, + transaction.scope.configuration.defaultPrivacyLevel, + nodePrivacyLevelCache + ) + if (parentNodePrivacyLevel === NodePrivacyLevel.HIDDEN || parentNodePrivacyLevel === NodePrivacyLevel.IGNORE) { + continue + } + + textMutations.push({ + id, + value: getTextContent(mutation.target, parentNodePrivacyLevel) ?? null, + }) + } + + return textMutations +} + +function processAttributesMutations( + mutations: Array>, + nodePrivacyLevelCache: NodePrivacyLevelCache, + transaction: SerializationTransaction +) { + const attributeMutations: AttributeMutation[] = [] + + // Deduplicate mutations based on their target node and changed attribute + const handledElements = new Map>() + const filteredMutations = mutations.filter((mutation) => { + const handledAttributes = handledElements.get(mutation.target) + if (handledAttributes && handledAttributes.has(mutation.attributeName!)) { + return false + } + if (!handledAttributes) { + handledElements.set(mutation.target, new Set([mutation.attributeName!])) + } else { + handledAttributes.add(mutation.attributeName!) + } + return true + }) + + // Emit mutations + const emittedMutations = new Map() + for (const mutation of filteredMutations) { + const uncensoredValue = mutation.target.getAttribute(mutation.attributeName!) + if (uncensoredValue === mutation.oldValue) { + continue + } + + const id = transaction.scope.nodeIds.get(mutation.target) + if (id === undefined) { + continue + } + + const privacyLevel = getNodePrivacyLevel( + mutation.target, + transaction.scope.configuration.defaultPrivacyLevel, + nodePrivacyLevelCache + ) + const attributeValue = serializeAttribute( + mutation.target, + privacyLevel, + mutation.attributeName!, + transaction.scope.configuration + ) + + let transformedValue: string | null + if (mutation.attributeName === 'value') { + const inputValue = getElementInputValue(mutation.target, privacyLevel) + if (inputValue === undefined) { + continue + } + transformedValue = inputValue + } else if (typeof attributeValue === 'string') { + transformedValue = attributeValue + } else { + transformedValue = null + } + + let emittedMutation = emittedMutations.get(mutation.target) + if (!emittedMutation) { + emittedMutation = { id, attributes: {} } + attributeMutations.push(emittedMutation) + emittedMutations.set(mutation.target, emittedMutation) + } + + emittedMutation.attributes[mutation.attributeName!] = transformedValue + } + + return attributeMutations +} + +export function sortAddedAndMovedNodes(nodes: Node[]) { + nodes.sort((a, b) => { + const position = a.compareDocumentPosition(b) + /* eslint-disable no-bitwise */ + if (position & Node.DOCUMENT_POSITION_CONTAINED_BY) { + return -1 + } else if (position & Node.DOCUMENT_POSITION_CONTAINS) { + return 1 + } else if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + return 1 + } else if (position & Node.DOCUMENT_POSITION_PRECEDING) { + return -1 + } + /* eslint-enable no-bitwise */ + return 0 + }) +} + +function traverseRemovedShadowDom(removedNode: Node, shadowDomRemovedCallback: RemoveShadowRootCallBack) { + if (isNodeShadowHost(removedNode)) { + shadowDomRemovedCallback(removedNode.shadowRoot) + } + forEachChildNodes(removedNode, (childNode) => traverseRemovedShadowDom(childNode, shadowDomRemovedCallback)) +} + +export function idsAreAssignedForNodeAndAncestors(node: Node, nodeIds: NodeIds): node is NodeWithSerializedNode { + let current: Node | null = node + while (current) { + if (nodeIds.get(current) === undefined && !isNodeShadowRoot(current)) { + return false + } + current = getParentNode(current) + } + return true +} diff --git a/packages/rum/src/domain/record/startFullSnapshots.ts b/packages/rum/src/domain/record/startFullSnapshots.ts index e10d7de96f..6d50e61243 100644 --- a/packages/rum/src/domain/record/startFullSnapshots.ts +++ b/packages/rum/src/domain/record/startFullSnapshots.ts @@ -1,30 +1,31 @@ -import { LifeCycleEventType, getScrollX, getScrollY, getViewportDimension } from '@datadog/browser-rum-core' +import { LifeCycleEventType, getViewportDimension } from '@datadog/browser-rum-core' import type { LifeCycle } from '@datadog/browser-rum-core' import { ExperimentalFeature, isExperimentalFeatureEnabled, timeStampNow } from '@datadog/browser-core' import type { TimeStamp } from '@datadog/browser-core' -import type { BrowserFullSnapshotRecord } from '../../types' import { RecordType } from '../../types' -import type { ChangeSerializationTransaction, SerializationTransaction } from './serialization' -import { - createRootInsertionCursor, - serializeChangesInTransaction, - serializeDocument, - serializeInTransaction, - serializeNodeAsChange, - SerializationKind, -} from './serialization' +import { SerializationKind, serializeFullSnapshotAsChange, serializeFullSnapshot } 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 + scope: RecordingScope, + serialize: SerializeFullSnapshotCallback = defaultSerializeFullSnapshotCallback() ) { - takeFullSnapshot(timeStampNow(), SerializationKind.INITIAL_FULL_SNAPSHOT, emitRecord, emitStats, scope) + takeFullSnapshot(timeStampNow(), SerializationKind.INITIAL_FULL_SNAPSHOT, emitRecord, emitStats, scope, serialize) const { unsubscribe } = lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, (view) => { flushMutations() @@ -33,7 +34,8 @@ export function startFullSnapshots( SerializationKind.SUBSEQUENT_FULL_SNAPSHOT, emitRecord, emitStats, - scope + scope, + serialize ) }) @@ -47,7 +49,8 @@ export function takeFullSnapshot( kind: SerializationKind, emitRecord: EmitRecordCallback, emitStats: EmitStatsCallback, - scope: RecordingScope + scope: RecordingScope, + serialize: SerializeFullSnapshotCallback = defaultSerializeFullSnapshotCallback() ): void { const { width, height } = getViewportDimension() emitRecord({ @@ -68,28 +71,7 @@ export function takeFullSnapshot( timestamp, }) - if (isExperimentalFeatureEnabled(ExperimentalFeature.USE_CHANGE_RECORDS)) { - scope.resetIds() - serializeChangesInTransaction( - kind, - emitRecord, - emitStats, - scope, - timestamp, - (transaction: ChangeSerializationTransaction) => { - serializeNodeAsChange( - createRootInsertionCursor(scope.nodeIds), - document, - scope.configuration.defaultPrivacyLevel, - transaction - ) - } - ) - } else { - serializeInTransaction(kind, emitRecord, emitStats, scope, (transaction: SerializationTransaction) => { - transaction.add(serializeFullSnapshotRecord(timestamp, transaction)) - }) - } + serialize(timestamp, kind, document, emitRecord, emitStats, scope) if (window.visualViewport) { emitRecord({ @@ -100,19 +82,8 @@ export function takeFullSnapshot( } } -function serializeFullSnapshotRecord( - timestamp: TimeStamp, - transaction: SerializationTransaction -): BrowserFullSnapshotRecord { - return { - data: { - node: serializeDocument(document, transaction), - initialOffset: { - left: getScrollX(), - top: getScrollY(), - }, - }, - type: RecordType.FullSnapshot, - timestamp, - } +function defaultSerializeFullSnapshotCallback(): SerializeFullSnapshotCallback { + return isExperimentalFeatureEnabled(ExperimentalFeature.USE_CHANGE_RECORDS) + ? serializeFullSnapshotAsChange + : serializeFullSnapshot } diff --git a/packages/rum/src/domain/record/test/serialization.specHelper.ts b/packages/rum/src/domain/record/test/serialization.specHelper.ts index 34bfb7a6b6..110c36c20b 100644 --- a/packages/rum/src/domain/record/test/serialization.specHelper.ts +++ b/packages/rum/src/domain/record/test/serialization.specHelper.ts @@ -1,3 +1,4 @@ +import type { TimeStamp } from '@datadog/browser-core' import { noop, timeStampNow } from '@datadog/browser-core' import { RecordType } from '../../../types' import type { @@ -18,8 +19,7 @@ import type { import { serializeNode, SerializationKind, - serializeDocument, - serializeInTransaction, + serializeFullSnapshot, updateSerializationStats, serializeChangesInTransaction, createRootInsertionCursor, @@ -56,19 +56,15 @@ export function createSerializationTransactionForTesting({ } export function takeFullSnapshotForTesting(scope: RecordingScope): DocumentNode & SerializedNodeWithId { - let node: (DocumentNode & SerializedNodeWithId) | null + let node: DocumentNode & SerializedNodeWithId + const emitRecord = (record: BrowserRecord) => { + // Tests want to assert against the serialized node representation of the document, + // not the record that would contain it if we emitted it, so we just extract it here. + const fullSnapshotRecord = record as BrowserFullSnapshotRecord + node = fullSnapshotRecord.data.node as DocumentNode & SerializedNodeWithId + } - serializeInTransaction( - SerializationKind.INITIAL_FULL_SNAPSHOT, - noop, - noop, - scope, - (transaction: SerializationTransaction): void => { - // Tests want to assert against the serialized node representation of the document, - // not the record that would contain it if we emitted it, so don't bother emitting. - node = serializeDocument(document, transaction) - } - ) + serializeFullSnapshot(0 as TimeStamp, SerializationKind.INITIAL_FULL_SNAPSHOT, document, emitRecord, noop, scope) return node! } diff --git a/packages/rum/src/domain/record/trackers/trackMutation.spec.ts b/packages/rum/src/domain/record/trackers/trackMutation.spec.ts index c49d9c8c5b..9d7f4db699 100644 --- a/packages/rum/src/domain/record/trackers/trackMutation.spec.ts +++ b/packages/rum/src/domain/record/trackers/trackMutation.spec.ts @@ -12,6 +12,8 @@ import type { Attributes, BrowserIncrementalSnapshotRecord, BrowserMutationPayload, + DocumentNode, + SerializedNodeWithId, } from '../../../types' import { NodeType } from '../../../types' import type { RecordingScope } from '../recordingScope' @@ -20,7 +22,8 @@ import type { AddShadowRootCallBack, RemoveShadowRootCallBack } from '../shadowR import { appendElement, appendText } from '../../../../../rum-core/test' import type { EmitRecordCallback, EmitStatsCallback } from '../record.types' import { takeFullSnapshotForTesting } from '../test/serialization.specHelper' -import { idsAreAssignedForNodeAndAncestors, sortAddedAndMovedNodes, trackMutation } from './trackMutation' +import { serializeMutations } from '../serialization' +import { trackMutation } from './trackMutation' import type { MutationTracker } from './trackMutation' describe('trackMutation', () => { @@ -48,28 +51,49 @@ describe('trackMutation', () => { }) } - function startMutationCollection(scope: RecordingScope): MutationTracker { - const mutationTracker = trackMutation(document, emitRecordCallback, emitStatsCallback, scope) - registerCleanupTask(() => { - mutationTracker.stop() - }) - return mutationTracker - } - function getLatestMutationPayload(): BrowserMutationPayload { const latestRecord = emitRecordCallback.calls.mostRecent()?.args[0] as BrowserIncrementalSnapshotRecord return latestRecord.data as BrowserMutationPayload } - describe('childList mutation records', () => { - it('emits a mutation when a node is appended to a known node', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) + function recordMutation( + mutation: () => void, + options: { + mutationBeforeTrackingStarts?: () => void + scope?: RecordingScope + skipFlush?: boolean + } = {} + ): { + mutationTracker: MutationTracker + serializedDocument: DocumentNode & SerializedNodeWithId + } { + const scope = options.scope || getRecordingScope() + + const serializedDocument = takeFullSnapshotForTesting(scope) + + if (options.mutationBeforeTrackingStarts) { + options.mutationBeforeTrackingStarts() + } + + const mutationTracker = trackMutation(document, emitRecordCallback, emitStatsCallback, scope, serializeMutations) + registerCleanupTask(() => { + mutationTracker.stop() + }) + + mutation() - appendElement('
', sandbox) + if (!options.skipFlush) { mutationTracker.flush() + } + + return { mutationTracker, serializedDocument } + } + describe('childList mutation records', () => { + it('emits a mutation when a node is appended to a known node', () => { + const { serializedDocument } = recordMutation(() => { + appendElement('
', sandbox) + }) expect(emitRecordCallback).toHaveBeenCalledTimes(1) const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidator(serializedDocument) @@ -83,14 +107,78 @@ describe('trackMutation', () => { }) }) - it('emits serialization stats with mutations', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) + it('emits add node mutations in the expected order', () => { + const a = appendElement('', sandbox) + const aa = appendElement('', a) + const b = appendElement('', sandbox) + const bb = appendElement('', b) + const c = appendElement('', sandbox) + const cc = appendElement('', c) + + const { serializedDocument } = recordMutation(() => { + const ab = document.createElement('ab') + const ac = document.createElement('ac') + const ba = document.createElement('ba') + const bc = document.createElement('bc') + const ca = document.createElement('ca') + const cb = document.createElement('cb') + + cc.before(cb) + aa.after(ac) + bb.before(ba) + aa.after(ab) + cb.before(ca) + bb.after(bc) + }) + expect(emitRecordCallback).toHaveBeenCalledTimes(1) + + const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidator(serializedDocument) + const cb = expectNewNode({ type: NodeType.Element, tagName: 'cb' }) + const ca = expectNewNode({ type: NodeType.Element, tagName: 'ca' }) + const bc = expectNewNode({ type: NodeType.Element, tagName: 'bc' }) + const ba = expectNewNode({ type: NodeType.Element, tagName: 'ba' }) + const ac = expectNewNode({ type: NodeType.Element, tagName: 'ac' }) + const ab = expectNewNode({ type: NodeType.Element, tagName: 'ab' }) + validate(getLatestMutationPayload(), { + adds: [ + { + parent: expectInitialNode({ tag: 'c' }), + node: cb, + next: expectInitialNode({ tag: 'cc' }), + }, + { + parent: expectInitialNode({ tag: 'c' }), + node: ca, + next: cb, + }, + { + parent: expectInitialNode({ tag: 'b' }), + node: bc, + }, + { + parent: expectInitialNode({ tag: 'b' }), + node: ba, + next: expectInitialNode({ tag: 'bb' }), + }, + { + parent: expectInitialNode({ tag: 'a' }), + node: ac, + }, + { + parent: expectInitialNode({ tag: 'a' }), + node: ab, + next: ac, + }, + ], + }) + }) + it('emits serialization stats with mutations', () => { const cssText = 'body { width: 100%; }' - appendElement(``, sandbox) - mutationTracker.flush() + + const { serializedDocument } = recordMutation(() => { + appendElement(``, sandbox) + }) expect(emitRecordCallback).toHaveBeenCalledTimes(1) @@ -115,11 +203,12 @@ describe('trackMutation', () => { }) it('processes mutations asynchronously', async () => { - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - startMutationCollection(scope) - - appendElement('
', sandbox) + recordMutation( + () => { + appendElement('
', sandbox) + }, + { skipFlush: true } + ) expect(emitRecordCallback).not.toHaveBeenCalled() @@ -127,28 +216,40 @@ describe('trackMutation', () => { }) it('does not emit a mutation when a node is appended to a unknown node', () => { - const scope = getRecordingScope() - - // Here, we don't call takeFullSnapshotForTesting(), so the sandbox is 'unknown'. - const mutationTracker = startMutationCollection(scope) + const unknownNode = document.createElement('div') + registerCleanupTask(() => { + unknownNode.remove() + }) - appendElement('
', sandbox) - mutationTracker.flush() + recordMutation( + () => { + appendElement('
', unknownNode) + }, + { + mutationBeforeTrackingStarts() { + // Append the node after the full snapshot, but before tracking starts, + // rendering it 'unknown'. + sandbox.appendChild(unknownNode) + }, + } + ) expect(emitRecordCallback).not.toHaveBeenCalled() }) it('emits buffered mutation records on flush', () => { - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - appendElement('
', sandbox) + const { mutationTracker } = recordMutation( + () => { + appendElement('
', sandbox) + }, + { + skipFlush: true, + } + ) expect(emitRecordCallback).toHaveBeenCalledTimes(0) mutationTracker.flush() - expect(emitRecordCallback).toHaveBeenCalledTimes(1) }) @@ -156,13 +257,10 @@ describe('trackMutation', () => { it('attribute mutations', () => { const element = appendElement('
', sandbox) - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - element.setAttribute('foo', 'bar') - sandbox.remove() - mutationTracker.flush() + recordMutation(() => { + element.setAttribute('foo', 'bar') + sandbox.remove() + }) expect(getLatestMutationPayload().attributes).toEqual([]) }) @@ -170,25 +268,19 @@ describe('trackMutation', () => { it('text mutations', () => { const textNode = appendText('text', sandbox) - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - textNode.data = 'bar' - sandbox.remove() - mutationTracker.flush() + recordMutation(() => { + textNode.data = 'bar' + sandbox.remove() + }) expect(getLatestMutationPayload().texts).toEqual([]) }) it('add mutations', () => { - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - appendElement('

', sandbox) - sandbox.remove() - mutationTracker.flush() + recordMutation(() => { + appendElement('

', sandbox) + sandbox.remove() + }) expect(getLatestMutationPayload().adds).toEqual([]) }) @@ -196,13 +288,10 @@ describe('trackMutation', () => { it('remove mutations', () => { const element = appendElement('
', sandbox) - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - element.remove() - sandbox.remove() - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + element.remove() + sandbox.remove() + }) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -225,15 +314,11 @@ describe('trackMutation', () => { it('attribute mutations', () => { const element = appendElement('
', sandbox) - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - element.remove() - sandbox.appendChild(element) - - element.setAttribute('foo', 'bar') - mutationTracker.flush() + recordMutation(() => { + element.remove() + sandbox.appendChild(element) + element.setAttribute('foo', 'bar') + }) expect(getLatestMutationPayload().attributes).toEqual([]) }) @@ -241,15 +326,11 @@ describe('trackMutation', () => { it('text mutations', () => { const textNode = appendText('foo', sandbox) - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - textNode.remove() - sandbox.appendChild(textNode) - - textNode.data = 'bar' - mutationTracker.flush() + recordMutation(() => { + textNode.remove() + sandbox.appendChild(textNode) + textNode.data = 'bar' + }) expect(getLatestMutationPayload().texts).toEqual([]) }) @@ -258,17 +339,14 @@ describe('trackMutation', () => { const child = appendElement('', sandbox) const parent = child.parentElement! - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - // Generate a mutation on 'child' - child.remove() - parent.appendChild(child) - // Generate a mutation on 'parent' - parent.remove() - sandbox.appendChild(parent) - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + // Generate a mutation on 'child' + child.remove() + parent.appendChild(child) + // Generate a mutation on 'parent' + parent.remove() + sandbox.appendChild(parent) + }) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) @@ -295,14 +373,10 @@ describe('trackMutation', () => { }) it('remove mutations', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - const child = appendElement('', sandbox) - - child.remove() - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + const child = appendElement('', sandbox) + child.remove() + }) const { validate, expectInitialNode, expectNewNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -317,16 +391,11 @@ describe('trackMutation', () => { }) it('emits only an "add" mutation when adding, removing then re-adding a child', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - const element = appendElement('', sandbox) - - element.remove() - sandbox.appendChild(element) - - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + const element = appendElement('', sandbox) + element.remove() + sandbox.appendChild(element) + }) const { validate, expectInitialNode, expectNewNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -342,14 +411,10 @@ describe('trackMutation', () => { it('emits an "add" and a "remove" mutation when moving a node', () => { const a = appendElement('', sandbox) - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - // Moves 'a' after 'b' - sandbox.appendChild(a) - - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + // Moves 'a' after 'b' + sandbox.appendChild(a) + }) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -378,14 +443,10 @@ describe('trackMutation', () => { const a = span.nextElementSibling! const b = a.nextElementSibling! - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - a.appendChild(span) - b.appendChild(span) - - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + a.appendChild(span) + b.appendChild(span) + }) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -405,13 +466,9 @@ describe('trackMutation', () => { }) it('keep nodes order when adding multiple sibling nodes', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - appendElement('', sandbox) - - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + appendElement('', sandbox) + }) const { validate, expectInitialNode, expectNewNode } = createMutationPayloadValidator(serializedDocument) const c = expectNewNode({ type: NodeType.Element, tagName: 'c' }) @@ -438,12 +495,12 @@ describe('trackMutation', () => { }) it('respects the default privacy level setting', () => { - const scope = getRecordingScope(DefaultPrivacyLevel.MASK) - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.innerText = 'foo bar' - mutationTracker.flush() + const { serializedDocument } = recordMutation( + () => { + sandbox.innerText = 'foo bar' + }, + { scope: getRecordingScope(DefaultPrivacyLevel.MASK) } + ) const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -461,14 +518,12 @@ describe('trackMutation', () => { describe('for shadow DOM', () => { it('should call addShadowRoot when host is added', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - const host = appendElement('
', sandbox) - const shadowRoot = host.attachShadow({ mode: 'open' }) - appendElement('', shadowRoot) - mutationTracker.flush() + let shadowRoot: ShadowRoot + const { serializedDocument } = recordMutation(() => { + const host = appendElement('
', sandbox) + shadowRoot = host.attachShadow({ mode: 'open' }) + appendElement('', shadowRoot) + }) expect(emitRecordCallback).toHaveBeenCalledTimes(1) const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidator(serializedDocument) @@ -484,7 +539,7 @@ describe('trackMutation', () => { }, ], }) - expect(addShadowRootSpy).toHaveBeenCalledOnceWith(shadowRoot, jasmine.anything()) + expect(addShadowRootSpy).toHaveBeenCalledOnceWith(shadowRoot!, jasmine.anything()) expect(removeShadowRootSpy).not.toHaveBeenCalled() }) @@ -493,12 +548,10 @@ describe('trackMutation', () => { const shadowRoot = host.attachShadow({ mode: 'open' }) appendElement('', shadowRoot) - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) + const { serializedDocument } = recordMutation(() => { + host.remove() + }) - host.remove() - mutationTracker.flush() expect(emitRecordCallback).toHaveBeenCalledTimes(1) expect(addShadowRootSpy).toHaveBeenCalledTimes(1) @@ -520,12 +573,10 @@ describe('trackMutation', () => { const shadowRoot = host.attachShadow({ mode: 'open' }) appendElement('', shadowRoot) - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) + const { serializedDocument } = recordMutation(() => { + host.parentElement!.remove() + }) - host.parentElement!.remove() - mutationTracker.flush() expect(emitRecordCallback).toHaveBeenCalledTimes(1) expect(addShadowRootSpy).toHaveBeenCalledTimes(1) @@ -548,12 +599,10 @@ describe('trackMutation', () => { const childHost = appendElement('', parentHost.querySelector('p')!) const childShadowRoot = childHost.attachShadow({ mode: 'open' }) - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) + const { serializedDocument } = recordMutation(() => { + parentHost.remove() + }) - parentHost.remove() - mutationTracker.flush() expect(emitRecordCallback).toHaveBeenCalledTimes(1) expect(addShadowRootSpy).toHaveBeenCalledTimes(2) @@ -585,12 +634,9 @@ describe('trackMutation', () => { }) it('emits a mutation when a text node is changed', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - textNode.data = 'bar' - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + textNode.data = 'bar' + }) expect(emitRecordCallback).toHaveBeenCalledTimes(1) @@ -608,37 +654,31 @@ describe('trackMutation', () => { it('emits a mutation when an empty text node is changed', () => { textNode.data = '' - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - textNode.data = 'bar' - mutationTracker.flush() + recordMutation(() => { + textNode.data = 'bar' + }) expect(emitRecordCallback).toHaveBeenCalledTimes(1) }) it('does not emit a mutation when a text node keeps the same value', () => { - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - textNode.data = 'bar' - textNode.data = 'foo' - mutationTracker.flush() + recordMutation(() => { + textNode.data = 'bar' + textNode.data = 'foo' + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) it('respects the default privacy level setting', () => { const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - - scope.configuration.defaultPrivacyLevel = DefaultPrivacyLevel.MASK - const mutationTracker = startMutationCollection(scope) - - textNode.data = 'foo bar' - mutationTracker.flush() + const { serializedDocument } = recordMutation( + () => { + scope.configuration.defaultPrivacyLevel = DefaultPrivacyLevel.MASK + textNode.data = 'foo bar' + }, + { scope } + ) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -655,12 +695,12 @@ describe('trackMutation', () => { sandbox.setAttribute('data-dd-privacy', 'allow') const div = appendElement('
foo 81
', sandbox) - const scope = getRecordingScope(DefaultPrivacyLevel.MASK) - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - div.firstChild!.textContent = 'bazz 7' - mutationTracker.flush() + const { serializedDocument } = recordMutation( + () => { + div.firstChild!.textContent = 'bazz 7' + }, + { scope: getRecordingScope(DefaultPrivacyLevel.MASK) } + ) expect(emitRecordCallback).toHaveBeenCalledTimes(1) @@ -678,12 +718,9 @@ describe('trackMutation', () => { describe('attributes mutations', () => { it('emits a mutation when an attribute is changed', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.setAttribute('foo', 'bar') - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + sandbox.setAttribute('foo', 'bar') + }) expect(emitRecordCallback).toHaveBeenCalledTimes(1) @@ -699,12 +736,9 @@ describe('trackMutation', () => { }) it('emits a mutation with an empty string when an attribute is changed to an empty string', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.setAttribute('foo', '') - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + sandbox.setAttribute('foo', '') + }) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -720,12 +754,9 @@ describe('trackMutation', () => { it('emits a mutation with `null` when an attribute is removed', () => { sandbox.setAttribute('foo', 'bar') - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.removeAttribute('foo') - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + sandbox.removeAttribute('foo') + }) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -741,25 +772,19 @@ describe('trackMutation', () => { it('does not emit a mutation when an attribute keeps the same value', () => { sandbox.setAttribute('foo', 'bar') - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.setAttribute('foo', 'biz') - sandbox.setAttribute('foo', 'bar') - mutationTracker.flush() + recordMutation(() => { + sandbox.setAttribute('foo', 'biz') + sandbox.setAttribute('foo', 'bar') + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) it('reuse the same mutation when multiple attributes are changed', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.setAttribute('foo1', 'biz') - sandbox.setAttribute('foo2', 'bar') - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + sandbox.setAttribute('foo1', 'biz') + sandbox.setAttribute('foo2', 'bar') + }) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -773,12 +798,12 @@ describe('trackMutation', () => { }) it('respects the default privacy level setting', () => { - const scope = getRecordingScope(DefaultPrivacyLevel.MASK) - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.setAttribute('data-foo', 'biz') - mutationTracker.flush() + const { serializedDocument } = recordMutation( + () => { + sandbox.setAttribute('data-foo', 'biz') + }, + { scope: getRecordingScope(DefaultPrivacyLevel.MASK) } + ) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -800,13 +825,9 @@ describe('trackMutation', () => { }) it('skips ignored nodes when looking for the next id', () => { - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.insertBefore(document.createElement('a'), ignoredElement) - - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + sandbox.insertBefore(document.createElement('a'), ignoredElement) + }) const { validate, expectInitialNode, expectNewNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -823,54 +844,36 @@ describe('trackMutation', () => { it('when adding an ignored node', () => { ignoredElement.remove() - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.appendChild(ignoredElement) - - mutationTracker.flush() + recordMutation(() => { + sandbox.appendChild(ignoredElement) + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) it('when changing the attributes of an ignored node', () => { - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - ignoredElement.setAttribute('foo', 'bar') - - mutationTracker.flush() + recordMutation(() => { + ignoredElement.setAttribute('foo', 'bar') + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) it('when adding a new child node', () => { - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - appendElement("'function foo() {}'", ignoredElement) - - mutationTracker.flush() + recordMutation(() => { + appendElement("'function foo() {}'", ignoredElement) + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) it('when mutating a known child node', () => { const textNode = appendText('function foo() {}', sandbox) - - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - ignoredElement.appendChild(textNode) - const mutationTracker = startMutationCollection(scope) - - textNode.data = 'function bar() {}' - - mutationTracker.flush() + recordMutation(() => { + textNode.data = 'function bar() {}' + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) @@ -878,13 +881,9 @@ describe('trackMutation', () => { it('when adding a known child node', () => { const textNode = appendText('function foo() {}', sandbox) - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - ignoredElement.appendChild(textNode) - - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + ignoredElement.appendChild(textNode) + }) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -900,12 +899,9 @@ describe('trackMutation', () => { it('when moving an ignored node', () => { const script = appendElement('', sandbox) - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.appendChild(script) - mutationTracker.flush() + recordMutation(() => { + sandbox.appendChild(script) + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) @@ -919,43 +915,29 @@ describe('trackMutation', () => { }) it('does not emit attribute mutations on hidden nodes', () => { - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - hiddenElement.setAttribute('foo', 'bar') - - mutationTracker.flush() + recordMutation(() => { + hiddenElement.setAttribute('foo', 'bar') + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) describe('does not emit mutations occurring in hidden node', () => { it('when adding a new node', () => { - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - appendElement('function foo() {}', hiddenElement) - - mutationTracker.flush() + recordMutation(() => { + appendElement('function foo() {}', hiddenElement) + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) it('when mutating a known child node', () => { const textNode = appendText('function foo() {}', sandbox) - - const scope = getRecordingScope() - takeFullSnapshotForTesting(scope) - hiddenElement.appendChild(textNode) - const mutationTracker = startMutationCollection(scope) - - textNode.data = 'function bar() {}' - - mutationTracker.flush() + recordMutation(() => { + textNode.data = 'function bar() {}' + }) expect(emitRecordCallback).not.toHaveBeenCalled() }) @@ -963,13 +945,9 @@ describe('trackMutation', () => { it('when moving a known node into an hidden node', () => { const textNode = appendText('function foo() {}', sandbox) - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - hiddenElement.appendChild(textNode) - - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + hiddenElement.appendChild(textNode) + }) const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -1054,12 +1032,9 @@ describe('trackMutation', () => { sandbox.setAttribute(PRIVACY_ATTR_NAME, privacyAttributeValue) } - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - sandbox.appendChild(input) - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + sandbox.appendChild(input) + }) const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidator(serializedDocument) validate(getLatestMutationPayload(), { @@ -1086,12 +1061,9 @@ describe('trackMutation', () => { } sandbox.appendChild(input) - const scope = getRecordingScope() - const serializedDocument = takeFullSnapshotForTesting(scope) - const mutationTracker = startMutationCollection(scope) - - input.setAttribute('value', 'bar') - mutationTracker.flush() + const { serializedDocument } = recordMutation(() => { + input.setAttribute('value', 'bar') + }) if (expectedAttributesMutation) { const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument) @@ -1106,113 +1078,3 @@ describe('trackMutation', () => { } }) }) - -describe('sortAddedAndMovedNodes', () => { - let parent: Node - let a: Node - let aa: Node - let b: Node - let c: Node - let d: Node - - beforeEach(() => { - // Create a tree like this: - // parent - // / | \ \ - // a b c d - // | - // aa - a = document.createElement('a') - aa = document.createElement('aa') - b = document.createElement('b') - c = document.createElement('c') - d = document.createElement('d') - parent = document.createElement('parent') - parent.appendChild(a) - a.appendChild(aa) - parent.appendChild(b) - parent.appendChild(c) - parent.appendChild(d) - }) - - it('sorts siblings in reverse order', () => { - const nodes = [c, b, d, a] - sortAddedAndMovedNodes(nodes) - expect(nodes).toEqual([d, c, b, a]) - }) - - it('sorts parents', () => { - const nodes = [a, parent, aa] - sortAddedAndMovedNodes(nodes) - expect(nodes).toEqual([parent, a, aa]) - }) - - it('sorts parents first then siblings', () => { - const nodes = [c, aa, b, parent, d, a] - sortAddedAndMovedNodes(nodes) - expect(nodes).toEqual([parent, d, c, b, a, aa]) - }) -}) - -describe('idsAreAssignedForNodeAndAncestors', () => { - let scope: RecordingScope - - beforeEach(() => { - scope = createRecordingScopeForTesting() - }) - - it('returns false for DOM Nodes that have not been assigned an id', () => { - expect(idsAreAssignedForNodeAndAncestors(document.createElement('div'), scope.nodeIds)).toBe(false) - }) - - it('returns true for DOM Nodes that have been assigned an id', () => { - const node = document.createElement('div') - scope.nodeIds.getOrInsert(node) - expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(true) - }) - - it('returns false for DOM Nodes when an ancestor has not been assigned an id', () => { - const node = document.createElement('div') - scope.nodeIds.getOrInsert(node) - - const parent = document.createElement('div') - parent.appendChild(node) - scope.nodeIds.getOrInsert(parent) - - const grandparent = document.createElement('div') - grandparent.appendChild(parent) - - expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(false) - }) - - it('returns true for DOM Nodes when all ancestors have been assigned an id', () => { - const node = document.createElement('div') - scope.nodeIds.getOrInsert(node) - - const parent = document.createElement('div') - parent.appendChild(node) - scope.nodeIds.getOrInsert(parent) - - const grandparent = document.createElement('div') - grandparent.appendChild(parent) - scope.nodeIds.getOrInsert(grandparent) - - expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(true) - }) - - it('returns true for DOM Nodes in shadow subtrees', () => { - const node = document.createElement('div') - scope.nodeIds.getOrInsert(node) - - const parent = document.createElement('div') - parent.appendChild(node) - scope.nodeIds.getOrInsert(parent) - - const grandparent = document.createElement('div') - const shadowRoot = grandparent.attachShadow({ mode: 'open' }) - shadowRoot.appendChild(parent) - scope.nodeIds.getOrInsert(grandparent) - - expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(true) - }) -}) diff --git a/packages/rum/src/domain/record/trackers/trackMutation.ts b/packages/rum/src/domain/record/trackers/trackMutation.ts index e8194d6d28..29f9e48432 100644 --- a/packages/rum/src/domain/record/trackers/trackMutation.ts +++ b/packages/rum/src/domain/record/trackers/trackMutation.ts @@ -1,50 +1,23 @@ -import { monitor, noop } from '@datadog/browser-core' -import type { - NodePrivacyLevelCache, - RumMutationRecord, - RumChildListMutationRecord, - RumCharacterDataMutationRecord, - RumAttributesMutationRecord, -} from '@datadog/browser-rum-core' -import { - isNodeShadowHost, - getMutationObserverConstructor, - getParentNode, - forEachChildNodes, - getNodePrivacyLevel, - getTextContent, - NodePrivacyLevel, - isNodeShadowRoot, -} from '@datadog/browser-rum-core' -import { IncrementalSource } from '../../../types' -import type { - BrowserMutationData, - AddedNodeMutation, - AttributeMutation, - RemovedNodeMutation, - TextMutation, -} from '../../../types' +import type { TimeStamp } from '@datadog/browser-core' +import { monitor, noop, timeStampNow } from '@datadog/browser-core' +import type { RumMutationRecord } from '@datadog/browser-rum-core' +import { getMutationObserverConstructor } from '@datadog/browser-rum-core' import type { RecordingScope } from '../recordingScope' -import type { SerializationTransaction } from '../serialization' -import { - getElementInputValue, - serializeAttribute, - serializeInTransaction, - serializeNode, - SerializationKind, -} from '../serialization' import { createMutationBatch } from '../mutationBatch' -import type { RemoveShadowRootCallBack } from '../shadowRootsController' -import { assembleIncrementalSnapshot } from '../assembly' import type { EmitRecordCallback, EmitStatsCallback } from '../record.types' -import type { NodeId, NodeIds } from '../itemIds' +import { serializeMutations } from '../serialization' import type { Tracker } from './tracker.types' -export type NodeWithSerializedNode = Node & { __brand: 'NodeWithSerializedNode' } -type WithSerializedTarget = T & { target: NodeWithSerializedNode } - export type MutationTracker = Tracker & { flush: () => void } +export type SerializeMutationsCallback = ( + timestamp: TimeStamp, + mutations: RumMutationRecord[], + emitRecord: EmitRecordCallback, + emitStats: EmitStatsCallback, + scope: RecordingScope +) => void + /** * Buffers and aggregate mutations generated by a MutationObserver into MutationPayload */ @@ -52,7 +25,8 @@ export function trackMutation( target: Node, emitRecord: EmitRecordCallback, emitStats: EmitStatsCallback, - scope: RecordingScope + scope: RecordingScope, + serialize: SerializeMutationsCallback = defaultSerializeMutationsCallback() ): MutationTracker { const MutationObserver = getMutationObserverConstructor() if (!MutationObserver) { @@ -60,13 +34,12 @@ export function trackMutation( } const mutationBatch = createMutationBatch((mutations) => { - serializeInTransaction( - SerializationKind.INCREMENTAL_SNAPSHOT, + serialize( + timeStampNow(), + mutations.concat(observer.takeRecords() as RumMutationRecord[]), emitRecord, emitStats, - scope, - (transaction: SerializationTransaction) => - processMutations(mutations.concat(observer.takeRecords() as RumMutationRecord[]), transaction) + scope ) }) @@ -92,329 +65,6 @@ export function trackMutation( } } -function processMutations(mutations: RumMutationRecord[], transaction: SerializationTransaction): void { - const nodePrivacyLevelCache: NodePrivacyLevelCache = new Map() - - mutations - .filter((mutation): mutation is RumChildListMutationRecord => mutation.type === 'childList') - .forEach((mutation) => { - mutation.removedNodes.forEach((removedNode) => { - traverseRemovedShadowDom(removedNode, transaction.scope.shadowRootsController.removeShadowRoot) - }) - }) - - // Discard any mutation with a 'target' node that: - // * isn't injected in the current document or isn't known/serialized yet: those nodes are likely - // part of a mutation occurring in a parent Node - // * should be hidden or ignored - const filteredMutations = mutations.filter( - (mutation): mutation is WithSerializedTarget => - mutation.target.isConnected && - idsAreAssignedForNodeAndAncestors(mutation.target, transaction.scope.nodeIds) && - getNodePrivacyLevel( - mutation.target, - transaction.scope.configuration.defaultPrivacyLevel, - nodePrivacyLevelCache - ) !== NodePrivacyLevel.HIDDEN - ) - - const { adds, removes, hasBeenSerialized } = processChildListMutations( - filteredMutations.filter( - (mutation): mutation is WithSerializedTarget => mutation.type === 'childList' - ), - nodePrivacyLevelCache, - transaction - ) - - const texts = processCharacterDataMutations( - filteredMutations.filter( - (mutation): mutation is WithSerializedTarget => - mutation.type === 'characterData' && !hasBeenSerialized(mutation.target) - ), - nodePrivacyLevelCache, - transaction - ) - - const attributes = processAttributesMutations( - filteredMutations.filter( - (mutation): mutation is WithSerializedTarget => - mutation.type === 'attributes' && !hasBeenSerialized(mutation.target) - ), - nodePrivacyLevelCache, - transaction - ) - - if (!texts.length && !attributes.length && !removes.length && !adds.length) { - return - } - - transaction.add( - assembleIncrementalSnapshot(IncrementalSource.Mutation, { - adds, - removes, - texts, - attributes, - }) - ) -} - -function processChildListMutations( - mutations: Array>, - nodePrivacyLevelCache: NodePrivacyLevelCache, - transaction: SerializationTransaction -) { - // First, we iterate over mutations to collect: - // - // * nodes that have been added in the document and not removed by a subsequent mutation - // * nodes that have been removed from the document but were not added in a previous mutation - // - // For this second category, we also collect their previous parent (mutation.target) because we'll - // need it to emit a 'remove' mutation. - // - // Those two categories may overlap: if a node moved from a position to another, it is reported as - // two mutation records, one with a "removedNodes" and the other with "addedNodes". In this case, - // the node will be in both sets. - const addedAndMovedNodes = new Set() - const removedNodes = new Map() - for (const mutation of mutations) { - mutation.addedNodes.forEach((node) => { - addedAndMovedNodes.add(node) - }) - mutation.removedNodes.forEach((node) => { - if (!addedAndMovedNodes.has(node)) { - removedNodes.set(node, mutation.target) - } - addedAndMovedNodes.delete(node) - }) - } - - // Then, we sort nodes that are still in the document by topological order, for two reasons: - // - // * We will serialize each added nodes with their descendants. We don't want to serialize a node - // twice, so we need to iterate over the parent nodes first and skip any node that is contained in - // a precedent node. - // - // * To emit "add" mutations, we need references to the parent and potential next sibling of each - // added node. So we need to iterate over the parent nodes first, and when multiple nodes are - // siblings, we want to iterate from last to first. This will ensure that any "next" node is - // already serialized and have an id. - const sortedAddedAndMovedNodes = Array.from(addedAndMovedNodes) - sortAddedAndMovedNodes(sortedAddedAndMovedNodes) - - // Then, we iterate over our sorted node sets to emit mutations. We collect the newly serialized - // node ids in a set to be able to skip subsequent related mutations. - transaction.serializedNodeIds = new Set() - - const addedNodeMutations: AddedNodeMutation[] = [] - for (const node of sortedAddedAndMovedNodes) { - if (hasBeenSerialized(node)) { - continue - } - - const parentNodePrivacyLevel = getNodePrivacyLevel( - node.parentNode!, - transaction.scope.configuration.defaultPrivacyLevel, - nodePrivacyLevelCache - ) - if (parentNodePrivacyLevel === NodePrivacyLevel.HIDDEN || parentNodePrivacyLevel === NodePrivacyLevel.IGNORE) { - continue - } - - const serializedNode = serializeNode(node, parentNodePrivacyLevel, transaction) - if (!serializedNode) { - continue - } - - const parentNode = getParentNode(node)! - addedNodeMutations.push({ - nextId: getNextSibling(node), - parentId: transaction.scope.nodeIds.get(parentNode)!, - node: serializedNode, - }) - } - // Finally, we emit remove mutations. - const removedNodeMutations: RemovedNodeMutation[] = [] - removedNodes.forEach((parent, node) => { - const parentId = transaction.scope.nodeIds.get(parent) - const id = transaction.scope.nodeIds.get(node) - if (parentId !== undefined && id !== undefined) { - removedNodeMutations.push({ parentId, id }) - } - }) - - return { adds: addedNodeMutations, removes: removedNodeMutations, hasBeenSerialized } - - function hasBeenSerialized(node: Node) { - const id = transaction.scope.nodeIds.get(node) - return id !== undefined && transaction.serializedNodeIds?.has(id) - } - - function getNextSibling(node: Node): null | number { - let nextSibling = node.nextSibling - while (nextSibling) { - const id = transaction.scope.nodeIds.get(nextSibling) - if (id !== undefined) { - return id - } - nextSibling = nextSibling.nextSibling - } - - return null - } -} - -function processCharacterDataMutations( - mutations: Array>, - nodePrivacyLevelCache: NodePrivacyLevelCache, - transaction: SerializationTransaction -) { - const textMutations: TextMutation[] = [] - - // Deduplicate mutations based on their target node - const handledNodes = new Set() - const filteredMutations = mutations.filter((mutation) => { - if (handledNodes.has(mutation.target)) { - return false - } - handledNodes.add(mutation.target) - return true - }) - - // Emit mutations - for (const mutation of filteredMutations) { - const value = mutation.target.textContent - if (value === mutation.oldValue) { - continue - } - - const id = transaction.scope.nodeIds.get(mutation.target) - if (id === undefined) { - continue - } - - const parentNodePrivacyLevel = getNodePrivacyLevel( - getParentNode(mutation.target)!, - transaction.scope.configuration.defaultPrivacyLevel, - nodePrivacyLevelCache - ) - if (parentNodePrivacyLevel === NodePrivacyLevel.HIDDEN || parentNodePrivacyLevel === NodePrivacyLevel.IGNORE) { - continue - } - - textMutations.push({ - id, - value: getTextContent(mutation.target, parentNodePrivacyLevel) ?? null, - }) - } - - return textMutations -} - -function processAttributesMutations( - mutations: Array>, - nodePrivacyLevelCache: NodePrivacyLevelCache, - transaction: SerializationTransaction -) { - const attributeMutations: AttributeMutation[] = [] - - // Deduplicate mutations based on their target node and changed attribute - const handledElements = new Map>() - const filteredMutations = mutations.filter((mutation) => { - const handledAttributes = handledElements.get(mutation.target) - if (handledAttributes && handledAttributes.has(mutation.attributeName!)) { - return false - } - if (!handledAttributes) { - handledElements.set(mutation.target, new Set([mutation.attributeName!])) - } else { - handledAttributes.add(mutation.attributeName!) - } - return true - }) - - // Emit mutations - const emittedMutations = new Map() - for (const mutation of filteredMutations) { - const uncensoredValue = mutation.target.getAttribute(mutation.attributeName!) - if (uncensoredValue === mutation.oldValue) { - continue - } - - const id = transaction.scope.nodeIds.get(mutation.target) - if (id === undefined) { - continue - } - - const privacyLevel = getNodePrivacyLevel( - mutation.target, - transaction.scope.configuration.defaultPrivacyLevel, - nodePrivacyLevelCache - ) - const attributeValue = serializeAttribute( - mutation.target, - privacyLevel, - mutation.attributeName!, - transaction.scope.configuration - ) - - let transformedValue: string | null - if (mutation.attributeName === 'value') { - const inputValue = getElementInputValue(mutation.target, privacyLevel) - if (inputValue === undefined) { - continue - } - transformedValue = inputValue - } else if (typeof attributeValue === 'string') { - transformedValue = attributeValue - } else { - transformedValue = null - } - - let emittedMutation = emittedMutations.get(mutation.target) - if (!emittedMutation) { - emittedMutation = { id, attributes: {} } - attributeMutations.push(emittedMutation) - emittedMutations.set(mutation.target, emittedMutation) - } - - emittedMutation.attributes[mutation.attributeName!] = transformedValue - } - - return attributeMutations -} - -export function sortAddedAndMovedNodes(nodes: Node[]) { - nodes.sort((a, b) => { - const position = a.compareDocumentPosition(b) - /* eslint-disable no-bitwise */ - if (position & Node.DOCUMENT_POSITION_CONTAINED_BY) { - return -1 - } else if (position & Node.DOCUMENT_POSITION_CONTAINS) { - return 1 - } else if (position & Node.DOCUMENT_POSITION_FOLLOWING) { - return 1 - } else if (position & Node.DOCUMENT_POSITION_PRECEDING) { - return -1 - } - /* eslint-enable no-bitwise */ - return 0 - }) -} - -function traverseRemovedShadowDom(removedNode: Node, shadowDomRemovedCallback: RemoveShadowRootCallBack) { - if (isNodeShadowHost(removedNode)) { - shadowDomRemovedCallback(removedNode.shadowRoot) - } - forEachChildNodes(removedNode, (childNode) => traverseRemovedShadowDom(childNode, shadowDomRemovedCallback)) -} - -export function idsAreAssignedForNodeAndAncestors(node: Node, nodeIds: NodeIds): node is NodeWithSerializedNode { - let current: Node | null = node - while (current) { - if (nodeIds.get(current) === undefined && !isNodeShadowRoot(current)) { - return false - } - current = getParentNode(current) - } - return true +function defaultSerializeMutationsCallback(): SerializeMutationsCallback { + return serializeMutations } From 245d68f70a84f302cf8f156e2f3ca7c3396e6a27 Mon Sep 17 00:00:00 2001 From: Seth Fowler Date: Thu, 19 Feb 2026 11:46:19 +0000 Subject: [PATCH 3/5] Add missing import --- packages/rum/src/domain/record/test/serialization.specHelper.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/rum/src/domain/record/test/serialization.specHelper.ts b/packages/rum/src/domain/record/test/serialization.specHelper.ts index 110c36c20b..a1bd7f0f64 100644 --- a/packages/rum/src/domain/record/test/serialization.specHelper.ts +++ b/packages/rum/src/domain/record/test/serialization.specHelper.ts @@ -3,6 +3,7 @@ import { noop, timeStampNow } from '@datadog/browser-core' import { RecordType } from '../../../types' import type { BrowserChangeRecord, + BrowserFullSnapshotRecord, BrowserRecord, DocumentNode, ElementNode, From ec82cd641bfb86934c0b569e5ea34c2a69fbfc68 Mon Sep 17 00:00:00 2001 From: "gh-worker-dd-devflow-36fce6[bot]" <244854925+gh-worker-dd-devflow-36fce6[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:53:01 +0000 Subject: [PATCH 4/5] [skip ci] #4185 conflicts with staging-08 From 5a04e583cf7210b8663c7ab1346704e337d8507c Mon Sep 17 00:00:00 2001 From: Seth Fowler Date: Thu, 19 Feb 2026 12:00:57 +0000 Subject: [PATCH 5/5] Remove obsolete import --- .../view/viewMetrics/trackInteractionToNextPaint.spec.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.spec.ts b/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.spec.ts index 448de7e33e..b90fc4e94f 100644 --- a/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.spec.ts +++ b/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.spec.ts @@ -1,11 +1,5 @@ import type { Duration, RelativeTime } from '@datadog/browser-core' -import { - elapsed, - relativeNow, - resetExperimentalFeatures, - ExperimentalFeature, - addExperimentalFeatures, -} from '@datadog/browser-core' +import { elapsed, relativeNow, ExperimentalFeature, addExperimentalFeatures } from '@datadog/browser-core' import { registerCleanupTask } from '@datadog/browser-core/test' import { appendElement,