From 7300456a86e75ec13c095bf80cd58f8c28ea0a8e Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Tue, 28 Mar 2023 17:02:19 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20extract=20mockWorker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/rum/test/index.ts | 1 + packages/rum/test/mockWorker.ts | 136 ++++++++++++++++++++++++++++++++ packages/rum/test/utils.ts | 136 -------------------------------- 3 files changed, 137 insertions(+), 136 deletions(-) create mode 100644 packages/rum/test/mockWorker.ts diff --git a/packages/rum/test/index.ts b/packages/rum/test/index.ts index 9c56149efa..f5c6707c2e 100644 --- a/packages/rum/test/index.ts +++ b/packages/rum/test/index.ts @@ -1 +1,2 @@ +export * from './mockWorker' export * from './utils' diff --git a/packages/rum/test/mockWorker.ts b/packages/rum/test/mockWorker.ts new file mode 100644 index 0000000000..0d941ace8c --- /dev/null +++ b/packages/rum/test/mockWorker.ts @@ -0,0 +1,136 @@ +import type { DeflateWorker, DeflateWorkerAction, DeflateWorkerListener } from '../src/domain/segmentCollection' + +export class MockWorker implements DeflateWorker { + readonly pendingMessages: DeflateWorkerAction[] = [] + private rawBytesCount = 0 + private deflatedData: Uint8Array[] = [] + private listeners: { + message: DeflateWorkerListener[] + error: Array<(error: unknown) => void> + } = { message: [], error: [] } + + addEventListener(eventName: 'message', listener: DeflateWorkerListener): void + addEventListener(eventName: 'error', listener: (error: ErrorEvent) => void): void + addEventListener(eventName: 'message' | 'error', listener: any): void { + const index = this.listeners[eventName].indexOf(listener) + if (index < 0) { + this.listeners[eventName].push(listener) + } + } + + removeEventListener(eventName: 'message', listener: DeflateWorkerListener): void + removeEventListener(eventName: 'error', listener: (error: ErrorEvent) => void): void + removeEventListener(eventName: 'message' | 'error', listener: any): void { + const index = this.listeners[eventName].indexOf(listener) + if (index >= 0) { + this.listeners[eventName].splice(index, 1) + } + } + + postMessage(message: DeflateWorkerAction): void { + this.pendingMessages.push(message) + } + + terminate(): void { + // do nothing + } + + get pendingData() { + return this.pendingMessages.map((message) => ('data' in message ? message.data : '')).join('') + } + + get messageListenersCount() { + return this.listeners.message.length + } + + processAllMessages(): void { + while (this.pendingMessages.length) { + this.processNextMessage() + } + } + + dropNextMessage(): void { + this.pendingMessages.shift() + } + + processNextMessage(): void { + const message = this.pendingMessages.shift() + if (message) { + switch (message.action) { + case 'init': + this.listeners.message.forEach((listener) => + listener({ + data: { + type: 'initialized', + }, + }) + ) + break + case 'write': + { + const additionalBytesCount = this.pushData(message.data) + this.listeners.message.forEach((listener) => + listener({ + data: { + type: 'wrote', + id: message.id, + compressedBytesCount: uint8ArraysSize(this.deflatedData), + additionalBytesCount, + }, + }) + ) + } + break + case 'flush': + { + const additionalBytesCount = this.pushData(message.data) + this.listeners.message.forEach((listener) => + listener({ + data: { + type: 'flushed', + id: message.id, + result: mergeUint8Arrays(this.deflatedData), + rawBytesCount: this.rawBytesCount, + additionalBytesCount, + }, + }) + ) + this.deflatedData.length = 0 + this.rawBytesCount = 0 + } + break + } + } + } + + dispatchErrorEvent() { + const error = new ErrorEvent('worker') + this.listeners.error.forEach((listener) => listener(error)) + } + + dispatchErrorMessage(error: Error | string) { + this.listeners.message.forEach((listener) => listener({ data: { type: 'errored', error } })) + } + + private pushData(data?: string) { + const encodedData = new TextEncoder().encode(data) + this.rawBytesCount += encodedData.length + // In the mock worker, for simplicity, we'll just use the UTF-8 encoded string instead of deflating it. + this.deflatedData.push(encodedData) + return encodedData.length + } +} + +function uint8ArraysSize(arrays: Uint8Array[]) { + return arrays.reduce((sum, bytes) => sum + bytes.length, 0) +} + +function mergeUint8Arrays(arrays: Uint8Array[]) { + const result = new Uint8Array(uint8ArraysSize(arrays)) + let offset = 0 + for (const bytes of arrays) { + result.set(bytes, offset) + offset += bytes.byteLength + } + return result +} diff --git a/packages/rum/test/utils.ts b/packages/rum/test/utils.ts index c8d47029d5..258139b8aa 100644 --- a/packages/rum/test/utils.ts +++ b/packages/rum/test/utils.ts @@ -2,7 +2,6 @@ import { noop } from '@datadog/browser-core' import type { RumConfiguration } from '@datadog/browser-rum-core' import { NodePrivacyLevel } from '../src/constants' import type { ShadowRootsController } from '../src/domain/record' -import type { DeflateWorker, DeflateWorkerAction, DeflateWorkerListener } from '../src/domain/segmentCollection' import type { BrowserFullSnapshotRecord, BrowserIncrementalSnapshotRecord, @@ -22,141 +21,6 @@ import type { } from '../src/types' import { RecordType, IncrementalSource, NodeType } from '../src/types' -export class MockWorker implements DeflateWorker { - readonly pendingMessages: DeflateWorkerAction[] = [] - private rawBytesCount = 0 - private deflatedData: Uint8Array[] = [] - private listeners: { - message: DeflateWorkerListener[] - error: Array<(error: unknown) => void> - } = { message: [], error: [] } - - addEventListener(eventName: 'message', listener: DeflateWorkerListener): void - addEventListener(eventName: 'error', listener: (error: ErrorEvent) => void): void - addEventListener(eventName: 'message' | 'error', listener: any): void { - const index = this.listeners[eventName].indexOf(listener) - if (index < 0) { - this.listeners[eventName].push(listener) - } - } - - removeEventListener(eventName: 'message', listener: DeflateWorkerListener): void - removeEventListener(eventName: 'error', listener: (error: ErrorEvent) => void): void - removeEventListener(eventName: 'message' | 'error', listener: any): void { - const index = this.listeners[eventName].indexOf(listener) - if (index >= 0) { - this.listeners[eventName].splice(index, 1) - } - } - - postMessage(message: DeflateWorkerAction): void { - this.pendingMessages.push(message) - } - - terminate(): void { - // do nothing - } - - get pendingData() { - return this.pendingMessages.map((message) => ('data' in message ? message.data : '')).join('') - } - - get messageListenersCount() { - return this.listeners.message.length - } - - processAllMessages(): void { - while (this.pendingMessages.length) { - this.processNextMessage() - } - } - - dropNextMessage(): void { - this.pendingMessages.shift() - } - - processNextMessage(): void { - const message = this.pendingMessages.shift() - if (message) { - switch (message.action) { - case 'init': - this.listeners.message.forEach((listener) => - listener({ - data: { - type: 'initialized', - }, - }) - ) - break - case 'write': - { - const additionalBytesCount = this.pushData(message.data) - this.listeners.message.forEach((listener) => - listener({ - data: { - type: 'wrote', - id: message.id, - compressedBytesCount: uint8ArraysSize(this.deflatedData), - additionalBytesCount, - }, - }) - ) - } - break - case 'flush': - { - const additionalBytesCount = this.pushData(message.data) - this.listeners.message.forEach((listener) => - listener({ - data: { - type: 'flushed', - id: message.id, - result: mergeUint8Arrays(this.deflatedData), - rawBytesCount: this.rawBytesCount, - additionalBytesCount, - }, - }) - ) - this.deflatedData.length = 0 - this.rawBytesCount = 0 - } - break - } - } - } - - dispatchErrorEvent() { - const error = new ErrorEvent('worker') - this.listeners.error.forEach((listener) => listener(error)) - } - - dispatchErrorMessage(error: Error | string) { - this.listeners.message.forEach((listener) => listener({ data: { type: 'errored', error } })) - } - - private pushData(data?: string) { - const encodedData = new TextEncoder().encode(data) - this.rawBytesCount += encodedData.length - // In the mock worker, for simplicity, we'll just use the UTF-8 encoded string instead of deflating it. - this.deflatedData.push(encodedData) - return encodedData.length - } -} - -function uint8ArraysSize(arrays: Uint8Array[]) { - return arrays.reduce((sum, bytes) => sum + bytes.length, 0) -} - -function mergeUint8Arrays(arrays: Uint8Array[]) { - const result = new Uint8Array(uint8ArraysSize(arrays)) - let offset = 0 - for (const bytes of arrays) { - result.set(bytes, offset) - offset += bytes.byteLength - } - return result -} - export function parseSegment(bytes: Uint8Array) { return JSON.parse(new TextDecoder().decode(bytes)) as BrowserSegment } From 3c9fd8533c55d379fdbbbb4efe6df8d9b76be017 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Tue, 28 Mar 2023 17:04:51 +0200 Subject: [PATCH 2/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20move=20parseSegment=20?= =?UTF-8?q?to=20segment.spec.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/rum/src/domain/segmentCollection/segment.spec.ts | 8 ++++++-- packages/rum/test/utils.ts | 4 ---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/rum/src/domain/segmentCollection/segment.spec.ts b/packages/rum/src/domain/segmentCollection/segment.spec.ts index 3a0656149b..240e5f42b4 100644 --- a/packages/rum/src/domain/segmentCollection/segment.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segment.spec.ts @@ -1,7 +1,7 @@ import type { TimeStamp } from '@datadog/browser-core' import { noop, setDebugMode, display, isIE } from '@datadog/browser-core' -import { MockWorker, parseSegment } from '../../../test' -import type { CreationReason, BrowserRecord, SegmentContext } from '../../types' +import { MockWorker } from '../../../test' +import type { CreationReason, BrowserRecord, SegmentContext, BrowserSegment } from '../../types' import { RecordType } from '../../types' import { getReplayStats, resetReplayStats } from '../replayStats' import { Segment } from './segment' @@ -240,3 +240,7 @@ describe('Segment', () => { return new Segment(worker, context, creationReason, initialRecord, onWrote, onFlushed) } }) + +function parseSegment(bytes: Uint8Array) { + return JSON.parse(new TextDecoder().decode(bytes)) as BrowserSegment +} diff --git a/packages/rum/test/utils.ts b/packages/rum/test/utils.ts index 258139b8aa..903f0c0f0f 100644 --- a/packages/rum/test/utils.ts +++ b/packages/rum/test/utils.ts @@ -21,10 +21,6 @@ import type { } from '../src/types' import { RecordType, IncrementalSource, NodeType } from '../src/types' -export function parseSegment(bytes: Uint8Array) { - return JSON.parse(new TextDecoder().decode(bytes)) as BrowserSegment -} - // Returns the first MetaRecord in a Segment, if any. export function findMeta(segment: BrowserSegment): MetaRecord | null { return segment.records.find((record) => record.type === RecordType.Meta) as MetaRecord From f401ffc5bd0c9928a16baff75361b1e3baee584a Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Tue, 28 Mar 2023 17:12:01 +0200 Subject: [PATCH 3/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20extract=20mutationPayl?= =?UTF-8?q?oadValidator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/rum/test/index.ts | 1 + packages/rum/test/mutationPayloadValidator.ts | 223 ++++++++++++++++++ packages/rum/test/utils.ts | 208 ---------------- 3 files changed, 224 insertions(+), 208 deletions(-) create mode 100644 packages/rum/test/mutationPayloadValidator.ts diff --git a/packages/rum/test/index.ts b/packages/rum/test/index.ts index f5c6707c2e..f78cfc9582 100644 --- a/packages/rum/test/index.ts +++ b/packages/rum/test/index.ts @@ -1,2 +1,3 @@ export * from './mockWorker' export * from './utils' +export * from './mutationPayloadValidator' diff --git a/packages/rum/test/mutationPayloadValidator.ts b/packages/rum/test/mutationPayloadValidator.ts new file mode 100644 index 0000000000..303b2b8110 --- /dev/null +++ b/packages/rum/test/mutationPayloadValidator.ts @@ -0,0 +1,223 @@ +import { NodeType, IncrementalSource } from '../src/types' +import type { + SerializedNodeWithId, + ElementNode, + TextNode, + DocumentFragmentNode, + SerializedNode, + BrowserMutationPayload, + BrowserSegment, + BrowserMutationData, +} from '../src/types' +import { + findTextNode, + findElementWithIdAttribute, + findElementWithTagName, + findFullSnapshot, + findAllIncrementalSnapshots, +} from './utils' + +interface NodeSelector { + // Select the first node with the given tag name from the initial full snapshot + tag?: string + // Select the first node with the given id attribute from the initial full snapshot + idAttribute?: string + // Select the first node with the given text content from the initial full snapshot + text?: string +} + +interface ExpectedTextMutation { + // Reference to the node where the mutation happens + node: ExpectedNode + // New text value + value: string +} + +interface ExpectedAttributeMutation { + // Reference to the node where the mutation happens + node: ExpectedNode + // Updated attributes + attributes: { + [key: string]: string | null + } +} + +interface ExpectedRemoveMutation { + // Reference to the removed node + node: ExpectedNode + // Reference to the parent of the removed node + parent: ExpectedNode +} + +interface ExpectedAddMutation { + // Partially check for the added node properties. + node: ExpectedNode + // Reference to the parent of the added node + parent: ExpectedNode + // Reference to the sibling of the added node + next?: ExpectedNode +} + +interface ExpectedMutationsPayload { + texts?: ExpectedTextMutation[] + attributes?: ExpectedAttributeMutation[] + removes?: ExpectedRemoveMutation[] + adds?: ExpectedAddMutation[] +} + +/** + * ExpectedNode is a helper class to build a serialized Node tree to be used to validate mutations. + * For now, its purpose is limited to specifying child nodes. + */ +class ExpectedNode { + constructor(private node: Omit & { childNodes?: ExpectedNode[] }) {} + + withChildren(...childNodes: ExpectedNode[]): ExpectedNode { + return new ExpectedNode({ ...this.node, childNodes }) + } + + getId() { + return this.node.id + } + + toSerializedNodeWithId() { + const { childNodes, ...result } = this.node + if (childNodes) { + ;(result as any).childNodes = childNodes.map((node) => node.toSerializedNodeWithId()) + } + return result as SerializedNodeWithId + } +} + +type Optional = Pick, K> & Omit + +/** + * Based on an serialized initial document, it returns: + * + * * a set of utilities functions to expect nodes by selecting initial nodes from the initial + * document (expectInitialNode) or creating new nodes (expectNewNode) + * + * * a 'validate' function to actually validate a mutation payload against an expected mutation + * object. + */ +export function createMutationPayloadValidator(initialDocument: SerializedNodeWithId) { + let maxNodeId = findMaxNodeId(initialDocument) + + /** + * Creates a new node based on input parameter, with sensible default properties, and an + * automatically computed 'id' attribute based on the previously created nodes. + */ + function expectNewNode(node: Optional): ExpectedNode + function expectNewNode(node: TextNode): ExpectedNode + function expectNewNode(node: Partial): ExpectedNode + function expectNewNode(node: Partial) { + maxNodeId += 1 + if (node.type === NodeType.Element) { + node.attributes ||= {} + node.childNodes = [] + } + return new ExpectedNode({ + ...node, + id: maxNodeId, + } as any) + } + + return { + /** + * Validates the mutation payload against the expected text, attribute, add and remove mutations. + */ + validate: (payload: BrowserMutationPayload, expected: ExpectedMutationsPayload) => { + payload = removeUndefinedValues(payload) + + expect(payload.adds).toEqual( + (expected.adds || []).map(({ node, parent, next }) => ({ + node: node.toSerializedNodeWithId(), + parentId: parent.getId(), + nextId: next ? next.getId() : null, + })) + ) + expect(payload.texts).toEqual((expected.texts || []).map(({ node, value }) => ({ id: node.getId(), value }))) + expect(payload.removes).toEqual( + (expected.removes || []).map(({ node, parent }) => ({ + id: node.getId(), + parentId: parent.getId(), + })) + ) + expect(payload.attributes).toEqual( + (expected.attributes || []).map(({ node, attributes }) => ({ + id: node.getId(), + attributes, + })) + ) + }, + + expectNewNode, + + /** + * Selects a node from the initially serialized document. Nodes can be selected via their 'tag' + * name, 'id' attribute or 'text' content. + */ + expectInitialNode: (selector: NodeSelector) => { + let node + if (selector.text) { + node = findTextNode(initialDocument, selector.text) + } else if (selector.idAttribute) { + node = findElementWithIdAttribute(initialDocument, selector.idAttribute) + } else if (selector.tag) { + node = findElementWithTagName(initialDocument, selector.tag) + } else { + throw new Error('Empty selector') + } + + if (!node) { + throw new Error(`Cannot find node from selector ${JSON.stringify(selector)}`) + } + + if ('childNodes' in node) { + node = { ...node, childNodes: [] } + } + + return new ExpectedNode(removeUndefinedValues(node)) + }, + } + + function findMaxNodeId(root: SerializedNodeWithId): number { + if ('childNodes' in root) { + return Math.max(root.id, ...root.childNodes.map((child) => findMaxNodeId(child))) + } + + return root.id + } + + /** + * When serializing a Node, some properties like 'isSVG' may be undefined, and they are not + * sent to the intake. + * + * To be able to validate mutations from E2E and Unit tests, we prefer to keep a single + * format. Thus, we serialize and deserialize objects to drop undefined + * properties, so they don't interferes during unit tests. + */ + function removeUndefinedValues(object: T) { + return JSON.parse(JSON.stringify(object)) as T + } +} + +/** + * Validate the first and only mutation record of a segment against the expected text, attribute, + * add and remove mutations. + */ +export function createMutationPayloadValidatorFromSegment(segment: BrowserSegment) { + const fullSnapshot = findFullSnapshot(segment)! + expect(fullSnapshot).toBeTruthy() + + const mutations = findAllIncrementalSnapshots(segment, IncrementalSource.Mutation) as Array<{ + data: BrowserMutationData + }> + expect(mutations.length).toBe(1) + + const mutationPayloadValidator = createMutationPayloadValidator(fullSnapshot.data.node) + return { + ...mutationPayloadValidator, + validate: (expected: ExpectedMutationsPayload) => mutationPayloadValidator.validate(mutations[0].data, expected), + } +} diff --git a/packages/rum/test/utils.ts b/packages/rum/test/utils.ts index 903f0c0f0f..aa6a66e33f 100644 --- a/packages/rum/test/utils.ts +++ b/packages/rum/test/utils.ts @@ -5,8 +5,6 @@ import type { ShadowRootsController } from '../src/domain/record' import type { BrowserFullSnapshotRecord, BrowserIncrementalSnapshotRecord, - BrowserMutationPayload, - BrowserMutationData, BrowserSegment, ElementNode, FrustrationRecord, @@ -17,7 +15,6 @@ import type { MouseInteractionType, VisualViewportRecord, BrowserRecord, - DocumentFragmentNode, } from '../src/types' import { RecordType, IncrementalSource, NodeType } from '../src/types' @@ -127,211 +124,6 @@ function isTextNode(node: SerializedNode): node is TextNode { return node.type === NodeType.Text } -interface NodeSelector { - // Select the first node with the given tag name from the initial full snapshot - tag?: string - // Select the first node with the given id attribute from the initial full snapshot - idAttribute?: string - // Select the first node with the given text content from the initial full snapshot - text?: string -} - -interface ExpectedTextMutation { - // Reference to the node where the mutation happens - node: ExpectedNode - // New text value - value: string -} - -interface ExpectedAttributeMutation { - // Reference to the node where the mutation happens - node: ExpectedNode - // Updated attributes - attributes: { - [key: string]: string | null - } -} - -interface ExpectedRemoveMutation { - // Reference to the removed node - node: ExpectedNode - // Reference to the parent of the removed node - parent: ExpectedNode -} - -interface ExpectedAddMutation { - // Partially check for the added node properties. - node: ExpectedNode - // Reference to the parent of the added node - parent: ExpectedNode - // Reference to the sibling of the added node - next?: ExpectedNode -} - -interface ExpectedMutationsPayload { - texts?: ExpectedTextMutation[] - attributes?: ExpectedAttributeMutation[] - removes?: ExpectedRemoveMutation[] - adds?: ExpectedAddMutation[] -} - -/** - * ExpectedNode is a helper class to build a serialized Node tree to be used to validate mutations. - * For now, its purpose is limited to specifying child nodes. - */ -class ExpectedNode { - constructor(private node: Omit & { childNodes?: ExpectedNode[] }) {} - - withChildren(...childNodes: ExpectedNode[]): ExpectedNode { - return new ExpectedNode({ ...this.node, childNodes }) - } - - getId() { - return this.node.id - } - - toSerializedNodeWithId() { - const { childNodes, ...result } = this.node - if (childNodes) { - ;(result as any).childNodes = childNodes.map((node) => node.toSerializedNodeWithId()) - } - return result as SerializedNodeWithId - } -} - -type Optional = Pick, K> & Omit - -/** - * Based on an serialized initial document, it returns: - * - * * a set of utilities functions to expect nodes by selecting initial nodes from the initial - * document (expectInitialNode) or creating new nodes (expectNewNode) - * - * * a 'validate' function to actually validate a mutation payload against an expected mutation - * object. - */ -export function createMutationPayloadValidator(initialDocument: SerializedNodeWithId) { - let maxNodeId = findMaxNodeId(initialDocument) - - /** - * Creates a new node based on input parameter, with sensible default properties, and an - * automatically computed 'id' attribute based on the previously created nodes. - */ - function expectNewNode(node: Optional): ExpectedNode - function expectNewNode(node: TextNode): ExpectedNode - function expectNewNode(node: Partial): ExpectedNode - function expectNewNode(node: Partial) { - maxNodeId += 1 - if (node.type === NodeType.Element) { - node.attributes ||= {} - node.childNodes = [] - } - return new ExpectedNode({ - ...node, - id: maxNodeId, - } as any) - } - - return { - /** - * Validates the mutation payload against the expected text, attribute, add and remove mutations. - */ - validate: (payload: BrowserMutationPayload, expected: ExpectedMutationsPayload) => { - payload = removeUndefinedValues(payload) - - expect(payload.adds).toEqual( - (expected.adds || []).map(({ node, parent, next }) => ({ - node: node.toSerializedNodeWithId(), - parentId: parent.getId(), - nextId: next ? next.getId() : null, - })) - ) - expect(payload.texts).toEqual((expected.texts || []).map(({ node, value }) => ({ id: node.getId(), value }))) - expect(payload.removes).toEqual( - (expected.removes || []).map(({ node, parent }) => ({ - id: node.getId(), - parentId: parent.getId(), - })) - ) - expect(payload.attributes).toEqual( - (expected.attributes || []).map(({ node, attributes }) => ({ - id: node.getId(), - attributes, - })) - ) - }, - - expectNewNode, - - /** - * Selects a node from the initially serialized document. Nodes can be selected via their 'tag' - * name, 'id' attribute or 'text' content. - */ - expectInitialNode: (selector: NodeSelector) => { - let node - if (selector.text) { - node = findTextNode(initialDocument, selector.text) - } else if (selector.idAttribute) { - node = findElementWithIdAttribute(initialDocument, selector.idAttribute) - } else if (selector.tag) { - node = findElementWithTagName(initialDocument, selector.tag) - } else { - throw new Error('Empty selector') - } - - if (!node) { - throw new Error(`Cannot find node from selector ${JSON.stringify(selector)}`) - } - - if ('childNodes' in node) { - node = { ...node, childNodes: [] } - } - - return new ExpectedNode(removeUndefinedValues(node)) - }, - } - - function findMaxNodeId(root: SerializedNodeWithId): number { - if ('childNodes' in root) { - return Math.max(root.id, ...root.childNodes.map((child) => findMaxNodeId(child))) - } - - return root.id - } - - /** - * When serializing a Node, some properties like 'isSVG' may be undefined, and they are not - * sent to the intake. - * - * To be able to validate mutations from E2E and Unit tests, we prefer to keep a single - * format. Thus, we serialize and deserialize objects to drop undefined - * properties, so they don't interferes during unit tests. - */ - function removeUndefinedValues(object: T) { - return JSON.parse(JSON.stringify(object)) as T - } -} - -/** - * Validate the first and only mutation record of a segment against the expected text, attribute, - * add and remove mutations. - */ -export function createMutationPayloadValidatorFromSegment(segment: BrowserSegment) { - const fullSnapshot = findFullSnapshot(segment)! - expect(fullSnapshot).toBeTruthy() - - const mutations = findAllIncrementalSnapshots(segment, IncrementalSource.Mutation) as Array<{ - data: BrowserMutationData - }> - expect(mutations.length).toBe(1) - - const mutationPayloadValidator = createMutationPayloadValidator(fullSnapshot.data.node) - return { - ...mutationPayloadValidator, - validate: (expected: ExpectedMutationsPayload) => mutationPayloadValidator.validate(mutations[0].data, expected), - } -} - /** * Simplify asserting record lengths across multiple devices when not all record types are supported */ From 6e52f52dc3e518854078464df0cfd7d3ba39b942 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Tue, 28 Mar 2023 17:15:16 +0200 Subject: [PATCH 4/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20extract=20segments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/rum/test/index.ts | 2 + packages/rum/test/mutationPayloadValidator.ts | 9 +- packages/rum/test/nodes.ts | 58 +++++++++ packages/rum/test/segments.ts | 61 +++++++++ packages/rum/test/utils.ts | 121 ------------------ 5 files changed, 123 insertions(+), 128 deletions(-) create mode 100644 packages/rum/test/nodes.ts create mode 100644 packages/rum/test/segments.ts diff --git a/packages/rum/test/index.ts b/packages/rum/test/index.ts index f78cfc9582..19b2e4f540 100644 --- a/packages/rum/test/index.ts +++ b/packages/rum/test/index.ts @@ -1,3 +1,5 @@ export * from './mockWorker' export * from './utils' export * from './mutationPayloadValidator' +export * from './nodes' +export * from './segments' diff --git a/packages/rum/test/mutationPayloadValidator.ts b/packages/rum/test/mutationPayloadValidator.ts index 303b2b8110..f1cdf04a5f 100644 --- a/packages/rum/test/mutationPayloadValidator.ts +++ b/packages/rum/test/mutationPayloadValidator.ts @@ -9,13 +9,8 @@ import type { BrowserSegment, BrowserMutationData, } from '../src/types' -import { - findTextNode, - findElementWithIdAttribute, - findElementWithTagName, - findFullSnapshot, - findAllIncrementalSnapshots, -} from './utils' +import { findAllIncrementalSnapshots, findFullSnapshot } from './segments' +import { findTextNode, findElementWithTagName, findElementWithIdAttribute } from './nodes' interface NodeSelector { // Select the first node with the given tag name from the initial full snapshot diff --git a/packages/rum/test/nodes.ts b/packages/rum/test/nodes.ts new file mode 100644 index 0000000000..e9122bb88b --- /dev/null +++ b/packages/rum/test/nodes.ts @@ -0,0 +1,58 @@ +// Returns the textContent of a ElementNode, if any. +import type { SerializedNodeWithId, ElementNode, SerializedNode, TextNode } from '../src/types' +import { NodeType } from '../src/types' + +export function findTextContent(elem: ElementNode): string | null { + const text = elem.childNodes.find((child) => child.type === NodeType.Text) as TextNode + return text ? text.textContent : null +} + +// Returns the first ElementNode with the given ID attribute contained in a node, if any. +export function findElementWithIdAttribute(root: SerializedNodeWithId, id: string) { + return findElement(root, (node) => node.attributes.id === id) +} + +// Returns the first ElementNode with the given tag name contained in a node, if any. +export function findElementWithTagName(root: SerializedNodeWithId, tagName: string) { + return findElement(root, (node) => node.tagName === tagName) +} + +// Returns the first TextNode with the given content contained in a node, if any. +export function findTextNode(root: SerializedNodeWithId, textContent: string) { + return findNode(root, (node) => isTextNode(node) && node.textContent === textContent) as + | (TextNode & { id: number }) + | null +} + +// Returns the first ElementNode matching the predicate +export function findElement(root: SerializedNodeWithId, predicate: (node: ElementNode) => boolean) { + return findNode(root, (node) => isElementNode(node) && predicate(node)) as (ElementNode & { id: number }) | null +} + +// Returns the first SerializedNodeWithId matching the predicate +export function findNode( + node: SerializedNodeWithId, + predicate: (node: SerializedNodeWithId) => boolean +): SerializedNodeWithId | null { + if (predicate(node)) { + return node + } + + if ('childNodes' in node) { + for (const child of node.childNodes) { + const node = findNode(child, predicate) + if (node !== null) { + return node + } + } + } + return null +} + +function isElementNode(node: SerializedNode): node is ElementNode { + return node.type === NodeType.Element +} + +function isTextNode(node: SerializedNode): node is TextNode { + return node.type === NodeType.Text +} diff --git a/packages/rum/test/segments.ts b/packages/rum/test/segments.ts new file mode 100644 index 0000000000..8bf5ab35e5 --- /dev/null +++ b/packages/rum/test/segments.ts @@ -0,0 +1,61 @@ +// Returns the first MetaRecord in a Segment, if any. +import type { + BrowserSegment, + MetaRecord, + BrowserRecord, + BrowserFullSnapshotRecord, + MouseInteractionType, + BrowserIncrementalSnapshotRecord, + VisualViewportRecord, + FrustrationRecord, +} from '../src/types' +import { RecordType, IncrementalSource } from '../src/types' + +export function findMeta(segment: BrowserSegment): MetaRecord | null { + return segment.records.find((record) => record.type === RecordType.Meta) as MetaRecord +} + +// Returns the first FullSnapshotRecord in a Segment, if any. +export function findFullSnapshot({ records }: { records: BrowserRecord[] }): BrowserFullSnapshotRecord | null { + return records.find((record) => record.type === RecordType.FullSnapshot) as BrowserFullSnapshotRecord +} + +// Returns all the VisualViewportRecords in a Segment, if any. +export function findAllVisualViewports(segment: BrowserSegment): VisualViewportRecord[] { + return segment.records.filter((record) => record.type === RecordType.VisualViewport) as VisualViewportRecord[] +} + +// Returns the first IncrementalSnapshotRecord of a given source in a Segment, if any. +export function findIncrementalSnapshot( + segment: BrowserSegment, + source: IncrementalSource +): BrowserIncrementalSnapshotRecord | null { + return segment.records.find( + (record) => record.type === RecordType.IncrementalSnapshot && record.data.source === source + ) as BrowserIncrementalSnapshotRecord +} + +// Returns all the IncrementalSnapshotRecord of a given source in a Segment, if any. +export function findAllIncrementalSnapshots( + segment: BrowserSegment, + source: IncrementalSource +): BrowserIncrementalSnapshotRecord[] { + return segment.records.filter( + (record) => record.type === RecordType.IncrementalSnapshot && record.data.source === source + ) as BrowserIncrementalSnapshotRecord[] +} + +// Returns all the FrustrationRecords in the given Segment, if any. +export function findAllFrustrationRecords(segment: BrowserSegment): FrustrationRecord[] { + return segment.records.filter((record) => record.type === RecordType.FrustrationRecord) as FrustrationRecord[] +} + +// Returns all the IncrementalSnapshotRecords of the given MouseInteraction source, if any +export function findMouseInteractionRecords( + segment: BrowserSegment, + source: MouseInteractionType +): BrowserIncrementalSnapshotRecord[] { + return findAllIncrementalSnapshots(segment, IncrementalSource.MouseInteraction).filter( + (record) => 'type' in record.data && record.data.type === source + ) +} diff --git a/packages/rum/test/utils.ts b/packages/rum/test/utils.ts index aa6a66e33f..b45aa8c266 100644 --- a/packages/rum/test/utils.ts +++ b/packages/rum/test/utils.ts @@ -2,127 +2,6 @@ import { noop } from '@datadog/browser-core' import type { RumConfiguration } from '@datadog/browser-rum-core' import { NodePrivacyLevel } from '../src/constants' import type { ShadowRootsController } from '../src/domain/record' -import type { - BrowserFullSnapshotRecord, - BrowserIncrementalSnapshotRecord, - BrowserSegment, - ElementNode, - FrustrationRecord, - SerializedNode, - SerializedNodeWithId, - TextNode, - MetaRecord, - MouseInteractionType, - VisualViewportRecord, - BrowserRecord, -} from '../src/types' -import { RecordType, IncrementalSource, NodeType } from '../src/types' - -// Returns the first MetaRecord in a Segment, if any. -export function findMeta(segment: BrowserSegment): MetaRecord | null { - return segment.records.find((record) => record.type === RecordType.Meta) as MetaRecord -} - -// Returns the first FullSnapshotRecord in a Segment, if any. -export function findFullSnapshot({ records }: { records: BrowserRecord[] }): BrowserFullSnapshotRecord | null { - return records.find((record) => record.type === RecordType.FullSnapshot) as BrowserFullSnapshotRecord -} - -// Returns all the VisualViewportRecords in a Segment, if any. -export function findAllVisualViewports(segment: BrowserSegment): VisualViewportRecord[] { - return segment.records.filter((record) => record.type === RecordType.VisualViewport) as VisualViewportRecord[] -} - -// Returns the first IncrementalSnapshotRecord of a given source in a Segment, if any. -export function findIncrementalSnapshot( - segment: BrowserSegment, - source: IncrementalSource -): BrowserIncrementalSnapshotRecord | null { - return segment.records.find( - (record) => record.type === RecordType.IncrementalSnapshot && record.data.source === source - ) as BrowserIncrementalSnapshotRecord -} - -// Returns all the IncrementalSnapshotRecord of a given source in a Segment, if any. -export function findAllIncrementalSnapshots( - segment: BrowserSegment, - source: IncrementalSource -): BrowserIncrementalSnapshotRecord[] { - return segment.records.filter( - (record) => record.type === RecordType.IncrementalSnapshot && record.data.source === source - ) as BrowserIncrementalSnapshotRecord[] -} - -// Returns all the FrustrationRecords in the given Segment, if any. -export function findAllFrustrationRecords(segment: BrowserSegment): FrustrationRecord[] { - return segment.records.filter((record) => record.type === RecordType.FrustrationRecord) as FrustrationRecord[] -} - -// Returns all the IncrementalSnapshotRecords of the given MouseInteraction source, if any -export function findMouseInteractionRecords( - segment: BrowserSegment, - source: MouseInteractionType -): BrowserIncrementalSnapshotRecord[] { - return findAllIncrementalSnapshots(segment, IncrementalSource.MouseInteraction).filter( - (record) => 'type' in record.data && record.data.type === source - ) -} - -// Returns the textContent of a ElementNode, if any. -export function findTextContent(elem: ElementNode): string | null { - const text = elem.childNodes.find((child) => child.type === NodeType.Text) as TextNode - return text ? text.textContent : null -} - -// Returns the first ElementNode with the given ID attribute contained in a node, if any. -export function findElementWithIdAttribute(root: SerializedNodeWithId, id: string) { - return findElement(root, (node) => node.attributes.id === id) -} - -// Returns the first ElementNode with the given tag name contained in a node, if any. -export function findElementWithTagName(root: SerializedNodeWithId, tagName: string) { - return findElement(root, (node) => node.tagName === tagName) -} - -// Returns the first TextNode with the given content contained in a node, if any. -export function findTextNode(root: SerializedNodeWithId, textContent: string) { - return findNode(root, (node) => isTextNode(node) && node.textContent === textContent) as - | (TextNode & { id: number }) - | null -} - -// Returns the first ElementNode matching the predicate -export function findElement(root: SerializedNodeWithId, predicate: (node: ElementNode) => boolean) { - return findNode(root, (node) => isElementNode(node) && predicate(node)) as (ElementNode & { id: number }) | null -} - -// Returns the first SerializedNodeWithId matching the predicate -export function findNode( - node: SerializedNodeWithId, - predicate: (node: SerializedNodeWithId) => boolean -): SerializedNodeWithId | null { - if (predicate(node)) { - return node - } - - if ('childNodes' in node) { - for (const child of node.childNodes) { - const node = findNode(child, predicate) - if (node !== null) { - return node - } - } - } - return null -} - -function isElementNode(node: SerializedNode): node is ElementNode { - return node.type === NodeType.Element -} - -function isTextNode(node: SerializedNode): node is TextNode { - return node.type === NodeType.Text -} /** * Simplify asserting record lengths across multiple devices when not all record types are supported From 7cafdc145ec3a5764b4813fbc53b16889ac3d953 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Tue, 28 Mar 2023 17:20:44 +0200 Subject: [PATCH 5/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20extract=20observers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/rum/src/domain/record/observers/index.ts | 2 ++ .../domain/record/observers/inputObserver.spec.ts | 2 +- .../observers/mouseInteractionObserver.spec.ts | 2 +- .../domain/record/observers/moveObserver.spec.ts | 2 +- .../record/observers/mutationObserver.spec.ts | 3 ++- .../record/observers/observers.specHelper.ts | 12 ++++++++++++ .../record/observers/styleSheetObserver.spec.ts | 2 +- packages/rum/test/utils.ts | 14 -------------- 8 files changed, 20 insertions(+), 19 deletions(-) create mode 100644 packages/rum/src/domain/record/observers/observers.specHelper.ts diff --git a/packages/rum/src/domain/record/observers/index.ts b/packages/rum/src/domain/record/observers/index.ts index 55b40d3b14..e030b69af7 100644 --- a/packages/rum/src/domain/record/observers/index.ts +++ b/packages/rum/src/domain/record/observers/index.ts @@ -1,3 +1,5 @@ export { initObservers } from './observers' export { InputCallback, initInputObserver } from './inputObserver' export { initMutationObserver, MutationCallBack, RumMutationRecord } from './mutationObserver' +export { DEFAULT_CONFIGURATION } from './observers.specHelper' +export { DEFAULT_SHADOW_ROOT_CONTROLLER } from './observers.specHelper' diff --git a/packages/rum/src/domain/record/observers/inputObserver.spec.ts b/packages/rum/src/domain/record/observers/inputObserver.spec.ts index 5aeb317321..6729a6d8de 100644 --- a/packages/rum/src/domain/record/observers/inputObserver.spec.ts +++ b/packages/rum/src/domain/record/observers/inputObserver.spec.ts @@ -3,9 +3,9 @@ import { createNewEvent } from '@datadog/browser-core/test' import { PRIVACY_ATTR_NAME, PRIVACY_ATTR_VALUE_MASK_USER_INPUT } from '../../../constants' import { serializeDocument, SerializationContextStatus } from '../serialization' import { createElementsScrollPositions } from '../elementsScrollPositions' -import { DEFAULT_CONFIGURATION, DEFAULT_SHADOW_ROOT_CONTROLLER } from '../../../../test' import type { InputCallback } from './inputObserver' import { initInputObserver } from './inputObserver' +import { DEFAULT_CONFIGURATION, DEFAULT_SHADOW_ROOT_CONTROLLER } from './observers.specHelper' describe('initInputObserver', () => { let stopInputObserver: () => void diff --git a/packages/rum/src/domain/record/observers/mouseInteractionObserver.spec.ts b/packages/rum/src/domain/record/observers/mouseInteractionObserver.spec.ts index f876387b7d..22cca63a57 100644 --- a/packages/rum/src/domain/record/observers/mouseInteractionObserver.spec.ts +++ b/packages/rum/src/domain/record/observers/mouseInteractionObserver.spec.ts @@ -3,11 +3,11 @@ import { createNewEvent } from '@datadog/browser-core/test' import { IncrementalSource, MouseInteractionType, RecordType } from '../../../types' import { serializeDocument, SerializationContextStatus } from '../serialization' import { createElementsScrollPositions } from '../elementsScrollPositions' -import { DEFAULT_CONFIGURATION, DEFAULT_SHADOW_ROOT_CONTROLLER } from '../../../../test' import type { MouseInteractionCallBack } from './mouseInteractionObserver' import { initMouseInteractionObserver } from './mouseInteractionObserver' import type { RecordIds } from './recordIds' import { initRecordIds } from './recordIds' +import { DEFAULT_CONFIGURATION, DEFAULT_SHADOW_ROOT_CONTROLLER } from './observers.specHelper' describe('initMouseInteractionObserver', () => { let mouseInteractionCallbackSpy: jasmine.Spy diff --git a/packages/rum/src/domain/record/observers/moveObserver.spec.ts b/packages/rum/src/domain/record/observers/moveObserver.spec.ts index 18c463c634..d1740cb523 100644 --- a/packages/rum/src/domain/record/observers/moveObserver.spec.ts +++ b/packages/rum/src/domain/record/observers/moveObserver.spec.ts @@ -3,9 +3,9 @@ import { createNewEvent } from '@datadog/browser-core/test' import { SerializationContextStatus, serializeDocument } from '../serialization' import { createElementsScrollPositions } from '../elementsScrollPositions' import { IncrementalSource } from '../../../types' -import { DEFAULT_CONFIGURATION, DEFAULT_SHADOW_ROOT_CONTROLLER } from '../../../../test' import type { MousemoveCallBack } from './moveObserver' import { initMoveObserver } from './moveObserver' +import { DEFAULT_CONFIGURATION, DEFAULT_SHADOW_ROOT_CONTROLLER } from './observers.specHelper' describe('initMoveObserver', () => { let mouseMoveCallbackSpy: jasmine.Spy diff --git a/packages/rum/src/domain/record/observers/mutationObserver.spec.ts b/packages/rum/src/domain/record/observers/mutationObserver.spec.ts index 54026c8d05..ab3e21bce9 100644 --- a/packages/rum/src/domain/record/observers/mutationObserver.spec.ts +++ b/packages/rum/src/domain/record/observers/mutationObserver.spec.ts @@ -1,7 +1,7 @@ import { DefaultPrivacyLevel, isIE } from '@datadog/browser-core' import type { RumConfiguration } from '@datadog/browser-rum-core' import { collectAsyncCalls } from '@datadog/browser-core/test' -import { createMutationPayloadValidator, DEFAULT_SHADOW_ROOT_CONTROLLER } from '../../../../test' +import { createMutationPayloadValidator } from '../../../../test' import { NodePrivacyLevel, PRIVACY_ATTR_NAME, @@ -16,6 +16,7 @@ import { createElementsScrollPositions } from '../elementsScrollPositions' import type { ShadowRootCallBack } from '../shadowRootsController' import { sortAddedAndMovedNodes, initMutationObserver } from './mutationObserver' import type { MutationCallBack } from './mutationObserver' +import { DEFAULT_SHADOW_ROOT_CONTROLLER } from './observers.specHelper' describe('startMutationCollection', () => { let sandbox: HTMLElement diff --git a/packages/rum/src/domain/record/observers/observers.specHelper.ts b/packages/rum/src/domain/record/observers/observers.specHelper.ts new file mode 100644 index 0000000000..c51f934bf7 --- /dev/null +++ b/packages/rum/src/domain/record/observers/observers.specHelper.ts @@ -0,0 +1,12 @@ +import { noop } from '@datadog/browser-core' +import type { RumConfiguration } from '@datadog/browser-rum-core' +import type { ShadowRootsController } from '../shadowRootsController' +import { NodePrivacyLevel } from '../../../constants' + +export const DEFAULT_SHADOW_ROOT_CONTROLLER: ShadowRootsController = { + flush: noop, + stop: noop, + addShadowRoot: noop, + removeShadowRoot: noop, +} +export const DEFAULT_CONFIGURATION = { defaultPrivacyLevel: NodePrivacyLevel.ALLOW } as RumConfiguration diff --git a/packages/rum/src/domain/record/observers/styleSheetObserver.spec.ts b/packages/rum/src/domain/record/observers/styleSheetObserver.spec.ts index 751beeee34..dfc632633c 100644 --- a/packages/rum/src/domain/record/observers/styleSheetObserver.spec.ts +++ b/packages/rum/src/domain/record/observers/styleSheetObserver.spec.ts @@ -1,10 +1,10 @@ import { isIE } from '@datadog/browser-core' import { isFirefox } from '@datadog/browser-core/test' -import { DEFAULT_CONFIGURATION, DEFAULT_SHADOW_ROOT_CONTROLLER } from '../../../../test' import { serializeDocument, SerializationContextStatus } from '../serialization' import { createElementsScrollPositions } from '../elementsScrollPositions' import type { StyleSheetCallback } from './styleSheetObserver' import { initStyleSheetObserver, getPathToNestedCSSRule } from './styleSheetObserver' +import { DEFAULT_CONFIGURATION, DEFAULT_SHADOW_ROOT_CONTROLLER } from './observers.specHelper' describe('initStyleSheetObserver', () => { let stopStyleSheetObserver: () => void diff --git a/packages/rum/test/utils.ts b/packages/rum/test/utils.ts index b45aa8c266..3a77d9bf3e 100644 --- a/packages/rum/test/utils.ts +++ b/packages/rum/test/utils.ts @@ -1,20 +1,6 @@ -import { noop } from '@datadog/browser-core' -import type { RumConfiguration } from '@datadog/browser-rum-core' -import { NodePrivacyLevel } from '../src/constants' -import type { ShadowRootsController } from '../src/domain/record' - /** * Simplify asserting record lengths across multiple devices when not all record types are supported */ export const recordsPerFullSnapshot = () => // Meta, Focus, FullSnapshot, VisualViewport (support limited) window.visualViewport ? 4 : 3 - -export const DEFAULT_SHADOW_ROOT_CONTROLLER: ShadowRootsController = { - flush: noop, - stop: noop, - addShadowRoot: noop, - removeShadowRoot: noop, -} - -export const DEFAULT_CONFIGURATION = { defaultPrivacyLevel: NodePrivacyLevel.ALLOW } as RumConfiguration From 882ec36211486d2cb5663e2e7949211962b32f77 Mon Sep 17 00:00:00 2001 From: Bastien Caudan Date: Tue, 28 Mar 2023 17:24:41 +0200 Subject: [PATCH 6/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20rename=20utils=20in=20?= =?UTF-8?q?recordsPerFullSnapshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/rum/test/index.ts | 2 +- packages/rum/test/{utils.ts => recordsPerFullSnapshot.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/rum/test/{utils.ts => recordsPerFullSnapshot.ts} (100%) diff --git a/packages/rum/test/index.ts b/packages/rum/test/index.ts index 19b2e4f540..9290c438f3 100644 --- a/packages/rum/test/index.ts +++ b/packages/rum/test/index.ts @@ -1,5 +1,5 @@ export * from './mockWorker' -export * from './utils' +export * from './recordsPerFullSnapshot' export * from './mutationPayloadValidator' export * from './nodes' export * from './segments' diff --git a/packages/rum/test/utils.ts b/packages/rum/test/recordsPerFullSnapshot.ts similarity index 100% rename from packages/rum/test/utils.ts rename to packages/rum/test/recordsPerFullSnapshot.ts