From d8afa8d2a75c6b242f493f0747f013fdc857f2eb Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Thu, 23 Nov 2023 19:31:21 +0000 Subject: [PATCH] feat: add ee licensed replay transformer --- .github/workflows/ci-frontend.yml | 6 +- ee/frontend/exports.ts | 5 + ee/frontend/mobile-replay/index.ts | 63 ++ ee/frontend/mobile-replay/mobile.types.ts | 150 +++ .../schema/mobile/rr-mobile-schema.json | 272 +++++ .../schema/web/rr-web-schema.json | 951 ++++++++++++++++++ ee/frontend/mobile-replay/transform.test.ts | 37 + ee/frontend/mobile-replay/transformers.ts | 209 ++++ ee/frontend/mobile-replay/wireframeStyle.ts | 76 ++ frontend/@posthog/ee/types.ts | 4 + frontend/src/lib/ee.test.ts | 2 + .../__snapshots__/transform.test.ts.snap | 9 + frontend/src/lib/replay/transform.test.ts | 193 ++++ package.json | 6 +- pnpm-lock.yaml | 27 +- 15 files changed, 1994 insertions(+), 16 deletions(-) create mode 100644 ee/frontend/mobile-replay/index.ts create mode 100644 ee/frontend/mobile-replay/mobile.types.ts create mode 100644 ee/frontend/mobile-replay/schema/mobile/rr-mobile-schema.json create mode 100644 ee/frontend/mobile-replay/schema/web/rr-web-schema.json create mode 100644 ee/frontend/mobile-replay/transform.test.ts create mode 100644 ee/frontend/mobile-replay/transformers.ts create mode 100644 ee/frontend/mobile-replay/wireframeStyle.ts create mode 100644 frontend/src/lib/replay/__snapshots__/transform.test.ts.snap create mode 100644 frontend/src/lib/replay/transform.test.ts diff --git a/.github/workflows/ci-frontend.yml b/.github/workflows/ci-frontend.yml index c586598152fd6..64b4094fa45b9 100644 --- a/.github/workflows/ci-frontend.yml +++ b/.github/workflows/ci-frontend.yml @@ -35,7 +35,7 @@ jobs: # NOTE: we are at risk of missing a dependency here. - 'bin/**' - 'frontend/**' - - 'ee/frontend/**' + - 'ee/frontend/**' # Make sure we run if someone is explicitly change the workflow - .github/workflows/ci-frontend.yml # various JS config files @@ -107,6 +107,10 @@ jobs: if: needs.changes.outputs.frontend == 'true' run: pnpm schema:build:json && git diff --exit-code + - name: Check if mobile replay "schema.json" is up to date + if: needs.changes.outputs.frontend == 'true' + run: pnpm mobile-replay:schema:build:json && git diff --exit-code + - name: Check toolbar bundle size if: needs.changes.outputs.frontend == 'true' uses: preactjs/compressed-size-action@v2 diff --git a/ee/frontend/exports.ts b/ee/frontend/exports.ts index 29d80016d730c..fbdd40658cf69 100644 --- a/ee/frontend/exports.ts +++ b/ee/frontend/exports.ts @@ -1,5 +1,7 @@ import { PostHogEE } from '@posthog/ee/types' +import { transformToWeb } from './mobile-replay' + const myTestCode = (): void => { // eslint-disable-next-line no-console console.log('it works!') @@ -8,6 +10,9 @@ const myTestCode = (): void => { const postHogEE: PostHogEE = { enabled: true, myTestCode, + mobileReplay: { + transformToWeb, + }, } export default postHogEE diff --git a/ee/frontend/mobile-replay/index.ts b/ee/frontend/mobile-replay/index.ts new file mode 100644 index 0000000000000..b27adc515d26c --- /dev/null +++ b/ee/frontend/mobile-replay/index.ts @@ -0,0 +1,63 @@ +import { eventWithTime } from '@rrweb/types' +import Ajv, { ErrorObject } from 'ajv' + +import mobileSchema from './schema/mobile/rr-mobile-schema.json' +import webSchema from './schema/web/rr-web-schema.json' +import { makeFullEvent, makeMetaEvent } from './transformers' + +const ajv = new Ajv() // options can be passed, e.g. {allErrors: true} + +const transformers: Record eventWithTime> = { + 4: makeMetaEvent, + 10: makeFullEvent, +} + +const mobileSchemaValidator = ajv.compile(mobileSchema) + +export function validateFromMobile(data: unknown): { + isValid: boolean + errors: ErrorObject[] | null | undefined +} { + const isValid = mobileSchemaValidator(data) + return { + isValid, + errors: isValid ? null : mobileSchemaValidator.errors, + } +} + +const webSchemaValidator = ajv.compile(webSchema) + +export class TransformationError implements Error { + name = 'TransformationError' + message = 'Failed to transform to web schema' + errors: ErrorObject, unknown>[] | null | undefined + + constructor(_errors: ErrorObject, unknown>[] | null | undefined) { + this.errors = _errors + } +} + +export function transformToWeb(mobileData: any[]): string { + const response = mobileData.reduce((acc, event) => { + const transformer = transformers[event.type] + if (!transformer) { + console.warn(`No transformer for event type ${event.type}`) + } else { + const transformed = transformer(event) + validateAgainstWebSchema(transformed) + acc.push(transformed) + } + return acc + }, []) + + return JSON.stringify(response) +} + +export function validateAgainstWebSchema(data: unknown): boolean { + const validationResult = webSchemaValidator(data) + if (!validationResult) { + console.error(webSchemaValidator.errors) + throw new TransformationError(webSchemaValidator.errors) + } + return validationResult +} diff --git a/ee/frontend/mobile-replay/mobile.types.ts b/ee/frontend/mobile-replay/mobile.types.ts new file mode 100644 index 0000000000000..9af94df398194 --- /dev/null +++ b/ee/frontend/mobile-replay/mobile.types.ts @@ -0,0 +1,150 @@ +// copied from rrweb-snapshot, not included in rrweb types +import { customEvent, EventType } from '@rrweb/types' + +export enum NodeType { + Document = 0, + DocumentType = 1, + Element = 2, + Text = 3, + CDATA = 4, + Comment = 5, +} + +export type documentNode = { + type: NodeType.Document + childNodes: serializedNodeWithId[] + compatMode?: string +} + +export type documentTypeNode = { + type: NodeType.DocumentType + name: string + publicId: string + systemId: string +} + +export type attributes = { + [key: string]: string | number | true | null +} + +export type elementNode = { + type: NodeType.Element + tagName: string + attributes: attributes + childNodes: serializedNodeWithId[] + isSVG?: true + needBlock?: boolean + // This is a custom element or not. + isCustom?: true +} + +export type textNode = { + type: NodeType.Text + textContent: string + isStyle?: true +} + +export type cdataNode = { + type: NodeType.CDATA + textContent: '' +} + +export type commentNode = { + type: NodeType.Comment + textContent: string +} + +export type serializedNode = (documentNode | documentTypeNode | elementNode | textNode | cdataNode | commentNode) & { + rootId?: number + isShadowHost?: boolean + isShadow?: boolean +} + +export type serializedNodeWithId = serializedNode & { id: number } + +// end copied section + +export type MobileNodeType = 'text' | 'image' | 'rectangle' + +export type MobileStyles = { + color?: string + backgroundColor?: string + /** + * @description if borderWidth is present, then border style is assumed to be solid + */ + borderWidth?: string | number + /** + * @description if borderRadius is present, then border style is assumed to be solid + */ + borderRadius?: string | number + /** + * @description if borderColor is present, then border style is assumed to be solid + */ + borderColor?: string +} + +type wireframeBase = { + id: number + /** + * @description x and y are the top left corner of the element, if they are present then the element is absolutely positioned + */ + x: number + y: number + width: number + height: number + childWireframes?: wireframe[] + type: MobileNodeType + style?: MobileStyles +} + +export type wireframeText = wireframeBase & { + type: 'text' + text: string +} + +export type wireframeImage = wireframeBase & { + type: 'image' + /** + * @description this will be used as base64 encoded image source, with no other attributes it is assumed to be a PNG + */ + base64: string +} + +export type wireframeRectangle = wireframeBase & { + type: 'rectangle' +} + +export type wireframe = wireframeText | wireframeImage | wireframeRectangle + +// the rrweb full snapshot event type, but it contains wireframes not html +export type fullSnapshotEvent = { + type: EventType.FullSnapshot + data: { + /** + * @description This mimics the RRWeb full snapshot event type, except instead of reporting a serialized DOM it reports a wireframe representation of the screen. + */ + wireframes: wireframe[] + initialOffset: { + top: number + left: number + } + } +} + +export type metaEvent = { + type: EventType.Meta + data: { + /** + * @description This mimics the RRWeb meta event type, except does not report href. + */ + width: number + height: number + } +} + +export type mobileEvent = fullSnapshotEvent | metaEvent | customEvent + +export type mobileEventWithTime = mobileEvent & { + timestamp: number + delay?: number +} diff --git a/ee/frontend/mobile-replay/schema/mobile/rr-mobile-schema.json b/ee/frontend/mobile-replay/schema/mobile/rr-mobile-schema.json new file mode 100644 index 0000000000000..f649fc46388b9 --- /dev/null +++ b/ee/frontend/mobile-replay/schema/mobile/rr-mobile-schema.json @@ -0,0 +1,272 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "initialOffset": { + "additionalProperties": false, + "properties": { + "left": { + "type": "number" + }, + "top": { + "type": "number" + } + }, + "required": ["top", "left"], + "type": "object" + }, + "wireframes": { + "description": "This mimics the RRWeb full snapshot event type, except instead of reporting a serialized DOM it reports a wireframe representation of the screen.", + "items": { + "$ref": "#/definitions/wireframe" + }, + "type": "array" + } + }, + "required": ["wireframes", "initialOffset"], + "type": "object" + }, + "delay": { + "type": "number" + }, + "timestamp": { + "type": "number" + }, + "type": { + "$ref": "#/definitions/EventType.FullSnapshot" + } + }, + "required": ["data", "timestamp", "type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "height": { + "type": "number" + }, + "width": { + "description": "This mimics the RRWeb meta event type, except does not report href.", + "type": "number" + } + }, + "required": ["width", "height"], + "type": "object" + }, + "delay": { + "type": "number" + }, + "timestamp": { + "type": "number" + }, + "type": { + "$ref": "#/definitions/EventType.Meta" + } + }, + "required": ["data", "timestamp", "type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "payload": {}, + "tag": { + "type": "string" + } + }, + "required": ["tag", "payload"], + "type": "object" + }, + "delay": { + "type": "number" + }, + "timestamp": { + "type": "number" + }, + "type": { + "$ref": "#/definitions/EventType.Custom" + } + }, + "required": ["data", "timestamp", "type"], + "type": "object" + } + ], + "definitions": { + "EventType.Custom": { + "const": 5, + "type": "number" + }, + "EventType.FullSnapshot": { + "const": 2, + "type": "number" + }, + "EventType.Meta": { + "const": 4, + "type": "number" + }, + "MobileNodeType": { + "enum": ["text", "image", "rectangle"], + "type": "string" + }, + "MobileStyles": { + "additionalProperties": false, + "properties": { + "backgroundColor": { + "type": "string" + }, + "borderColor": { + "description": "if borderColor is present, then border style is assumed to be solid", + "type": "string" + }, + "borderRadius": { + "description": "if borderRadius is present, then border style is assumed to be solid", + "type": ["string", "number"] + }, + "borderWidth": { + "description": "if borderWidth is present, then border style is assumed to be solid", + "type": ["string", "number"] + }, + "color": { + "type": "string" + } + }, + "type": "object" + }, + "wireframe": { + "anyOf": [ + { + "$ref": "#/definitions/wireframeText" + }, + { + "$ref": "#/definitions/wireframeImage" + }, + { + "$ref": "#/definitions/wireframeRectangle" + } + ] + }, + "wireframeImage": { + "additionalProperties": false, + "properties": { + "base64": { + "description": "this will be used as base64 encoded image source, with no other attributes it is assumed to be a PNG", + "type": "string" + }, + "childWireframes": { + "items": { + "$ref": "#/definitions/wireframe" + }, + "type": "array" + }, + "height": { + "type": "number" + }, + "id": { + "type": "number" + }, + "style": { + "$ref": "#/definitions/MobileStyles" + }, + "type": { + "$ref": "#/definitions/MobileNodeType" + }, + "width": { + "type": "number" + }, + "x": { + "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned", + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": ["base64", "height", "id", "type", "width", "x", "y"], + "type": "object" + }, + "wireframeRectangle": { + "additionalProperties": false, + "properties": { + "childWireframes": { + "items": { + "$ref": "#/definitions/wireframe" + }, + "type": "array" + }, + "height": { + "type": "number" + }, + "id": { + "type": "number" + }, + "style": { + "$ref": "#/definitions/MobileStyles" + }, + "type": { + "$ref": "#/definitions/MobileNodeType" + }, + "width": { + "type": "number" + }, + "x": { + "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned", + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": ["height", "id", "type", "width", "x", "y"], + "type": "object" + }, + "wireframeText": { + "additionalProperties": false, + "properties": { + "childWireframes": { + "items": { + "$ref": "#/definitions/wireframe" + }, + "type": "array" + }, + "height": { + "type": "number" + }, + "id": { + "type": "number" + }, + "style": { + "$ref": "#/definitions/MobileStyles" + }, + "text": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/MobileNodeType" + }, + "width": { + "type": "number" + }, + "x": { + "description": "x and y are the top left corner of the element, if they are present then the element is absolutely positioned", + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": ["height", "id", "text", "type", "width", "x", "y"], + "type": "object" + } + } +} diff --git a/ee/frontend/mobile-replay/schema/web/rr-web-schema.json b/ee/frontend/mobile-replay/schema/web/rr-web-schema.json new file mode 100644 index 0000000000000..26f0f34428b0f --- /dev/null +++ b/ee/frontend/mobile-replay/schema/web/rr-web-schema.json @@ -0,0 +1,951 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "data": {}, + "delay": { + "type": "number" + }, + "timestamp": { + "type": "number" + }, + "type": { + "$ref": "#/definitions/EventType.DomContentLoaded" + } + }, + "required": ["data", "timestamp", "type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": {}, + "delay": { + "type": "number" + }, + "timestamp": { + "type": "number" + }, + "type": { + "$ref": "#/definitions/EventType.Load" + } + }, + "required": ["data", "timestamp", "type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "initialOffset": { + "additionalProperties": false, + "properties": { + "left": { + "type": "number" + }, + "top": { + "type": "number" + } + }, + "required": ["top", "left"], + "type": "object" + }, + "node": {} + }, + "required": ["node", "initialOffset"], + "type": "object" + }, + "delay": { + "type": "number" + }, + "timestamp": { + "type": "number" + }, + "type": { + "$ref": "#/definitions/EventType.FullSnapshot" + } + }, + "required": ["data", "timestamp", "type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/definitions/incrementalData" + }, + "delay": { + "type": "number" + }, + "timestamp": { + "type": "number" + }, + "type": { + "$ref": "#/definitions/EventType.IncrementalSnapshot" + } + }, + "required": ["data", "timestamp", "type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "height": { + "type": "number" + }, + "href": { + "type": "string" + }, + "width": { + "type": "number" + } + }, + "required": ["href", "width", "height"], + "type": "object" + }, + "delay": { + "type": "number" + }, + "timestamp": { + "type": "number" + }, + "type": { + "$ref": "#/definitions/EventType.Meta" + } + }, + "required": ["data", "timestamp", "type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "payload": {}, + "tag": { + "type": "string" + } + }, + "required": ["tag", "payload"], + "type": "object" + }, + "delay": { + "type": "number" + }, + "timestamp": { + "type": "number" + }, + "type": { + "$ref": "#/definitions/EventType.Custom" + } + }, + "required": ["data", "timestamp", "type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "payload": {}, + "plugin": { + "type": "string" + } + }, + "required": ["plugin", "payload"], + "type": "object" + }, + "delay": { + "type": "number" + }, + "timestamp": { + "type": "number" + }, + "type": { + "$ref": "#/definitions/EventType.Plugin" + } + }, + "required": ["data", "timestamp", "type"], + "type": "object" + } + ], + "definitions": { + "CanvasContext": { + "enum": [0, 1, 2], + "type": "number" + }, + "EventType.Custom": { + "const": 5, + "type": "number" + }, + "EventType.DomContentLoaded": { + "const": 0, + "type": "number" + }, + "EventType.FullSnapshot": { + "const": 2, + "type": "number" + }, + "EventType.IncrementalSnapshot": { + "const": 3, + "type": "number" + }, + "EventType.Load": { + "const": 1, + "type": "number" + }, + "EventType.Meta": { + "const": 4, + "type": "number" + }, + "EventType.Plugin": { + "const": 6, + "type": "number" + }, + "FontDisplay": { + "enum": ["auto", "block", "fallback", "optional", "swap"], + "type": "string" + }, + "FontFaceDescriptors": { + "additionalProperties": false, + "properties": { + "ascentOverride": { + "type": "string" + }, + "descentOverride": { + "type": "string" + }, + "display": { + "$ref": "#/definitions/FontDisplay" + }, + "featureSettings": { + "type": "string" + }, + "lineGapOverride": { + "type": "string" + }, + "stretch": { + "type": "string" + }, + "style": { + "type": "string" + }, + "unicodeRange": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "weight": { + "type": "string" + } + }, + "type": "object" + }, + "IncrementalSource.AdoptedStyleSheet": { + "const": 15, + "type": "number" + }, + "IncrementalSource.CanvasMutation": { + "const": 9, + "type": "number" + }, + "IncrementalSource.Drag": { + "const": 12, + "type": "number" + }, + "IncrementalSource.Font": { + "const": 10, + "type": "number" + }, + "IncrementalSource.Input": { + "const": 5, + "type": "number" + }, + "IncrementalSource.MediaInteraction": { + "const": 7, + "type": "number" + }, + "IncrementalSource.MouseInteraction": { + "const": 2, + "type": "number" + }, + "IncrementalSource.MouseMove": { + "const": 1, + "type": "number" + }, + "IncrementalSource.Mutation": { + "const": 0, + "type": "number" + }, + "IncrementalSource.Scroll": { + "const": 3, + "type": "number" + }, + "IncrementalSource.Selection": { + "const": 14, + "type": "number" + }, + "IncrementalSource.StyleDeclaration": { + "const": 13, + "type": "number" + }, + "IncrementalSource.StyleSheetRule": { + "const": 8, + "type": "number" + }, + "IncrementalSource.TouchMove": { + "const": 6, + "type": "number" + }, + "IncrementalSource.ViewportResize": { + "const": 4, + "type": "number" + }, + "MediaInteractions": { + "enum": [0, 1, 2, 3, 4], + "type": "number" + }, + "MouseInteractions": { + "enum": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + "type": "number" + }, + "PointerTypes": { + "enum": [0, 1, 2], + "type": "number" + }, + "SelectionRange": { + "additionalProperties": false, + "properties": { + "end": { + "type": "number" + }, + "endOffset": { + "type": "number" + }, + "start": { + "type": "number" + }, + "startOffset": { + "type": "number" + } + }, + "required": ["start", "startOffset", "end", "endOffset"], + "type": "object" + }, + "addedNodeMutation": { + "additionalProperties": false, + "properties": { + "nextId": { + "type": ["number", "null"] + }, + "node": {}, + "parentId": { + "type": "number" + }, + "previousId": { + "type": ["number", "null"] + } + }, + "required": ["parentId", "nextId", "node"], + "type": "object" + }, + "adoptedStyleSheetData": { + "additionalProperties": false, + "properties": { + "id": { + "type": "number" + }, + "source": { + "$ref": "#/definitions/IncrementalSource.AdoptedStyleSheet" + }, + "styleIds": { + "items": { + "type": "number" + }, + "type": "array" + }, + "styles": { + "items": { + "additionalProperties": false, + "properties": { + "rules": { + "items": { + "$ref": "#/definitions/styleSheetAddRule" + }, + "type": "array" + }, + "styleId": { + "type": "number" + } + }, + "required": ["styleId", "rules"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["id", "source", "styleIds"], + "type": "object" + }, + "attributeMutation": { + "additionalProperties": false, + "properties": { + "attributes": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/styleOMValue" + }, + { + "type": "null" + } + ] + }, + "type": "object" + }, + "id": { + "type": "number" + } + }, + "required": ["id", "attributes"], + "type": "object" + }, + "canvasMutationCommand": { + "additionalProperties": false, + "properties": { + "args": { + "items": {}, + "type": "array" + }, + "property": { + "type": "string" + }, + "setter": { + "const": true, + "type": "boolean" + } + }, + "required": ["property", "args"], + "type": "object" + }, + "canvasMutationData": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "commands": { + "items": { + "$ref": "#/definitions/canvasMutationCommand" + }, + "type": "array" + }, + "id": { + "type": "number" + }, + "source": { + "$ref": "#/definitions/IncrementalSource.CanvasMutation" + }, + "type": { + "$ref": "#/definitions/CanvasContext" + } + }, + "required": ["commands", "id", "source", "type"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "args": { + "items": {}, + "type": "array" + }, + "id": { + "type": "number" + }, + "property": { + "type": "string" + }, + "setter": { + "const": true, + "type": "boolean" + }, + "source": { + "$ref": "#/definitions/IncrementalSource.CanvasMutation" + }, + "type": { + "$ref": "#/definitions/CanvasContext" + } + }, + "required": ["args", "id", "property", "source", "type"], + "type": "object" + } + ] + }, + "fontData": { + "additionalProperties": false, + "properties": { + "buffer": { + "type": "boolean" + }, + "descriptors": { + "$ref": "#/definitions/FontFaceDescriptors" + }, + "family": { + "type": "string" + }, + "fontSource": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/IncrementalSource.Font" + } + }, + "required": ["buffer", "family", "fontSource", "source"], + "type": "object" + }, + "incrementalData": { + "anyOf": [ + { + "$ref": "#/definitions/mutationData" + }, + { + "$ref": "#/definitions/mousemoveData" + }, + { + "$ref": "#/definitions/mouseInteractionData" + }, + { + "$ref": "#/definitions/scrollData" + }, + { + "$ref": "#/definitions/viewportResizeData" + }, + { + "$ref": "#/definitions/inputData" + }, + { + "$ref": "#/definitions/mediaInteractionData" + }, + { + "$ref": "#/definitions/styleSheetRuleData" + }, + { + "$ref": "#/definitions/canvasMutationData" + }, + { + "$ref": "#/definitions/fontData" + }, + { + "$ref": "#/definitions/selectionData" + }, + { + "$ref": "#/definitions/styleDeclarationData" + }, + { + "$ref": "#/definitions/adoptedStyleSheetData" + } + ] + }, + "inputData": { + "additionalProperties": false, + "properties": { + "id": { + "type": "number" + }, + "isChecked": { + "type": "boolean" + }, + "source": { + "$ref": "#/definitions/IncrementalSource.Input" + }, + "text": { + "type": "string" + }, + "userTriggered": { + "type": "boolean" + } + }, + "required": ["id", "isChecked", "source", "text"], + "type": "object" + }, + "mediaInteractionData": { + "additionalProperties": false, + "properties": { + "currentTime": { + "type": "number" + }, + "id": { + "type": "number" + }, + "muted": { + "type": "boolean" + }, + "playbackRate": { + "type": "number" + }, + "source": { + "$ref": "#/definitions/IncrementalSource.MediaInteraction" + }, + "type": { + "$ref": "#/definitions/MediaInteractions" + }, + "volume": { + "type": "number" + } + }, + "required": ["id", "source", "type"], + "type": "object" + }, + "mouseInteractionData": { + "additionalProperties": false, + "properties": { + "id": { + "type": "number" + }, + "pointerType": { + "$ref": "#/definitions/PointerTypes" + }, + "source": { + "$ref": "#/definitions/IncrementalSource.MouseInteraction" + }, + "type": { + "$ref": "#/definitions/MouseInteractions" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": ["id", "source", "type", "x", "y"], + "type": "object" + }, + "mousePosition": { + "additionalProperties": false, + "properties": { + "id": { + "type": "number" + }, + "timeOffset": { + "type": "number" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": ["x", "y", "id", "timeOffset"], + "type": "object" + }, + "mousemoveData": { + "additionalProperties": false, + "properties": { + "positions": { + "items": { + "$ref": "#/definitions/mousePosition" + }, + "type": "array" + }, + "source": { + "anyOf": [ + { + "$ref": "#/definitions/IncrementalSource.MouseMove" + }, + { + "$ref": "#/definitions/IncrementalSource.TouchMove" + }, + { + "$ref": "#/definitions/IncrementalSource.Drag" + } + ] + } + }, + "required": ["source", "positions"], + "type": "object" + }, + "mutationData": { + "additionalProperties": false, + "properties": { + "adds": { + "items": { + "$ref": "#/definitions/addedNodeMutation" + }, + "type": "array" + }, + "attributes": { + "items": { + "$ref": "#/definitions/attributeMutation" + }, + "type": "array" + }, + "isAttachIframe": { + "const": true, + "type": "boolean" + }, + "removes": { + "items": { + "$ref": "#/definitions/removedNodeMutation" + }, + "type": "array" + }, + "source": { + "$ref": "#/definitions/IncrementalSource.Mutation" + }, + "texts": { + "items": { + "$ref": "#/definitions/textMutation" + }, + "type": "array" + } + }, + "required": ["adds", "attributes", "removes", "source", "texts"], + "type": "object" + }, + "removedNodeMutation": { + "additionalProperties": false, + "properties": { + "id": { + "type": "number" + }, + "isShadow": { + "type": "boolean" + }, + "parentId": { + "type": "number" + } + }, + "required": ["parentId", "id"], + "type": "object" + }, + "scrollData": { + "additionalProperties": false, + "properties": { + "id": { + "type": "number" + }, + "source": { + "$ref": "#/definitions/IncrementalSource.Scroll" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": ["id", "source", "x", "y"], + "type": "object" + }, + "selectionData": { + "additionalProperties": false, + "properties": { + "ranges": { + "items": { + "$ref": "#/definitions/SelectionRange" + }, + "type": "array" + }, + "source": { + "$ref": "#/definitions/IncrementalSource.Selection" + } + }, + "required": ["ranges", "source"], + "type": "object" + }, + "styleDeclarationData": { + "additionalProperties": false, + "properties": { + "id": { + "type": "number" + }, + "index": { + "items": { + "type": "number" + }, + "type": "array" + }, + "remove": { + "additionalProperties": false, + "properties": { + "property": { + "type": "string" + } + }, + "required": ["property"], + "type": "object" + }, + "set": { + "additionalProperties": false, + "properties": { + "priority": { + "type": "string" + }, + "property": { + "type": "string" + }, + "value": { + "type": ["string", "null"] + } + }, + "required": ["property", "value"], + "type": "object" + }, + "source": { + "$ref": "#/definitions/IncrementalSource.StyleDeclaration" + }, + "styleId": { + "type": "number" + } + }, + "required": ["index", "source"], + "type": "object" + }, + "styleOMValue": { + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/definitions/styleValueWithPriority" + }, + { + "type": "string" + }, + { + "const": false, + "type": "boolean" + } + ] + }, + "type": "object" + }, + "styleSheetAddRule": { + "additionalProperties": false, + "properties": { + "index": { + "anyOf": [ + { + "type": "number" + }, + { + "items": { + "type": "number" + }, + "type": "array" + } + ] + }, + "rule": { + "type": "string" + } + }, + "required": ["rule"], + "type": "object" + }, + "styleSheetDeleteRule": { + "additionalProperties": false, + "properties": { + "index": { + "anyOf": [ + { + "type": "number" + }, + { + "items": { + "type": "number" + }, + "type": "array" + } + ] + } + }, + "required": ["index"], + "type": "object" + }, + "styleSheetRuleData": { + "additionalProperties": false, + "properties": { + "adds": { + "items": { + "$ref": "#/definitions/styleSheetAddRule" + }, + "type": "array" + }, + "id": { + "type": "number" + }, + "removes": { + "items": { + "$ref": "#/definitions/styleSheetDeleteRule" + }, + "type": "array" + }, + "replace": { + "type": "string" + }, + "replaceSync": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/IncrementalSource.StyleSheetRule" + }, + "styleId": { + "type": "number" + } + }, + "required": ["source"], + "type": "object" + }, + "styleValueWithPriority": { + "items": { + "type": "string" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + "textMutation": { + "additionalProperties": false, + "properties": { + "id": { + "type": "number" + }, + "value": { + "type": ["string", "null"] + } + }, + "required": ["id", "value"], + "type": "object" + }, + "viewportResizeData": { + "additionalProperties": false, + "properties": { + "height": { + "type": "number" + }, + "source": { + "$ref": "#/definitions/IncrementalSource.ViewportResize" + }, + "width": { + "type": "number" + } + }, + "required": ["height", "source", "width"], + "type": "object" + } + } +} diff --git a/ee/frontend/mobile-replay/transform.test.ts b/ee/frontend/mobile-replay/transform.test.ts new file mode 100644 index 0000000000000..2bd9542812f7e --- /dev/null +++ b/ee/frontend/mobile-replay/transform.test.ts @@ -0,0 +1,37 @@ +import { EventType } from '@rrweb/types' + +import { TransformationError, validateAgainstWebSchema, validateFromMobile } from './index' + +describe('validation', () => { + test('example of validating incoming _invalid_ data', () => { + const invalidData = { + foo: 'abc', + bar: 'abc', + } + + expect(validateFromMobile(invalidData).isValid).toBe(false) + }) + + test('example of validating mobile meta event', () => { + const validData = { + data: { width: 1, height: 1 }, + timestamp: 1, + type: EventType.Meta, + } + + expect(validateFromMobile(validData)).toStrictEqual({ + isValid: true, + errors: null, + }) + }) + + describe('validate web schema', () => { + test('should be invalid if...', () => { + expect(() => validateAgainstWebSchema({})).toThrow(TransformationError) + }) + + test('should be valid when...', () => { + expect(validateAgainstWebSchema({ data: {}, timestamp: 12345, type: 0 })).toBeTruthy() + }) + }) +}) diff --git a/ee/frontend/mobile-replay/transformers.ts b/ee/frontend/mobile-replay/transformers.ts new file mode 100644 index 0000000000000..352e6b78fbb18 --- /dev/null +++ b/ee/frontend/mobile-replay/transformers.ts @@ -0,0 +1,209 @@ +import { EventType, fullSnapshotEvent, metaEvent } from '@rrweb/types' + +import { + fullSnapshotEvent as MobileFullSnapshotEvent, + metaEvent as MobileMetaEvent, + NodeType, + serializedNodeWithId, + wireframeImage, + wireframeRectangle, + wireframeText, +} from './mobile.types' +import { makePositionStyles, makeStylesString, makeSvgBorder } from './wireframeStyle' + +/** + * generates a sequence of ids + * from 100 to 9,999,999 + * the transformer reserves ids in the range 0 to 9,999,999 + * we reserve a range of ids because we need nodes to have stable ids across snapshots + * in order for incremental snapshots to work + * some mobile elements have to be wrapped in other elements in order to be styled correctly + * which means the web version of a mobile replay will use ids that don't exist in the mobile replay + * and we need to ensure they don't clash + * ----- + * id is typed as a number in rrweb + * and there's a few places in their code where rrweb uses a check for `id === -1` to bail out of processing + * so, it's safest to assume that id is expected to be a positive integer + */ +function* ids(): Generator { + let i = 100 + while (i < 9999999) { + yield i++ + } +} +const idSequence = ids() + +export const makeMetaEvent = ( + mobileMetaEvent: MobileMetaEvent & { + timestamp: number + } +): metaEvent & { + timestamp: number + delay?: number +} => ({ + type: EventType.Meta, + data: { + href: '', // the replay doesn't use the href, so we don't need it + // instead we only need width and height in order to size the viewport + width: mobileMetaEvent.data.width, + height: mobileMetaEvent.data.height, + }, + timestamp: mobileMetaEvent.timestamp, +}) + +function makeTextElement(wireframe: wireframeText): serializedNodeWithId | null { + if (wireframe.type !== 'text') { + console.error('Passed incorrect wireframe type to makeTextElement') + return null + } + + // because we might have to style the text, we always wrap it in a div + // and apply styles to that + return { + type: NodeType.Element, + tagName: 'div', + attributes: { + style: makeStylesString(wireframe) + 'overflow:hidden;white-space:nowrap;', + }, + id: idSequence.next().value, + childNodes: [ + { + type: NodeType.Text, + textContent: wireframe.text, + id: wireframe.id, + }, + ], + } +} + +function makeImageElement(wireframe: wireframeImage): serializedNodeWithId | null { + const src = wireframe.base64 + if (!src.startsWith('data:image/')) { + console.error('Expected base64 to start with data:image/') + return null + } + + return { + type: NodeType.Element, + tagName: 'img', + attributes: { + src: src, + width: wireframe.width, + height: wireframe.height, + style: makeStylesString(wireframe), + }, + id: wireframe.id, + childNodes: [], + } +} + +function makeRectangleElement(wireframe: wireframeRectangle): serializedNodeWithId | null { + return { + type: NodeType.Element, + tagName: 'svg', + attributes: { + style: makePositionStyles(wireframe), + viewBox: `0 0 ${wireframe.width} ${wireframe.height}`, + }, + id: wireframe.id, + childNodes: [ + { + type: NodeType.Element, + tagName: 'rect', + attributes: { + x: 0, + y: 0, + width: wireframe.width, + height: wireframe.height, + fill: wireframe.style?.backgroundColor || 'transparent', + ...makeSvgBorder(wireframe.style), + }, + id: idSequence.next().value, + childNodes: [], + }, + ], + } +} + +export const makeFullEvent = ( + mobileEvent: MobileFullSnapshotEvent & { + timestamp: number + delay?: number + } +): fullSnapshotEvent & { + timestamp: number + delay?: number +} => { + return { + type: EventType.FullSnapshot, + timestamp: mobileEvent.timestamp, + data: { + node: { + type: NodeType.Document, + childNodes: [ + { + type: NodeType.DocumentType, + name: 'html', + publicId: '', + systemId: '', + id: 2, + }, + { + type: NodeType.Element, + tagName: 'html', + attributes: {}, + id: 3, + childNodes: [ + { + type: NodeType.Element, + tagName: 'head', + attributes: {}, + id: 4, + childNodes: [], + }, + { + type: NodeType.Element, + tagName: 'body', + attributes: {}, + id: 5, + childNodes: [ + { + type: NodeType.Element, + tagName: 'div', + attributes: {}, + id: idSequence.next().value, + childNodes: mobileEvent.data.wireframes.reduce((acc, wireframe) => { + if (wireframe.type === 'text') { + const textEl = makeTextElement(wireframe) + if (textEl !== null) { + acc.push(textEl) + } + acc.push() + } else if (wireframe.type === 'image') { + const imgEl = makeImageElement(wireframe) + if (imgEl !== null) { + acc.push(imgEl) + } + } else if (wireframe.type === 'rectangle') { + const rectEl = makeRectangleElement(wireframe) + if (rectEl !== null) { + acc.push(rectEl) + } + } + return acc + }, [] as serializedNodeWithId[]), + }, + ], + }, + ], + }, + ], + id: 12345, // where from? + }, + initialOffset: { + top: 0, + left: 0, + }, + }, + } +} diff --git a/ee/frontend/mobile-replay/wireframeStyle.ts b/ee/frontend/mobile-replay/wireframeStyle.ts new file mode 100644 index 0000000000000..40618b4460ebb --- /dev/null +++ b/ee/frontend/mobile-replay/wireframeStyle.ts @@ -0,0 +1,76 @@ +import { MobileStyles, wireframe } from './mobile.types' + +function ensureUnit(value: string | number): string { + return typeof value === 'number' ? `${value}px` : value +} + +function makeBorderStyles(wireframe: wireframe): string { + let styles = '' + + if (wireframe.style?.borderWidth) { + const borderWidth = ensureUnit(wireframe.style.borderWidth) + styles += `border-width: ${borderWidth};` + } + if (wireframe.style?.borderRadius) { + const borderRadius = ensureUnit(wireframe.style.borderRadius) + styles += `border-radius: ${borderRadius};` + } + if (wireframe.style?.borderColor) { + styles += `border-color: ${wireframe.style.borderColor};` + } + + if (styles.length > 0) { + styles += `border-style: solid;` + } + + return styles +} + +export function makeSvgBorder(style: MobileStyles | undefined): Record { + const svgBorderStyles: Record = {} + + if (style?.borderWidth) { + svgBorderStyles['stroke-width'] = style.borderWidth.toString() + } + if (style?.borderColor) { + svgBorderStyles.stroke = style.borderColor + } + if (style?.borderRadius) { + svgBorderStyles.rx = style.borderRadius.toString() + } + + return svgBorderStyles +} + +export function makePositionStyles(wireframe: wireframe): string { + let styles = '' + if (wireframe.width) { + styles += `width: ${wireframe.width}px;` + } + if (wireframe.height) { + styles += `height: ${wireframe.height}px;` + } + if (wireframe.x || wireframe.y) { + styles += `position: absolute;` + if (wireframe.x) { + styles += `left: ${wireframe.x}px;` + } + if (wireframe.y) { + styles += `top: ${wireframe.y}px;` + } + } + return styles +} + +export function makeStylesString(wireframe: wireframe): string { + let styles = '' + if (wireframe.style?.color) { + styles += `color: ${wireframe.style.color};` + } + if (wireframe.style?.backgroundColor) { + styles += `background-color: ${wireframe.style.backgroundColor};` + } + styles += makeBorderStyles(wireframe) + styles += makePositionStyles(wireframe) + return styles +} diff --git a/frontend/@posthog/ee/types.ts b/frontend/@posthog/ee/types.ts index 7270631c7c78f..cc51b49a9701a 100644 --- a/frontend/@posthog/ee/types.ts +++ b/frontend/@posthog/ee/types.ts @@ -1,5 +1,9 @@ // NOTE: All exported items from the EE module _must_ be optionally defined to ensure we work well with FOSS + export type PostHogEE = { enabled: boolean myTestCode?: () => void + mobileReplay?: { + transformToWeb(mobileData: any[]): string + } } diff --git a/frontend/src/lib/ee.test.ts b/frontend/src/lib/ee.test.ts index e5cddf9ec2436..792b805f6e739 100644 --- a/frontend/src/lib/ee.test.ts +++ b/frontend/src/lib/ee.test.ts @@ -3,6 +3,8 @@ import fs from 'fs' const eeFolderExists = fs.existsSync('ee/frontend/exports.ts') export const ifEeIt = eeFolderExists ? it : it.skip export const ifFossIt = !eeFolderExists ? it : it.skip +export const ifEeDescribe = eeFolderExists ? describe : describe.skip +export const ifFossDescribe = !eeFolderExists ? describe : describe.skip import posthogEE from '@posthog/ee/exports' diff --git a/frontend/src/lib/replay/__snapshots__/transform.test.ts.snap b/frontend/src/lib/replay/__snapshots__/transform.test.ts.snap new file mode 100644 index 0000000000000..97ed2df9a5ed7 --- /dev/null +++ b/frontend/src/lib/replay/__snapshots__/transform.test.ts.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`replay/transform transform can convert images 1`] = `"[{"type":4,"data":{"href":"","width":300,"height":600},"timestamp":1},{"type":2,"timestamp":1,"data":{"node":{"type":0,"childNodes":[{"type":1,"name":"html","publicId":"","systemId":"","id":2},{"type":2,"tagName":"html","attributes":{},"id":3,"childNodes":[{"type":2,"tagName":"head","attributes":{},"id":4,"childNodes":[]},{"type":2,"tagName":"body","attributes":{},"id":5,"childNodes":[{"type":2,"tagName":"div","attributes":{},"id":103,"childNodes":[{"type":2,"tagName":"div","attributes":{"style":"color: red;width: 100px;height: 30px;position: absolute;left: 11px;top: 12px;overflow:hidden;white-space:nowrap;"},"id":104,"childNodes":[{"type":3,"textContent":"Ⱏ遲䩞㡛쓯잘ጫ䵤㥦鷁끞鈅毅┌빯湌Თ","id":12345}]},{"type":2,"tagName":"img","attributes":{"src":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=","width":100,"height":30,"style":"width: 100px;height: 30px;position: absolute;left: 25px;top: 42px;"},"id":12345,"childNodes":[]}]}]}]}],"id":12345},"initialOffset":{"top":0,"left":0}}}]"`; + +exports[`replay/transform transform can convert rect with text 1`] = `"[{"type":4,"data":{"href":"","width":300,"height":600},"timestamp":1},{"type":2,"timestamp":1,"data":{"node":{"type":0,"childNodes":[{"type":1,"name":"html","publicId":"","systemId":"","id":2},{"type":2,"tagName":"html","attributes":{},"id":3,"childNodes":[{"type":2,"tagName":"head","attributes":{},"id":4,"childNodes":[]},{"type":2,"tagName":"body","attributes":{},"id":5,"childNodes":[{"type":2,"tagName":"div","attributes":{},"id":105,"childNodes":[{"type":2,"tagName":"svg","attributes":{"style":"width: 100px;height: 30px;position: absolute;left: 11px;top: 12px;","viewBox":"0 0 100 30"},"id":12345,"childNodes":[{"type":2,"tagName":"rect","attributes":{"x":0,"y":0,"width":100,"height":30,"fill":"transparent","stroke-width":"4","stroke":"blue","rx":"10px"},"id":106,"childNodes":[]}]},{"type":2,"tagName":"div","attributes":{"style":"width: 100px;height: 30px;position: absolute;left: 13px;top: 17px;overflow:hidden;white-space:nowrap;"},"id":107,"childNodes":[{"type":3,"textContent":"i am in the box","id":12345}]}]}]}]}],"id":12345},"initialOffset":{"top":0,"left":0}}}]"`; + +exports[`replay/transform transform can ignore unknown wireframe types 1`] = `"[{"type":4,"data":{"href":"","width":300,"height":600},"timestamp":1},{"type":2,"timestamp":1,"data":{"node":{"type":0,"childNodes":[{"type":1,"name":"html","publicId":"","systemId":"","id":2},{"type":2,"tagName":"html","attributes":{},"id":3,"childNodes":[{"type":2,"tagName":"head","attributes":{},"id":4,"childNodes":[]},{"type":2,"tagName":"body","attributes":{},"id":5,"childNodes":[{"type":2,"tagName":"div","attributes":{},"id":102,"childNodes":[]}]}]}],"id":12345},"initialOffset":{"top":0,"left":0}}}]"`; + +exports[`replay/transform transform text is wrapped in a div to apply styling 1`] = `"[{"type":4,"data":{"href":"","width":300,"height":600},"timestamp":1},{"type":2,"timestamp":1,"data":{"node":{"type":0,"childNodes":[{"type":1,"name":"html","publicId":"","systemId":"","id":2},{"type":2,"tagName":"html","attributes":{},"id":3,"childNodes":[{"type":2,"tagName":"head","attributes":{},"id":4,"childNodes":[]},{"type":2,"tagName":"body","attributes":{},"id":5,"childNodes":[{"type":2,"tagName":"div","attributes":{},"id":100,"childNodes":[{"type":2,"tagName":"div","attributes":{"style":"color: red;background-color: yellow;border-width: 4px;border-radius: 10px;border-color: blue;border-style: solid;width: 100px;height: 30px;position: absolute;left: 11px;top: 12px;overflow:hidden;white-space:nowrap;"},"id":101,"childNodes":[{"type":3,"textContent":"Ⱏ遲䩞㡛쓯잘ጫ䵤㥦鷁끞鈅毅┌빯湌Თ","id":12345}]}]}]}]}],"id":12345},"initialOffset":{"top":0,"left":0}}}]"`; diff --git a/frontend/src/lib/replay/transform.test.ts b/frontend/src/lib/replay/transform.test.ts new file mode 100644 index 0000000000000..459b5ee87bf0b --- /dev/null +++ b/frontend/src/lib/replay/transform.test.ts @@ -0,0 +1,193 @@ +import posthogEE from '@posthog/ee/exports' +import { ifEeDescribe } from 'lib/ee.test' + +const heartEyesEmojiURL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=' + +describe('replay/transform', () => { + ifEeDescribe('transform', () => { + test('text is wrapped in a div to apply styling', () => { + const helloWorld = posthogEE.mobileReplay?.transformToWeb([ + { + data: { screen: 'App Home Page', width: 300, height: 600 }, + timestamp: 1, + type: 4, + }, + { + type: 10, + data: { + wireframes: [ + { + id: 12345, + x: 11, + y: 12, + width: 100, + height: 30, + // clip: { + // bottom: 83, + // right: 44, + // }, + type: 'text', + text: 'Ⱏ遲䩞㡛쓯잘ጫ䵤㥦鷁끞鈅毅┌빯湌Თ', + style: { + // family: '疴ꖻ䖭㋑⁃⻋ꑧٹ㧕Ⓖ', + // size: 4220431756569966319, + color: 'red', + backgroundColor: 'yellow', + borderWidth: '4px', + borderColor: 'blue', + borderRadius: '10px', + }, + }, + ], + }, + timestamp: 1, + }, + ]) + expect(helloWorld).toMatchSnapshot() + }) + + test('can ignore unknown types', () => { + expect( + posthogEE.mobileReplay?.transformToWeb([ + { + data: { width: 300, height: 600 }, + timestamp: 1, + type: 4, + }, + { type: 9999 }, + ]) + ).toBe('[{"type":4,"data":{"href":"","width":300,"height":600},"timestamp":1}]') + }) + + test('can ignore unknown wireframe types', () => { + const unexpectedWireframeType = posthogEE.mobileReplay?.transformToWeb([ + { + data: { screen: 'App Home Page', width: 300, height: 600 }, + timestamp: 1, + type: 4, + }, + { + type: 10, + data: { + wireframes: [ + { + id: 12345, + x: 11, + y: 12, + width: 100, + height: 30, + type: 'something in the SDK but not yet the transformer', + }, + ], + }, + timestamp: 1, + }, + ]) + expect(unexpectedWireframeType).toMatchSnapshot() + }) + + test('can convert images', () => { + const exampleWithImage = posthogEE.mobileReplay?.transformToWeb([ + { + data: { + screen: 'App Home Page', + width: 300, + height: 600, + }, + timestamp: 1, + type: 4, + }, + { + type: 10, + data: { + wireframes: [ + { + id: 12345, + x: 11, + y: 12, + width: 100, + height: 30, + // clip: { + // bottom: 83, + // right: 44, + // }, + type: 'text', + text: 'Ⱏ遲䩞㡛쓯잘ጫ䵤㥦鷁끞鈅毅┌빯湌Თ', + style: { + // family: '疴ꖻ䖭㋑⁃⻋ꑧٹ㧕Ⓖ', + // size: 4220431756569966319, + color: 'red', + }, + }, + { + id: 12345, + x: 25, + y: 42, + width: 100, + height: 30, + // clip: { + // bottom: 83, + // right: 44, + // }, + type: 'image', + base64: heartEyesEmojiURL, + }, + ], + }, + timestamp: 1, + }, + ]) + expect(exampleWithImage).toMatchSnapshot() + }) + + test('can convert rect with text', () => { + const exampleWithRectAndText = posthogEE.mobileReplay?.transformToWeb([ + { + data: { + width: 300, + height: 600, + }, + timestamp: 1, + type: 4, + }, + { + type: 10, + data: { + wireframes: [ + { + id: 12345, + x: 11, + y: 12, + width: 100, + height: 30, + type: 'rectangle', + style: { + color: 'red', + borderColor: 'blue', + borderWidth: '4', + borderRadius: '10px', + }, + }, + { + id: 12345, + x: 13, + y: 17, + width: 100, + height: 30, + // clip: { + // bottom: 83, + // right: 44, + // }, + type: 'text', + text: 'i am in the box', + }, + ], + }, + timestamp: 1, + }, + ]) + expect(exampleWithRectAndText).toMatchSnapshot() + }) + }) +}) diff --git a/package.json b/package.json index 01da1beb31545..8f196d0d9ea56 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,10 @@ "build-storybook": "storybook build", "dev:migrate:postgres": "export DEBUG=1 && source env/bin/activate && python manage.py migrate", "dev:migrate:clickhouse": "export DEBUG=1 && source env/bin/activate && python manage.py migrate_clickhouse", - "prepare": "husky install" + "prepare": "husky install", + "mobile-replay:web:schema:build:json": "ts-json-schema-generator -f tsconfig.json --path 'node_modules/@rrweb/types/dist/index.d.ts' --type 'eventWithTime' --expose all --no-top-ref --out schema/web/rr-web-schema.json && prettier --write schema/web/rr-web-schema.json", + "mobile-replay:mobile:schema:build:json": "ts-json-schema-generator -f tsconfig.json --path 'src/mobile.types.ts' --type 'mobileEventWithTime' --expose all --no-top-ref --out schema/mobile/rr-mobile-schema.json && prettier --write schema/mobile/rr-mobile-schema.json", + "mobile-replay:schema:build:json": "pnpm web:schema:build:json && pnpm mobile:schema:build:json" }, "dependencies": { "@ant-design/icons": "^4.7.0", @@ -95,6 +98,7 @@ "@types/md5": "^2.3.0", "@types/react-transition-group": "^4.4.5", "@types/react-virtualized": "^9.21.23", + "ajv": "^8.12.0", "antd": "^4.17.1", "antd-dayjs-webpack-plugin": "^1.0.6", "babel-preset-nano-react-app": "^0.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0c59a7b595b2..fca1a52b1c20a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ dependencies: '@types/react-virtualized': specifier: ^9.21.23 version: 9.21.26 + ajv: + specifier: ^8.12.0 + version: 8.12.0 antd: specifier: ^4.17.1 version: 4.17.1(react-dom@18.2.0)(react@18.2.0) @@ -7192,7 +7195,7 @@ packages: indent-string: 4.0.0 dev: true - /ajv-formats@2.1.1(ajv@8.11.0): + /ajv-formats@2.1.1(ajv@8.12.0): resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: ajv: ^8.0.0 @@ -7200,7 +7203,7 @@ packages: ajv: optional: true dependencies: - ajv: 8.11.0 + ajv: 8.12.0 dev: true /ajv-keywords@3.5.2(ajv@6.12.6): @@ -7211,12 +7214,12 @@ packages: ajv: 6.12.6 dev: true - /ajv-keywords@5.1.0(ajv@8.11.0): + /ajv-keywords@5.1.0(ajv@8.12.0): resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} peerDependencies: ajv: ^8.8.2 dependencies: - ajv: 8.11.0 + ajv: 8.12.0 fast-deep-equal: 3.1.3 dev: true @@ -7229,14 +7232,13 @@ packages: uri-js: 4.4.1 dev: true - /ajv@8.11.0: - resolution: {integrity: sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==} + /ajv@8.12.0: + resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} dependencies: fast-deep-equal: 3.1.3 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 uri-js: 4.4.1 - dev: true /alphanum-sort@1.0.2: resolution: {integrity: sha512-0FcBfdcmaumGPQ0qPn7Q5qTgz/ooXgIyp1rf8ik5bGX8mpE2YHjC0P/eyQvxu1GURYQgq9ozf2mteQ5ZD9YiyQ==} @@ -13730,7 +13732,6 @@ packages: /json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - dev: true /json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -16140,7 +16141,6 @@ packages: /punycode@2.1.1: resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==} engines: {node: '>=6'} - dev: true /puppeteer-core@2.1.1: resolution: {integrity: sha512-n13AWriBMPYxnpbb6bnaY5YoY6rGj8vPLrz6CZF3o0qJNEwlcfJVxBzYZ0NJsQ21UbdJoijPCDrM++SUVEz7+w==} @@ -17661,9 +17661,9 @@ packages: engines: {node: '>= 12.13.0'} dependencies: '@types/json-schema': 7.0.12 - ajv: 8.11.0 - ajv-formats: 2.1.1(ajv@8.11.0) - ajv-keywords: 5.1.0(ajv@8.11.0) + ajv: 8.12.0 + ajv-formats: 2.1.1(ajv@8.12.0) + ajv-keywords: 5.1.0(ajv@8.12.0) dev: true /scroll-into-view-if-needed@2.2.26: @@ -18516,7 +18516,7 @@ packages: resolution: {integrity: sha512-Y4X9zqrCftUhMeH2EptSSERdVKt/nEdijTOacGD/97EKjhQ/Qs8RTlEGABSJNNN8lac9kheH+af7yAkEWlgneA==} engines: {node: '>=10.0.0'} dependencies: - ajv: 8.11.0 + ajv: 8.12.0 lodash.truncate: 4.4.2 slice-ansi: 4.0.0 string-width: 4.2.3 @@ -19164,7 +19164,6 @@ packages: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} dependencies: punycode: 2.1.1 - dev: true /url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}