From 6e60bbab289c2896930844334d3490e8c8eccf91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt?= Date: Tue, 17 Sep 2024 18:47:50 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20[RUM-5785]=20fix=20missing=20nav?= =?UTF-8?q?igation=20timings=20on=20Safari=20(#2964)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✅♻️ move `mockDocumentReadyState` in a common module And add the possibility to mock onload * ✅ clone emitted view events Sometimes when checking for View events emitted in the past, the properties don't reflect the state at the time because they have been mutated in place. * ✅♻️ factorize the way we mock `window.performance.getEntries*` * ♻️ make runOnReadyState stoppable * ♻️ extract the code to polyfill "navigation" performance entry We'll use this in `trackNavigationTimings` next. * 🐛 [RUM-5785] fix track navigation timings on Safari In most browsers, when observing `PerformanceNavigationTiming` via a `PerformanceObserver` before the page is fully loaded, the same "navigation" entry is notified twice: * once with the "buffered" entry, even if it is incomplete * once when all the timings are defined, after the `load` event is complete. On Safari, the entry is only notified once. The SDK is only aware of the incomplete entry (which is discarded), so all timings related to the navigation entry are missing. To work around this issue and keep things simple, this commit removes the `PerformanceObserver` usage. Instead, we wait for the `load` event to be complete, and then just pick up the buffered navigation entry. This is similar to the strategy we use to generate the "document" resource, which works reliably. * ✅ adjust related integration tests * 👌 review feedbacks * ♻️ NavigationTiming inherits from ResourceTiming The native PerformanceNavigationTiming is inheriting from PerformanceResourceTiming, so let's do the same for our interfaces, so we don't need to copy/paste/document the same properties. * ✅ fix unit test in IE11 --- packages/core/src/browser/runOnReadyState.ts | 9 +- packages/rum-core/src/boot/startRum.spec.ts | 5 +- .../src/browser/performanceObservable.spec.ts | 17 ++- .../src/browser/performanceObservable.ts | 8 +- .../src/browser/performanceUtils.spec.ts | 45 ++++++++ .../rum-core/src/browser/performanceUtils.ts | 45 +++++++- .../matchRequestResourceEntry.spec.ts | 45 +++++--- .../retrieveInitialDocumentResourceTiming.ts | 37 +------ .../domain/view/setupViewTest.specHelper.ts | 35 +++--- .../src/domain/view/trackViews.spec.ts | 39 ++----- .../trackInitialViewMetrics.spec.ts | 25 ++--- .../trackNavigationTimings.spec.ts | 104 +++++++++++------- .../viewMetrics/trackNavigationTimings.ts | 62 +++++------ .../test/emulate/mockDocumentReadyState.ts | 17 +++ .../emulate/mockGlobalPerformanceBuffer.ts | 19 ++++ .../test/emulate/mockPerformanceObserver.ts | 21 ---- packages/rum-core/test/index.ts | 2 + packages/rum/src/boot/recorderApi.spec.ts | 20 ++-- 18 files changed, 325 insertions(+), 230 deletions(-) create mode 100644 packages/rum-core/src/browser/performanceUtils.spec.ts create mode 100644 packages/rum-core/test/emulate/mockDocumentReadyState.ts create mode 100644 packages/rum-core/test/emulate/mockGlobalPerformanceBuffer.ts diff --git a/packages/core/src/browser/runOnReadyState.ts b/packages/core/src/browser/runOnReadyState.ts index 143d64b3ff..02fcbb6203 100644 --- a/packages/core/src/browser/runOnReadyState.ts +++ b/packages/core/src/browser/runOnReadyState.ts @@ -1,15 +1,16 @@ import type { Configuration } from '../domain/configuration' +import { noop } from '../tools/utils/functionUtils' import { DOM_EVENT, addEventListener } from './addEventListener' export function runOnReadyState( configuration: Configuration, expectedReadyState: 'complete' | 'interactive', callback: () => void -) { +): { stop: () => void } { if (document.readyState === expectedReadyState || document.readyState === 'complete') { callback() - } else { - const eventName = expectedReadyState === 'complete' ? DOM_EVENT.LOAD : DOM_EVENT.DOM_CONTENT_LOADED - addEventListener(configuration, window, eventName, callback, { once: true }) + return { stop: noop } } + const eventName = expectedReadyState === 'complete' ? DOM_EVENT.LOAD : DOM_EVENT.DOM_CONTENT_LOADED + return addEventListener(configuration, window, eventName, callback, { once: true }) } diff --git a/packages/rum-core/src/boot/startRum.spec.ts b/packages/rum-core/src/boot/startRum.spec.ts index f7909304d9..64774bd58e 100644 --- a/packages/rum-core/src/boot/startRum.spec.ts +++ b/packages/rum-core/src/boot/startRum.spec.ts @@ -26,6 +26,7 @@ import type { RumSessionManagerMock } from '../../test' import { createPerformanceEntry, createRumSessionManagerMock, + mockDocumentReadyState, mockPageStateHistory, mockPerformanceObserver, mockRumConfiguration, @@ -295,7 +296,7 @@ describe('rum events url', () => { it('should keep the same URL when updating an ended view', () => { clock = mockClock() - const { notifyPerformanceEntries } = mockPerformanceObserver() + const { triggerOnLoad } = mockDocumentReadyState() setupViewUrlTest() clock.tick(VIEW_DURATION) @@ -304,7 +305,7 @@ describe('rum events url', () => { serverRumEvents.length = 0 - notifyPerformanceEntries([createPerformanceEntry(RumPerformanceEntryType.NAVIGATION)]) + triggerOnLoad() clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) expect(serverRumEvents.length).toEqual(1) diff --git a/packages/rum-core/src/browser/performanceObservable.spec.ts b/packages/rum-core/src/browser/performanceObservable.spec.ts index 802ebda0d7..17af008f4a 100644 --- a/packages/rum-core/src/browser/performanceObservable.spec.ts +++ b/packages/rum-core/src/browser/performanceObservable.spec.ts @@ -1,7 +1,13 @@ import type { Duration, Subscription } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' import { mockClock } from '@datadog/browser-core/test' -import { createPerformanceEntry, mockPerformanceObserver, mockRumConfiguration } from '../../test' +import type { GlobalPerformanceBufferMock } from '../../test' +import { + createPerformanceEntry, + mockGlobalPerformanceBuffer, + mockPerformanceObserver, + mockRumConfiguration, +} from '../../test' import { RumPerformanceEntryType, createPerformanceObservable } from './performanceObservable' describe('performanceObservable', () => { @@ -76,11 +82,10 @@ describe('performanceObservable', () => { }) describe('fallback strategy when type not supported', () => { - let bufferedEntries: PerformanceEntryList + let globalPerformanceBufferMock: GlobalPerformanceBufferMock beforeEach(() => { - bufferedEntries = [] - spyOn(performance, 'getEntriesByType').and.callFake(() => bufferedEntries) + globalPerformanceBufferMock = mockGlobalPerformanceBuffer() }) it('should notify performance resources when type not supported', () => { @@ -97,7 +102,9 @@ describe('performanceObservable', () => { it('should notify buffered performance resources when type not supported', () => { mockPerformanceObserver({ typeSupported: false }) // add the performance entry to the buffer - bufferedEntries = [createPerformanceEntry(RumPerformanceEntryType.RESOURCE, { name: allowedUrl })] + globalPerformanceBufferMock.addPerformanceEntry( + createPerformanceEntry(RumPerformanceEntryType.RESOURCE, { name: allowedUrl }) + ) const performanceResourceObservable = createPerformanceObservable(configuration, { type: RumPerformanceEntryType.RESOURCE, diff --git a/packages/rum-core/src/browser/performanceObservable.ts b/packages/rum-core/src/browser/performanceObservable.ts index b0f105f315..4662cf0c4c 100644 --- a/packages/rum-core/src/browser/performanceObservable.ts +++ b/packages/rum-core/src/browser/performanceObservable.ts @@ -69,13 +69,17 @@ export interface RumPerformancePaintTiming { startTime: RelativeTime } -export interface RumPerformanceNavigationTiming { +export interface RumPerformanceNavigationTiming extends Omit { entryType: RumPerformanceEntryType.NAVIGATION + initiatorType: 'navigation' + name: string + domComplete: RelativeTime domContentLoadedEventEnd: RelativeTime domInteractive: RelativeTime loadEventEnd: RelativeTime - responseStart: RelativeTime + + toJSON(): Omit } export interface RumLargestContentfulPaintTiming { diff --git a/packages/rum-core/src/browser/performanceUtils.spec.ts b/packages/rum-core/src/browser/performanceUtils.spec.ts new file mode 100644 index 0000000000..90996e0a97 --- /dev/null +++ b/packages/rum-core/src/browser/performanceUtils.spec.ts @@ -0,0 +1,45 @@ +import { isIE, type RelativeTime } from '@datadog/browser-core' +import type { RumPerformanceNavigationTiming } from './performanceObservable' +import { RumPerformanceEntryType } from './performanceObservable' +import { getNavigationEntry } from './performanceUtils' + +describe('getNavigationEntry', () => { + it('returns the navigation entry', () => { + // Declare the expected value here, so TypeScript can make sure all expected fields are covered, + // even though the actual value contains more fields. + const expectation: jasmine.Expected = { + entryType: RumPerformanceEntryType.NAVIGATION, + initiatorType: 'navigation', + name: jasmine.any(String), + + domComplete: jasmine.any(Number), + domContentLoadedEventEnd: jasmine.any(Number), + domInteractive: jasmine.any(Number), + loadEventEnd: jasmine.any(Number), + + startTime: 0 as RelativeTime, + duration: jasmine.any(Number), + + fetchStart: jasmine.any(Number), + domainLookupStart: jasmine.any(Number), + domainLookupEnd: jasmine.any(Number), + connectStart: jasmine.any(Number), + ...(isIE() + ? ({} as unknown as { secureConnectionStart: RelativeTime }) + : { secureConnectionStart: jasmine.any(Number) }), + connectEnd: jasmine.any(Number), + requestStart: jasmine.any(Number), + responseStart: jasmine.any(Number), + responseEnd: jasmine.any(Number), + redirectStart: jasmine.any(Number), + redirectEnd: jasmine.any(Number), + decodedBodySize: jasmine.any(Number), + encodedBodySize: jasmine.any(Number), + transferSize: jasmine.any(Number), + + toJSON: jasmine.any(Function), + } + + expect(getNavigationEntry()).toEqual(jasmine.objectContaining(expectation)) + }) +}) diff --git a/packages/rum-core/src/browser/performanceUtils.ts b/packages/rum-core/src/browser/performanceUtils.ts index 5ed146a349..3d025851f1 100644 --- a/packages/rum-core/src/browser/performanceUtils.ts +++ b/packages/rum-core/src/browser/performanceUtils.ts @@ -1,20 +1,53 @@ import type { RelativeTime, TimeStamp } from '@datadog/browser-core' -import { getRelativeTime, isNumber } from '@datadog/browser-core' +import { assign, getRelativeTime, isNumber } from '@datadog/browser-core' +import { + RumPerformanceEntryType, + supportPerformanceTimingEvent, + type RumPerformanceNavigationTiming, +} from './performanceObservable' -export type RelativePerformanceTiming = { +export function getNavigationEntry(): RumPerformanceNavigationTiming { + if (supportPerformanceTimingEvent(RumPerformanceEntryType.NAVIGATION)) { + const navigationEntry = performance.getEntriesByType( + RumPerformanceEntryType.NAVIGATION + )[0] as unknown as RumPerformanceNavigationTiming + if (navigationEntry) { + return navigationEntry + } + } + + const timings = computeTimingsFromDeprecatedPerformanceTiming() + const entry = assign( + { + entryType: RumPerformanceEntryType.NAVIGATION as const, + initiatorType: 'navigation' as const, + name: window.location.href, + startTime: 0 as RelativeTime, + duration: timings.responseEnd, + decodedBodySize: 0, + encodedBodySize: 0, + transferSize: 0, + toJSON: () => assign({}, entry, { toJSON: undefined }), + }, + timings + ) + return entry +} + +export type TimingsFromDeprecatedPerformanceTiming = { -readonly [key in keyof Omit]: RelativeTime } -export function computeRelativePerformanceTiming() { - const result: Partial = {} +export function computeTimingsFromDeprecatedPerformanceTiming() { + const result: Partial = {} const timing = performance.timing for (const key in timing) { if (isNumber(timing[key as keyof PerformanceTiming])) { - const numberKey = key as keyof RelativePerformanceTiming + const numberKey = key as keyof TimingsFromDeprecatedPerformanceTiming const timingElement = timing[numberKey] as TimeStamp result[numberKey] = timingElement === 0 ? (0 as RelativeTime) : getRelativeTime(timingElement) } } - return result as RelativePerformanceTiming + return result as TimingsFromDeprecatedPerformanceTiming } diff --git a/packages/rum-core/src/domain/resource/matchRequestResourceEntry.spec.ts b/packages/rum-core/src/domain/resource/matchRequestResourceEntry.spec.ts index 8ef57f4624..3eaad6aa27 100644 --- a/packages/rum-core/src/domain/resource/matchRequestResourceEntry.spec.ts +++ b/packages/rum-core/src/domain/resource/matchRequestResourceEntry.spec.ts @@ -1,6 +1,7 @@ import type { Duration, RelativeTime } from '@datadog/browser-core' import { isIE, relativeToClocks } from '@datadog/browser-core' -import { createPerformanceEntry } from '../../../test' +import type { GlobalPerformanceBufferMock } from '../../../test' +import { createPerformanceEntry, mockGlobalPerformanceBuffer } from '../../../test' import type { RumPerformanceResourceTiming } from '../../browser/performanceObservable' import { RumPerformanceEntryType } from '../../browser/performanceObservable' import type { RequestCompleteEvent } from '../requestCollection' @@ -8,26 +9,28 @@ import type { RequestCompleteEvent } from '../requestCollection' import { matchRequestResourceEntry } from './matchRequestResourceEntry' describe('matchRequestResourceEntry', () => { + const FAKE_URL = 'https://example.com' const FAKE_REQUEST: Partial = { + url: FAKE_URL, startClocks: relativeToClocks(100 as RelativeTime), duration: 500 as Duration, } - let entries: RumPerformanceResourceTiming[] + let globalPerformanceObjectMock: GlobalPerformanceBufferMock beforeEach(() => { if (isIE()) { pending('no full rum support') } - entries = [] - spyOn(performance, 'getEntriesByName').and.returnValue(entries) + globalPerformanceObjectMock = mockGlobalPerformanceBuffer() }) it('should match single entry nested in the request ', () => { const entry = createPerformanceEntry(RumPerformanceEntryType.RESOURCE, { + name: FAKE_URL, startTime: 200 as RelativeTime, duration: 300 as Duration, }) - entries.push(entry) + globalPerformanceObjectMock.addPerformanceEntry(entry) const matchingEntry = matchRequestResourceEntry(FAKE_REQUEST as RequestCompleteEvent) @@ -36,10 +39,11 @@ describe('matchRequestResourceEntry', () => { it('should match single entry nested in the request with error margin', () => { const entry = createPerformanceEntry(RumPerformanceEntryType.RESOURCE, { + name: FAKE_URL, startTime: 99 as RelativeTime, duration: 502 as Duration, }) - entries.push(entry) + globalPerformanceObjectMock.addPerformanceEntry(entry) const matchingEntry = matchRequestResourceEntry(FAKE_REQUEST as RequestCompleteEvent) @@ -48,10 +52,11 @@ describe('matchRequestResourceEntry', () => { it('should not match single entry outside the request ', () => { const entry = createPerformanceEntry(RumPerformanceEntryType.RESOURCE, { + name: FAKE_URL, startTime: 0 as RelativeTime, duration: 300 as Duration, }) - entries.push(entry) + globalPerformanceObjectMock.addPerformanceEntry(entry) const matchingEntry = matchRequestResourceEntry(FAKE_REQUEST as RequestCompleteEvent) @@ -60,20 +65,22 @@ describe('matchRequestResourceEntry', () => { it('should discard already matched entries when multiple identical requests are done conurently', () => { const entry1 = createPerformanceEntry(RumPerformanceEntryType.RESOURCE, { + name: FAKE_URL, startTime: 200 as RelativeTime, duration: 300 as Duration, }) - entries.push(entry1) + globalPerformanceObjectMock.addPerformanceEntry(entry1) const matchingEntry1 = matchRequestResourceEntry(FAKE_REQUEST as RequestCompleteEvent) expect(matchingEntry1).toEqual(entry1.toJSON() as RumPerformanceResourceTiming) const entry2 = createPerformanceEntry(RumPerformanceEntryType.RESOURCE, { + name: FAKE_URL, startTime: 99 as RelativeTime, duration: 502 as Duration, }) - entries.push(entry2) + globalPerformanceObjectMock.addPerformanceEntry(entry2) const matchingEntry2 = matchRequestResourceEntry(FAKE_REQUEST as RequestCompleteEvent) @@ -82,14 +89,17 @@ describe('matchRequestResourceEntry', () => { it('should not match two not following entries nested in the request ', () => { const entry1 = createPerformanceEntry(RumPerformanceEntryType.RESOURCE, { + name: FAKE_URL, startTime: 150 as RelativeTime, duration: 100 as Duration, }) const entry2 = createPerformanceEntry(RumPerformanceEntryType.RESOURCE, { + name: FAKE_URL, startTime: 200 as RelativeTime, duration: 100 as Duration, }) - entries.push(entry1, entry2) + globalPerformanceObjectMock.addPerformanceEntry(entry1) + globalPerformanceObjectMock.addPerformanceEntry(entry2) const matchingEntry = matchRequestResourceEntry(FAKE_REQUEST as RequestCompleteEvent) @@ -98,18 +108,23 @@ describe('matchRequestResourceEntry', () => { it('should not match multiple entries nested in the request', () => { const entry1 = createPerformanceEntry(RumPerformanceEntryType.RESOURCE, { + name: FAKE_URL, startTime: 100 as RelativeTime, duration: 50 as Duration, }) const entry2 = createPerformanceEntry(RumPerformanceEntryType.RESOURCE, { + name: FAKE_URL, startTime: 150 as RelativeTime, duration: 50 as Duration, }) const entry3 = createPerformanceEntry(RumPerformanceEntryType.RESOURCE, { + name: FAKE_URL, startTime: 200 as RelativeTime, duration: 50 as Duration, }) - entries.push(entry1, entry2, entry3) + globalPerformanceObjectMock.addPerformanceEntry(entry1) + globalPerformanceObjectMock.addPerformanceEntry(entry2) + globalPerformanceObjectMock.addPerformanceEntry(entry3) const matchingEntry = matchRequestResourceEntry(FAKE_REQUEST as RequestCompleteEvent) @@ -118,10 +133,10 @@ describe('matchRequestResourceEntry', () => { it('should not match entry with invalid duration', () => { const entry = createPerformanceEntry(RumPerformanceEntryType.RESOURCE, { + name: FAKE_URL, duration: -1 as Duration, }) - - entries.push(entry) + globalPerformanceObjectMock.addPerformanceEntry(entry) const matchingEntry = matchRequestResourceEntry(FAKE_REQUEST as RequestCompleteEvent) @@ -130,12 +145,12 @@ describe('matchRequestResourceEntry', () => { it('should not match invalid entry nested in the request ', () => { const entry = createPerformanceEntry(RumPerformanceEntryType.RESOURCE, { + name: FAKE_URL, // fetchStart < startTime is invalid fetchStart: 0 as RelativeTime, startTime: 200 as RelativeTime, }) - - entries.push(entry) + globalPerformanceObjectMock.addPerformanceEntry(entry) const matchingEntry = matchRequestResourceEntry(FAKE_REQUEST as RequestCompleteEvent) diff --git a/packages/rum-core/src/domain/resource/retrieveInitialDocumentResourceTiming.ts b/packages/rum-core/src/domain/resource/retrieveInitialDocumentResourceTiming.ts index 5e8c47d888..5ac987c735 100644 --- a/packages/rum-core/src/domain/resource/retrieveInitialDocumentResourceTiming.ts +++ b/packages/rum-core/src/domain/resource/retrieveInitialDocumentResourceTiming.ts @@ -1,10 +1,9 @@ -import type { RelativeTime } from '@datadog/browser-core' import { assign, runOnReadyState } from '@datadog/browser-core' -import { supportPerformanceTimingEvent, RumPerformanceEntryType } from '../../browser/performanceObservable' +import { RumPerformanceEntryType } from '../../browser/performanceObservable' import type { RumPerformanceResourceTiming } from '../../browser/performanceObservable' import type { RumConfiguration } from '../configuration' import { getDocumentTraceId } from '../tracing/getDocumentTraceId' -import { computeRelativePerformanceTiming } from '../../browser/performanceUtils' +import { getNavigationEntry } from '../../browser/performanceUtils' import { FAKE_INITIAL_DOCUMENT } from './resourceUtils' export function retrieveInitialDocumentResourceTiming( @@ -12,36 +11,12 @@ export function retrieveInitialDocumentResourceTiming( callback: (timing: RumPerformanceResourceTiming) => void ) { runOnReadyState(configuration, 'interactive', () => { - let timing: RumPerformanceResourceTiming - - const forcedAttributes = { + const entry = assign(getNavigationEntry().toJSON(), { entryType: RumPerformanceEntryType.RESOURCE as const, initiatorType: FAKE_INITIAL_DOCUMENT, traceId: getDocumentTraceId(document), - toJSON: () => assign({}, timing, { toJSON: undefined }), - } - if ( - supportPerformanceTimingEvent(RumPerformanceEntryType.NAVIGATION) && - performance.getEntriesByType(RumPerformanceEntryType.NAVIGATION).length > 0 - ) { - const navigationEntry = performance.getEntriesByType(RumPerformanceEntryType.NAVIGATION)[0] - timing = assign(navigationEntry.toJSON() as RumPerformanceResourceTiming, forcedAttributes) - } else { - const relativePerformanceTiming = computeRelativePerformanceTiming() - timing = assign( - relativePerformanceTiming, - { - decodedBodySize: 0, - encodedBodySize: 0, - transferSize: 0, - renderBlockingStatus: 'non-blocking', - duration: relativePerformanceTiming.responseEnd, - name: window.location.href, - startTime: 0 as RelativeTime, - }, - forcedAttributes - ) - } - callback(timing) + toJSON: () => assign({}, entry, { toJSON: undefined }), + }) + callback(entry) }) } diff --git a/packages/rum-core/src/domain/view/setupViewTest.specHelper.ts b/packages/rum-core/src/domain/view/setupViewTest.specHelper.ts index fdb8fd1d2e..f0b8795f2c 100644 --- a/packages/rum-core/src/domain/view/setupViewTest.specHelper.ts +++ b/packages/rum-core/src/domain/view/setupViewTest.specHelper.ts @@ -1,8 +1,8 @@ -import { Observable } from '@datadog/browser-core' +import { Observable, deepClone } from '@datadog/browser-core' import { mockRumConfiguration, setupLocationObserver } from '../../../test' import type { LifeCycle } from '../lifeCycle' import { LifeCycleEventType } from '../lifeCycle' -import type { ViewEvent, ViewOptions } from './trackViews' +import type { ViewCreatedEvent, ViewEvent, ViewOptions, ViewEndedEvent } from './trackViews' import { trackViews } from './trackViews' export type ViewTest = ReturnType @@ -21,17 +21,21 @@ export function setupViewTest({ lifeCycle, initialLocation }: ViewTrackingContex handler: viewUpdateHandler, getViewEvent: getViewUpdate, getHandledCount: getViewUpdateCount, - } = spyOnViews('view update') + } = spyOnViews() lifeCycle.subscribe(LifeCycleEventType.VIEW_UPDATED, viewUpdateHandler) const { handler: viewCreateHandler, getViewEvent: getViewCreate, getHandledCount: getViewCreateCount, - } = spyOnViews('view create') + } = spyOnViews() lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, viewCreateHandler) - const { handler: viewEndHandler, getViewEvent: getViewEnd, getHandledCount: getViewEndCount } = spyOnViews('view end') + const { + handler: viewEndHandler, + getViewEvent: getViewEnd, + getHandledCount: getViewEndCount, + } = spyOnViews() lifeCycle.subscribe(LifeCycleEventType.VIEW_ENDED, viewEndHandler) const { stop, startView, updateViewName, setViewContext, setViewContextProperty, addTiming } = trackViews( @@ -63,16 +67,19 @@ export function setupViewTest({ lifeCycle, initialLocation }: ViewTrackingContex } } -function spyOnViews(name?: string) { - const handler = jasmine.createSpy(name) +function spyOnViews() { + const events: Event[] = [] - function getViewEvent(index: number) { - return handler.calls.argsFor(index)[0] as ViewEvent - } + return { + handler: (event: Event) => { + events.push( + // Some properties can be mutated later + deepClone(event) + ) + }, - function getHandledCount() { - return handler.calls.count() - } + getViewEvent: (index: number) => events[index], - return { handler, getViewEvent, getHandledCount } + getHandledCount: () => events.length, + } } diff --git a/packages/rum-core/src/domain/view/trackViews.spec.ts b/packages/rum-core/src/domain/view/trackViews.spec.ts index ad4adef95e..e2b482034d 100644 --- a/packages/rum-core/src/domain/view/trackViews.spec.ts +++ b/packages/rum-core/src/domain/view/trackViews.spec.ts @@ -415,6 +415,7 @@ describe('view metrics', () => { } const { getViewUpdate, getViewUpdateCount, getViewCreateCount, startView } = viewTest startView() + clock.tick(0) // run immediate timeouts (mostly for `trackNavigationTimings`) expect(getViewCreateCount()).toEqual(2) lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ @@ -430,26 +431,20 @@ describe('view metrics', () => { }) describe('initial view metrics', () => { - it('should be updated when notified with a PERFORMANCE_ENTRY_COLLECTED event (throttled)', () => { + it('updates should be throttled', () => { const { getViewUpdateCount, getViewUpdate } = viewTest expect(getViewUpdateCount()).toEqual(1) expect(getViewUpdate(0).initialViewMetrics).toEqual({}) - const navigationEntry = createPerformanceEntry(RumPerformanceEntryType.NAVIGATION) - notifyPerformanceEntries([navigationEntry]) + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD - 1) expect(getViewUpdateCount()).toEqual(1) + expect(getViewUpdate(0).initialViewMetrics).toEqual({}) - clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + clock.tick(1) expect(getViewUpdateCount()).toEqual(2) - expect(getViewUpdate(1).initialViewMetrics.navigationTimings).toEqual({ - firstByte: 123 as Duration, - domComplete: 456 as Duration, - domContentLoaded: 345 as Duration, - domInteractive: 234 as Duration, - loadEvent: 567 as Duration, - }) + expect(getViewUpdate(1).initialViewMetrics.navigationTimings).toEqual(jasmine.any(Object)) }) it('should be updated for 5 min after view end', () => { @@ -462,7 +457,7 @@ describe('view metrics', () => { lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ createPerformanceEntry(RumPerformanceEntryType.PAINT), ]) - notifyPerformanceEntries([createPerformanceEntry(RumPerformanceEntryType.NAVIGATION), lcpEntry]) + notifyPerformanceEntries([lcpEntry]) clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) @@ -482,7 +477,6 @@ describe('view metrics', () => { createPerformanceEntry(RumPerformanceEntryType.PAINT), createPerformanceEntry(RumPerformanceEntryType.LARGEST_CONTENTFUL_PAINT), ]) - notifyPerformanceEntries([createPerformanceEntry(RumPerformanceEntryType.NAVIGATION)]) clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) const latestUpdate = getViewUpdate(getViewUpdateCount() - 1) @@ -513,10 +507,7 @@ describe('view metrics', () => { lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ createPerformanceEntry(RumPerformanceEntryType.PAINT), ]) - notifyPerformanceEntries([ - createPerformanceEntry(RumPerformanceEntryType.NAVIGATION), - createPerformanceEntry(RumPerformanceEntryType.LARGEST_CONTENTFUL_PAINT), - ]) + notifyPerformanceEntries([createPerformanceEntry(RumPerformanceEntryType.LARGEST_CONTENTFUL_PAINT)]) clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) @@ -541,13 +532,7 @@ describe('view metrics', () => { expect(initialView.last.initialViewMetrics).toEqual( jasmine.objectContaining({ firstContentfulPaint: 123 as Duration, - navigationTimings: { - firstByte: 123 as Duration, - domComplete: 456 as Duration, - domContentLoaded: 345 as Duration, - domInteractive: 234 as Duration, - loadEvent: 567 as Duration, - }, + navigationTimings: jasmine.any(Object), largestContentfulPaint: { value: 789 as Duration, targetSelector: undefined }, }) ) @@ -559,7 +544,7 @@ describe('view metrics', () => { }) it('should update the initial view loadingTime following the loadEventEnd value', () => { - expect(initialView.last.commonViewMetrics.loadingTime).toBe(567 as RelativeTime) + expect(initialView.last.commonViewMetrics.loadingTime).toEqual(jasmine.any(Number)) }) }) }) @@ -609,7 +594,7 @@ describe('view custom timings', () => { }) it('should add custom timing to current view', () => { - clock.tick(0) + clock.tick(0) // run immediate timeouts (mostly for `trackNavigationTimings`) const { getViewUpdate, startView, addTiming } = viewTest startView() @@ -715,7 +700,7 @@ describe('view custom timings', () => { }) it('should not add custom timing when the session has expired', () => { - clock.tick(0) + clock.tick(0) // run immediate timeouts (mostly for `trackNavigationTimings`) const { getViewUpdateCount, addTiming } = viewTest lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) diff --git a/packages/rum-core/src/domain/view/viewMetrics/trackInitialViewMetrics.spec.ts b/packages/rum-core/src/domain/view/viewMetrics/trackInitialViewMetrics.spec.ts index 6775ff707c..3b2c088590 100644 --- a/packages/rum-core/src/domain/view/viewMetrics/trackInitialViewMetrics.spec.ts +++ b/packages/rum-core/src/domain/view/viewMetrics/trackInitialViewMetrics.spec.ts @@ -1,24 +1,25 @@ import type { Duration, RelativeTime } from '@datadog/browser-core' -import { registerCleanupTask } from '@datadog/browser-core/test' -import type { RumPerformanceEntry } from '../../../browser/performanceObservable' +import type { Clock } from '@datadog/browser-core/test' +import { mockClock, registerCleanupTask } from '@datadog/browser-core/test' import { RumPerformanceEntryType } from '../../../browser/performanceObservable' -import { createPerformanceEntry, mockPerformanceObserver, mockRumConfiguration } from '../../../../test' +import { createPerformanceEntry, mockRumConfiguration } from '../../../../test' import { LifeCycle, LifeCycleEventType } from '../../lifeCycle' import { trackInitialViewMetrics } from './trackInitialViewMetrics' describe('trackInitialViewMetrics', () => { let lifeCycle: LifeCycle + let clock: Clock let scheduleViewUpdateSpy: jasmine.Spy<() => void> let trackInitialViewMetricsResult: ReturnType let setLoadEventSpy: jasmine.Spy<(loadEvent: Duration) => void> - let notifyPerformanceEntries: (entries: RumPerformanceEntry[]) => void beforeEach(() => { lifeCycle = new LifeCycle() const configuration = mockRumConfiguration() scheduleViewUpdateSpy = jasmine.createSpy() setLoadEventSpy = jasmine.createSpy() - ;({ notifyPerformanceEntries } = mockPerformanceObserver()) + clock = mockClock() + registerCleanupTask(clock.cleanup) trackInitialViewMetricsResult = trackInitialViewMetrics( lifeCycle, @@ -31,21 +32,15 @@ describe('trackInitialViewMetrics', () => { }) it('should merge metrics from various sources', () => { - notifyPerformanceEntries([createPerformanceEntry(RumPerformanceEntryType.NAVIGATION)]) lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ createPerformanceEntry(RumPerformanceEntryType.PAINT), createPerformanceEntry(RumPerformanceEntryType.FIRST_INPUT), ]) + clock.tick(0) expect(scheduleViewUpdateSpy).toHaveBeenCalledTimes(3) expect(trackInitialViewMetricsResult.initialViewMetrics).toEqual({ - navigationTimings: { - firstByte: 123 as Duration, - domComplete: 456 as Duration, - domContentLoaded: 345 as Duration, - domInteractive: 234 as Duration, - loadEvent: 567 as Duration, - }, + navigationTimings: jasmine.any(Object), firstContentfulPaint: 123 as Duration, firstInput: { delay: 100 as Duration, @@ -56,12 +51,12 @@ describe('trackInitialViewMetrics', () => { }) it('calls the `setLoadEvent` callback when the loadEvent timing is known', () => { - notifyPerformanceEntries([createPerformanceEntry(RumPerformanceEntryType.NAVIGATION)]) lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ createPerformanceEntry(RumPerformanceEntryType.PAINT), createPerformanceEntry(RumPerformanceEntryType.FIRST_INPUT), ]) + clock.tick(0) - expect(setLoadEventSpy).toHaveBeenCalledOnceWith(567 as Duration) + expect(setLoadEventSpy).toHaveBeenCalledOnceWith(jasmine.any(Number)) }) }) diff --git a/packages/rum-core/src/domain/view/viewMetrics/trackNavigationTimings.spec.ts b/packages/rum-core/src/domain/view/viewMetrics/trackNavigationTimings.spec.ts index d94259dc7c..5ee757b52f 100644 --- a/packages/rum-core/src/domain/view/viewMetrics/trackNavigationTimings.spec.ts +++ b/packages/rum-core/src/domain/view/viewMetrics/trackNavigationTimings.spec.ts @@ -1,42 +1,45 @@ -import type { Duration, RelativeTime } from '@datadog/browser-core' +import { relativeNow, type Duration, type RelativeTime } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' import { mockClock, registerCleanupTask } from '@datadog/browser-core/test' -import type { RumPerformanceEntry } from '../../../browser/performanceObservable' -import { RumPerformanceEntryType } from '../../../browser/performanceObservable' -import { - createPerformanceEntry, - mockPerformanceObserver, - mockPerformanceTiming, - mockRumConfiguration, -} from '../../../../test' -import type { NavigationTimings } from './trackNavigationTimings' +import { mockDocumentReadyState, mockRumConfiguration } from '../../../../test' +import type { NavigationTimings, RelevantNavigationTiming } from './trackNavigationTimings' import { trackNavigationTimings } from './trackNavigationTimings' +const FAKE_NAVIGATION_ENTRY: RelevantNavigationTiming = { + domComplete: 456 as RelativeTime, + domContentLoadedEventEnd: 345 as RelativeTime, + domInteractive: 234 as RelativeTime, + loadEventEnd: 567 as RelativeTime, + responseStart: 123 as RelativeTime, +} + +const FAKE_INCOMPLETE_NAVIGATION_ENTRY: RelevantNavigationTiming = { + domComplete: 0 as RelativeTime, + domContentLoadedEventEnd: 0 as RelativeTime, + domInteractive: 0 as RelativeTime, + loadEventEnd: 0 as RelativeTime, + responseStart: 0 as RelativeTime, +} + describe('trackNavigationTimings', () => { let navigationTimingsCallback: jasmine.Spy<(timings: NavigationTimings) => void> - let notifyPerformanceEntries: (entries: RumPerformanceEntry[]) => void let stop: () => void let clock: Clock - function removePerformanceObserver() { - const originalPerformanceObserver = window.PerformanceObserver - window.PerformanceObserver = undefined as any + beforeEach(() => { + navigationTimingsCallback = jasmine.createSpy() + clock = mockClock() registerCleanupTask(() => { - window.PerformanceObserver = originalPerformanceObserver + clock.cleanup() stop() - clock?.cleanup() }) - } - - beforeEach(() => { - navigationTimingsCallback = jasmine.createSpy() }) - it('should provide navigation timing', () => { - ;({ notifyPerformanceEntries } = mockPerformanceObserver()) - ;({ stop } = trackNavigationTimings(mockRumConfiguration(), navigationTimingsCallback)) - notifyPerformanceEntries([createPerformanceEntry(RumPerformanceEntryType.NAVIGATION)]) + it('notifies navigation timings after the load event', () => { + ;({ stop } = trackNavigationTimings(mockRumConfiguration(), navigationTimingsCallback, () => FAKE_NAVIGATION_ENTRY)) + + clock.tick(0) expect(navigationTimingsCallback).toHaveBeenCalledOnceWith({ firstByte: 123 as Duration, @@ -47,29 +50,46 @@ describe('trackNavigationTimings', () => { }) }) - it('should discard incomplete navigation timing', () => { - ;({ notifyPerformanceEntries } = mockPerformanceObserver()) - ;({ stop } = trackNavigationTimings(mockRumConfiguration(), navigationTimingsCallback)) - notifyPerformanceEntries([ - createPerformanceEntry(RumPerformanceEntryType.NAVIGATION, { loadEventEnd: 0 as RelativeTime }), - ]) + it('does not report "firstByte" if "responseStart" is negative', () => { + ;({ stop } = trackNavigationTimings(mockRumConfiguration(), navigationTimingsCallback, () => ({ + ...FAKE_NAVIGATION_ENTRY, + responseStart: -1 as RelativeTime, + }))) + + clock.tick(0) + + expect(navigationTimingsCallback.calls.mostRecent().args[0].firstByte).toBeUndefined() + }) + + it('does not report "firstByte" if "responseStart" is in the future', () => { + ;({ stop } = trackNavigationTimings(mockRumConfiguration(), navigationTimingsCallback, () => ({ + ...FAKE_NAVIGATION_ENTRY, + responseStart: (relativeNow() + 1) as RelativeTime, + }))) + + clock.tick(0) + + expect(navigationTimingsCallback.calls.mostRecent().args[0].firstByte).toBeUndefined() + }) + + it('wait for the load event to provide navigation timing', () => { + mockDocumentReadyState() + ;({ stop } = trackNavigationTimings(mockRumConfiguration(), navigationTimingsCallback, () => FAKE_NAVIGATION_ENTRY)) + + clock.tick(0) expect(navigationTimingsCallback).not.toHaveBeenCalled() }) - it('should provide navigation timing when navigation timing is not supported ', () => { - clock = mockClock() - mockPerformanceTiming() - removePerformanceObserver() - ;({ stop } = trackNavigationTimings(mockRumConfiguration(), navigationTimingsCallback)) + it('discard incomplete navigation timing', () => { + ;({ stop } = trackNavigationTimings( + mockRumConfiguration(), + navigationTimingsCallback, + () => FAKE_INCOMPLETE_NAVIGATION_ENTRY + )) + clock.tick(0) - expect(navigationTimingsCallback).toHaveBeenCalledOnceWith({ - firstByte: 123 as Duration, - domComplete: 456 as Duration, - domContentLoaded: 345 as Duration, - domInteractive: 234 as Duration, - loadEvent: 567 as Duration, - }) + expect(navigationTimingsCallback).not.toHaveBeenCalled() }) }) diff --git a/packages/rum-core/src/domain/view/viewMetrics/trackNavigationTimings.ts b/packages/rum-core/src/domain/view/viewMetrics/trackNavigationTimings.ts index 0da6c8cb89..b70585f267 100644 --- a/packages/rum-core/src/domain/view/viewMetrics/trackNavigationTimings.ts +++ b/packages/rum-core/src/domain/view/viewMetrics/trackNavigationTimings.ts @@ -1,14 +1,8 @@ -import type { Duration } from '@datadog/browser-core' -import { forEach, setTimeout, noop, relativeNow, runOnReadyState } from '@datadog/browser-core' -import type { RelativePerformanceTiming } from '../../../browser/performanceUtils' -import { computeRelativePerformanceTiming } from '../../../browser/performanceUtils' +import type { Duration, TimeoutId } from '@datadog/browser-core' +import { setTimeout, relativeNow, runOnReadyState, clearTimeout } from '@datadog/browser-core' import type { RumPerformanceNavigationTiming } from '../../../browser/performanceObservable' -import { - createPerformanceObservable, - RumPerformanceEntryType, - supportPerformanceTimingEvent, -} from '../../../browser/performanceObservable' import type { RumConfiguration } from '../../configuration' +import { getNavigationEntry } from '../../../browser/performanceUtils' export interface NavigationTimings { domComplete: Duration @@ -18,30 +12,28 @@ export interface NavigationTimings { firstByte: Duration | undefined } +// This is a subset of "RumPerformanceNavigationTiming" that only contains the relevant fields for +// computing navigation timings. This is useful to mock the navigation entry in tests. +export type RelevantNavigationTiming = Pick< + RumPerformanceNavigationTiming, + 'domComplete' | 'domContentLoadedEventEnd' | 'domInteractive' | 'loadEventEnd' | 'responseStart' +> + export function trackNavigationTimings( configuration: RumConfiguration, - callback: (timings: NavigationTimings) => void + callback: (timings: NavigationTimings) => void, + getNavigationEntryImpl: () => RelevantNavigationTiming = getNavigationEntry ) { - const processEntry = (entry: RumPerformanceNavigationTiming | RelativePerformanceTiming) => { + return waitAfterLoadEvent(configuration, () => { + const entry = getNavigationEntryImpl() + if (!isIncompleteNavigation(entry)) { callback(processNavigationEntry(entry)) } - } - - let stop = noop - if (supportPerformanceTimingEvent(RumPerformanceEntryType.NAVIGATION)) { - ;({ unsubscribe: stop } = createPerformanceObservable(configuration, { - type: RumPerformanceEntryType.NAVIGATION, - buffered: true, - }).subscribe((entries) => forEach(entries, processEntry))) - } else { - retrieveNavigationTiming(configuration, processEntry) - } - - return { stop } + }) } -function processNavigationEntry(entry: RumPerformanceNavigationTiming | RelativePerformanceTiming): NavigationTimings { +function processNavigationEntry(entry: RelevantNavigationTiming): NavigationTimings { return { domComplete: entry.domComplete, domContentLoaded: entry.domContentLoadedEventEnd, @@ -55,16 +47,20 @@ function processNavigationEntry(entry: RumPerformanceNavigationTiming | Relative } } -function isIncompleteNavigation(entry: RumPerformanceNavigationTiming | RelativePerformanceTiming) { +function isIncompleteNavigation(entry: RelevantNavigationTiming) { return entry.loadEventEnd <= 0 } -function retrieveNavigationTiming( - configuration: RumConfiguration, - callback: (timing: RelativePerformanceTiming) => void -) { - runOnReadyState(configuration, 'complete', () => { - // Send it a bit after the actual load event, so the "loadEventEnd" timing is accurate - setTimeout(() => callback(computeRelativePerformanceTiming())) +function waitAfterLoadEvent(configuration: RumConfiguration, callback: () => void) { + let timeoutId: TimeoutId | undefined + const { stop: stopOnReadyState } = runOnReadyState(configuration, 'complete', () => { + // Invoke the callback a bit after the actual load event, so the "loadEventEnd" timing is accurate + timeoutId = setTimeout(() => callback()) }) + return { + stop: () => { + stopOnReadyState() + clearTimeout(timeoutId) + }, + } } diff --git a/packages/rum-core/test/emulate/mockDocumentReadyState.ts b/packages/rum-core/test/emulate/mockDocumentReadyState.ts new file mode 100644 index 0000000000..d035ff10a9 --- /dev/null +++ b/packages/rum-core/test/emulate/mockDocumentReadyState.ts @@ -0,0 +1,17 @@ +import { DOM_EVENT } from '@datadog/browser-core' +import { createNewEvent } from '../../../core/test' + +export function mockDocumentReadyState() { + let readyState: DocumentReadyState = 'loading' + spyOnProperty(Document.prototype, 'readyState', 'get').and.callFake(() => readyState) + return { + triggerOnDomLoaded: () => { + readyState = 'interactive' + window.dispatchEvent(createNewEvent(DOM_EVENT.DOM_CONTENT_LOADED)) + }, + triggerOnLoad: () => { + readyState = 'complete' + window.dispatchEvent(createNewEvent(DOM_EVENT.LOAD)) + }, + } +} diff --git a/packages/rum-core/test/emulate/mockGlobalPerformanceBuffer.ts b/packages/rum-core/test/emulate/mockGlobalPerformanceBuffer.ts new file mode 100644 index 0000000000..ccb654e935 --- /dev/null +++ b/packages/rum-core/test/emulate/mockGlobalPerformanceBuffer.ts @@ -0,0 +1,19 @@ +export interface GlobalPerformanceBufferMock { + addPerformanceEntry: (entry: PerformanceEntry) => void +} + +export function mockGlobalPerformanceBuffer(initialEntries: PerformanceEntry[] = []): GlobalPerformanceBufferMock { + const performanceEntries: PerformanceEntry[] = initialEntries + + spyOn(performance, 'getEntries').and.callFake(() => performanceEntries.slice()) + spyOn(performance, 'getEntriesByName').and.callFake((name) => + performanceEntries.filter((entry) => entry.name === name) + ) + spyOn(performance, 'getEntriesByType').and.callFake((type) => + performanceEntries.filter((entry) => entry.entryType === type) + ) + + return { + addPerformanceEntry: (entry) => performanceEntries.push(entry), + } +} diff --git a/packages/rum-core/test/emulate/mockPerformanceObserver.ts b/packages/rum-core/test/emulate/mockPerformanceObserver.ts index 9d14475452..4325864238 100644 --- a/packages/rum-core/test/emulate/mockPerformanceObserver.ts +++ b/packages/rum-core/test/emulate/mockPerformanceObserver.ts @@ -73,24 +73,3 @@ export function mockPerformanceObserver({ typeSupported = true, emulateAllEntryT }, } } - -export function mockPerformanceTiming() { - const timings = { - domComplete: 456, - domContentLoadedEventEnd: 345, - domContentLoadedEventStart: 0, - domInteractive: 234, - loadEventEnd: 567, - loadEventStart: 567, - responseStart: 123, - unloadEventEnd: 0, - unloadEventStart: 0, - } as typeof performance.timing - const properties = Object.keys(timings) as Array - - for (const propertyName of properties) { - spyOnProperty(performance.timing, propertyName, 'get').and.callFake( - () => performance.timing.navigationStart + (timings[propertyName] as number) - ) - } -} diff --git a/packages/rum-core/test/index.ts b/packages/rum-core/test/index.ts index a71e31e960..fcd3ab5cc6 100644 --- a/packages/rum-core/test/index.ts +++ b/packages/rum-core/test/index.ts @@ -6,6 +6,8 @@ export * from './mockCiVisibilityValues' export * from './mockRumSessionManager' export * from './noopRecorderApi' export * from './emulate/mockPerformanceObserver' +export * from './emulate/mockDocumentReadyState' +export * from './emulate/mockGlobalPerformanceBuffer' export * from './mockPageStateHistory' export * from './mockRumConfiguration' export * from './locationChangeSetup' diff --git a/packages/rum/src/boot/recorderApi.spec.ts b/packages/rum/src/boot/recorderApi.spec.ts index fabb4afd81..87a0e756ae 100644 --- a/packages/rum/src/boot/recorderApi.spec.ts +++ b/packages/rum/src/boot/recorderApi.spec.ts @@ -2,9 +2,14 @@ import type { DeflateEncoder, DeflateWorker, DeflateWorkerAction } from '@datado import { BridgeCapability, PageExitReason, display, isIE } from '@datadog/browser-core' import type { RecorderApi, RumSessionManager } from '@datadog/browser-rum-core' import { LifeCycle, LifeCycleEventType } from '@datadog/browser-rum-core' -import { mockEventBridge, createNewEvent, registerCleanupTask } from '@datadog/browser-core/test' +import { mockEventBridge, registerCleanupTask } from '@datadog/browser-core/test' import type { RumSessionManagerMock } from '../../../rum-core/test' -import { createRumSessionManagerMock, mockRumConfiguration, mockViewHistory } from '../../../rum-core/test' +import { + createRumSessionManagerMock, + mockDocumentReadyState, + mockRumConfiguration, + mockViewHistory, +} from '../../../rum-core/test' import type { CreateDeflateWorker } from '../domain/deflate' import { MockWorker } from '../../test' import { resetDeflateWorkerState } from '../domain/deflate' @@ -555,14 +560,3 @@ describe('makeRecorderApi', () => { }) }) }) - -function mockDocumentReadyState() { - let readyState: DocumentReadyState = 'loading' - spyOnProperty(Document.prototype, 'readyState', 'get').and.callFake(() => readyState) - return { - triggerOnDomLoaded: () => { - readyState = 'interactive' - window.dispatchEvent(createNewEvent('DOMContentLoaded')) - }, - } -}