From 12787f58b54c26c483a3f3a86115db948651fd8e Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Sat, 11 Jul 2020 10:07:34 +0800 Subject: [PATCH 1/2] fast-forward implementation v1 related to #6 Since the currently 'play at any time offset' implementation is pretty simple, there are many things we can do to optimize its performance. In this patch, we do the following optimizations: 1. Ignore some of the events during fast forward. For example, when we are going to fast forward to 10 minutes later, we do not need to perform mouse movement events during this period. 2. Use a fragment element as the 'virtual parent node'. So newly added DOM nodes will be appended to this fragment node, and finally being appended into the document as a batch operation. These changes reduce a lot of time which was spent on reflow/repaint previously. I've seen a 10 times performance improvement within these approaches. And there are still some things we can do better but not in this patch. 1. We can build a virtual DOM tree to store the mutations of DOM. This will minimize the number of DOM operations. 2. Another thing that may help UX is to make the fast forward process async and cancellable. This may make the drag and drop interactions in the player's UI looks smooth. --- src/replay/index.ts | 390 +++++++++++++++++++++++++----------------- src/replay/machine.ts | 5 + src/types.ts | 15 +- src/utils.ts | 229 +++++++++++++++++++++++++ 4 files changed, 477 insertions(+), 162 deletions(-) diff --git a/src/replay/index.ts b/src/replay/index.ts index a291f10e11..c189da1bfa 100644 --- a/src/replay/index.ts +++ b/src/replay/index.ts @@ -1,4 +1,4 @@ -import { rebuild, buildNodeWithSN } from 'rrweb-snapshot'; +import { rebuild, buildNodeWithSN, INode, NodeType } from 'rrweb-snapshot'; import * as mittProxy from 'mitt'; import * as smoothscroll from 'smoothscroll-polyfill'; import { Timer } from './timer'; @@ -22,8 +22,11 @@ import { Emitter, MediaInteractions, metaEvent, + mutationData, + scrollData, + inputData, } from '../types'; -import { mirror, polyfill } from '../utils'; +import { mirror, polyfill, TreeIndex } from '../utils'; import getInjectStyleRules from './styles/inject-style'; import './styles/style.css'; @@ -71,6 +74,9 @@ export class Replayer { private service!: ReturnType; + private treeIndex!: TreeIndex; + private fragmentParentMap!: Map; + constructor( events: Array, config?: Partial, @@ -82,12 +88,42 @@ export class Replayer { this.handleResize = this.handleResize.bind(this); this.getCastFn = this.getCastFn.bind(this); - this.emitter.on('resize', this.handleResize as Handler); + this.emitter.on(ReplayerEvents.Resize, this.handleResize as Handler); smoothscroll.polyfill(); polyfill(); this.setupDom(); + this.treeIndex = new TreeIndex(); + this.fragmentParentMap = new Map(); + this.emitter.on(ReplayerEvents.Flush, () => { + const { scrollMap, inputMap } = this.treeIndex.flush(); + + for (const d of scrollMap.values()) { + this.applyScroll(d); + } + for (const d of inputMap.values()) { + this.applyInput(d); + } + + for (const [frag, parent] of this.fragmentParentMap.entries()) { + mirror.map[parent.__sn.id] = parent; + /** + * If we have already set value attribute on textarea, + * then we could not apply text content as default value any more. + */ + if ( + parent.__sn.type === NodeType.Element && + parent.__sn.tagName === 'textarea' && + frag.textContent + ) { + ((parent as unknown) as HTMLTextAreaElement).value = frag.textContent; + } + parent.appendChild(frag); + } + this.fragmentParentMap.clear(); + }); + this.service = createPlayerService( { events: events.map((e) => { @@ -402,130 +438,13 @@ export class Replayer { const { data: d } = e; switch (d.source) { case IncrementalSource.Mutation: { - d.removes.forEach((mutation) => { - const target = mirror.getNode(mutation.id); - if (!target) { - return this.warnNodeNotFound(d, mutation.id); - } - const parent = mirror.getNode(mutation.parentId); - if (!parent) { - return this.warnNodeNotFound(d, mutation.parentId); - } - // target may be removed with its parents before - mirror.removeNodeFromMap(target); - if (parent) { - parent.removeChild(target); - } - }); - - const legacy_missingNodeMap: missingNodeMap = { - ...this.legacy_missingNodeRetryMap, - }; - const queue: addedNodeMutation[] = []; - - const appendNode = (mutation: addedNodeMutation) => { - const parent = mirror.getNode(mutation.parentId); - if (!parent) { - return queue.push(mutation); - } - - let previous: Node | null = null; - let next: Node | null = null; - if (mutation.previousId) { - previous = mirror.getNode(mutation.previousId) as Node; - } - if (mutation.nextId) { - next = mirror.getNode(mutation.nextId) as Node; - } - // next not present at this moment - if (mutation.nextId !== null && mutation.nextId !== -1 && !next) { - return queue.push(mutation); - } - - const target = buildNodeWithSN( - mutation.node, - this.iframe.contentDocument!, - mirror.map, - true, - ) as Node; - - // legacy data, we should not have -1 siblings any more - if (mutation.previousId === -1 || mutation.nextId === -1) { - legacy_missingNodeMap[mutation.node.id] = { - node: target, - mutation, - }; - return; - } - - if ( - previous && - previous.nextSibling && - previous.nextSibling.parentNode - ) { - parent.insertBefore(target, previous.nextSibling); - } else if (next && next.parentNode) { - // making sure the parent contains the reference nodes - // before we insert target before next. - parent.contains(next) - ? parent.insertBefore(target, next) - : parent.insertBefore(target, null); - } else { - parent.appendChild(target); - } - - if (mutation.previousId || mutation.nextId) { - this.legacy_resolveMissingNode( - legacy_missingNodeMap, - parent, - target, - mutation, - ); - } - }; - - d.adds.forEach((mutation) => { - appendNode(mutation); - }); - - while (queue.length) { - if (queue.every((m) => !Boolean(mirror.getNode(m.parentId)))) { - return queue.forEach((m) => this.warnNodeNotFound(d, m.node.id)); - } - const mutation = queue.shift()!; - appendNode(mutation); - } - - if (Object.keys(legacy_missingNodeMap).length) { - Object.assign(this.legacy_missingNodeRetryMap, legacy_missingNodeMap); + if (isSync) { + d.adds.forEach((m) => this.treeIndex.add(m)); + d.texts.forEach((m) => this.treeIndex.text(m)); + d.attributes.forEach((m) => this.treeIndex.attribute(m)); + d.removes.forEach((m) => this.treeIndex.remove(m)); } - - d.texts.forEach((mutation) => { - const target = mirror.getNode(mutation.id); - if (!target) { - return this.warnNodeNotFound(d, mutation.id); - } - target.textContent = mutation.value; - }); - d.attributes.forEach((mutation) => { - const target = mirror.getNode(mutation.id); - if (!target) { - return this.warnNodeNotFound(d, mutation.id); - } - for (const attributeName in mutation.attributes) { - if (typeof attributeName === 'string') { - const value = mutation.attributes[attributeName]; - if (value !== null) { - ((target as Node) as Element).setAttribute( - attributeName, - value, - ); - } else { - ((target as Node) as Element).removeAttribute(attributeName); - } - } - } - }); + this.applyMutation(d, true); break; } case IncrementalSource.MouseMove: @@ -604,27 +523,11 @@ export class Replayer { if (d.id === -1) { break; } - const target = mirror.getNode(d.id); - if (!target) { - return this.debugNodeNotFound(d, d.id); - } - if ((target as Node) === this.iframe.contentDocument) { - this.iframe.contentWindow!.scrollTo({ - top: d.y, - left: d.x, - behavior: isSync ? 'auto' : 'smooth', - }); - } else { - try { - ((target as Node) as Element).scrollTop = d.y; - ((target as Node) as Element).scrollLeft = d.x; - } catch (error) { - /** - * Seldomly we may found scroll target was removed before - * its last scroll event. - */ - } + if (isSync) { + this.treeIndex.scroll(d); + break; } + this.applyScroll(d); break; } case IncrementalSource.ViewportResize: @@ -643,16 +546,11 @@ export class Replayer { if (d.id === -1) { break; } - const target = mirror.getNode(d.id); - if (!target) { - return this.debugNodeNotFound(d, d.id); - } - try { - ((target as Node) as HTMLInputElement).checked = d.isChecked; - ((target as Node) as HTMLInputElement).value = d.text; - } catch (error) { - // for safe + if (isSync) { + this.treeIndex.input(d); + break; } + this.applyInput(d); break; } case IncrementalSource.MediaInteraction: { @@ -712,6 +610,188 @@ export class Replayer { } } + private applyMutation(d: mutationData, useVirtualParent: boolean) { + d.removes.forEach((mutation) => { + const target = mirror.getNode(mutation.id); + if (!target) { + return this.warnNodeNotFound(d, mutation.id); + } + const parent = mirror.getNode(mutation.parentId); + if (!parent) { + return this.warnNodeNotFound(d, mutation.parentId); + } + // target may be removed with its parents before + mirror.removeNodeFromMap(target); + if (parent) { + const realParent = this.fragmentParentMap.get(parent); + if (realParent && realParent.contains(target)) { + realParent.removeChild(target); + } else { + parent.removeChild(target); + } + } + }); + + const legacy_missingNodeMap: missingNodeMap = { + ...this.legacy_missingNodeRetryMap, + }; + const queue: addedNodeMutation[] = []; + + const appendNode = (mutation: addedNodeMutation) => { + let parent = mirror.getNode(mutation.parentId); + if (!parent) { + return queue.push(mutation); + } + + const parentInDocument = this.iframe.contentDocument!.contains(parent); + if (useVirtualParent && parentInDocument) { + const virtualParent = (document.createDocumentFragment() as unknown) as INode; + mirror.map[mutation.parentId] = virtualParent; + this.fragmentParentMap.set(virtualParent, parent); + while (parent.firstChild) { + virtualParent.appendChild(parent.firstChild); + } + parent = virtualParent; + } + + let previous: Node | null = null; + let next: Node | null = null; + if (mutation.previousId) { + previous = mirror.getNode(mutation.previousId) as Node; + } + if (mutation.nextId) { + next = mirror.getNode(mutation.nextId) as Node; + } + // next not present at this moment + if (mutation.nextId !== null && mutation.nextId !== -1 && !next) { + return queue.push(mutation); + } + + const target = buildNodeWithSN( + mutation.node, + this.iframe.contentDocument!, + mirror.map, + true, + ) as Node; + + // legacy data, we should not have -1 siblings any more + if (mutation.previousId === -1 || mutation.nextId === -1) { + legacy_missingNodeMap[mutation.node.id] = { + node: target, + mutation, + }; + return; + } + + if (previous && previous.nextSibling && previous.nextSibling.parentNode) { + parent.insertBefore(target, previous.nextSibling); + } else if (next && next.parentNode) { + // making sure the parent contains the reference nodes + // before we insert target before next. + parent.contains(next) + ? parent.insertBefore(target, next) + : parent.insertBefore(target, null); + } else { + parent.appendChild(target); + } + + if (mutation.previousId || mutation.nextId) { + this.legacy_resolveMissingNode( + legacy_missingNodeMap, + parent, + target, + mutation, + ); + } + }; + + d.adds.forEach((mutation) => { + appendNode(mutation); + }); + + while (queue.length) { + if (queue.every((m) => !Boolean(mirror.getNode(m.parentId)))) { + return queue.forEach((m) => this.warnNodeNotFound(d, m.node.id)); + } + const mutation = queue.shift()!; + appendNode(mutation); + } + + if (Object.keys(legacy_missingNodeMap).length) { + Object.assign(this.legacy_missingNodeRetryMap, legacy_missingNodeMap); + } + + d.texts.forEach((mutation) => { + let target = mirror.getNode(mutation.id); + if (!target) { + return this.warnNodeNotFound(d, mutation.id); + } + /** + * apply text content to real parent directly + */ + if (this.fragmentParentMap.has(target)) { + target = this.fragmentParentMap.get(target)!; + } + target.textContent = mutation.value; + }); + d.attributes.forEach((mutation) => { + let target = mirror.getNode(mutation.id); + if (!target) { + return this.warnNodeNotFound(d, mutation.id); + } + if (this.fragmentParentMap.has(target)) { + target = this.fragmentParentMap.get(target)!; + } + for (const attributeName in mutation.attributes) { + if (typeof attributeName === 'string') { + const value = mutation.attributes[attributeName]; + if (value !== null) { + ((target as Node) as Element).setAttribute(attributeName, value); + } else { + ((target as Node) as Element).removeAttribute(attributeName); + } + } + } + }); + } + + private applyScroll(d: scrollData) { + const target = mirror.getNode(d.id); + if (!target) { + return this.debugNodeNotFound(d, d.id); + } + if ((target as Node) === this.iframe.contentDocument) { + this.iframe.contentWindow!.scrollTo({ + top: d.y, + left: d.x, + behavior: 'smooth', + }); + } else { + try { + ((target as Node) as Element).scrollTop = d.y; + ((target as Node) as Element).scrollLeft = d.x; + } catch (error) { + /** + * Seldomly we may found scroll target was removed before + * its last scroll event. + */ + } + } + } + + private applyInput(d: inputData) { + const target = mirror.getNode(d.id); + if (!target) { + return this.debugNodeNotFound(d, d.id); + } + try { + ((target as Node) as HTMLInputElement).checked = d.isChecked; + ((target as Node) as HTMLInputElement).value = d.text; + } catch (error) { + // for safe + } + } + private legacy_resolveMissingNode( map: missingNodeMap, parent: Node, diff --git a/src/replay/machine.ts b/src/replay/machine.ts index 24ea012a2f..af9cc85299 100644 --- a/src/replay/machine.ts +++ b/src/replay/machine.ts @@ -8,6 +8,7 @@ import { Emitter, } from '../types'; import { Timer, getDelay } from './timer'; +import { needCastInSyncMode } from '../utils'; export type PlayerContext = { events: eventWithTime[]; @@ -194,6 +195,9 @@ export function createPlayerService( continue; } const isSync = event.timestamp < baselineTime; + if (isSync && !needCastInSyncMode(event)) { + continue; + } const castFn = getCastFn(event, isSync); if (isSync) { castFn(); @@ -207,6 +211,7 @@ export function createPlayerService( }); } } + emitter.emit(ReplayerEvents.Flush); timer.addActions(actions); timer.start(); }, diff --git a/src/types.ts b/src/types.ts index d2d121d123..749f3d3316 100644 --- a/src/types.ts +++ b/src/types.ts @@ -168,13 +168,13 @@ export type hooksParam = { // https://dom.spec.whatwg.org/#interface-mutationrecord export type mutationRecord = { - type: string, - target: Node, - oldValue: string | null, - addedNodes: NodeList, - removedNodes: NodeList, - attributeName: string | null, -} + type: string; + target: Node; + oldValue: string | null; + addedNodes: NodeList; + removedNodes: NodeList; + attributeName: string | null; +}; export type textCursor = { node: Node; @@ -377,4 +377,5 @@ export enum ReplayerEvents { MouseInteraction = 'mouse-interaction', EventCast = 'event-cast', CustomEvent = 'custom-event', + Flush = 'flush', } diff --git a/src/utils.ts b/src/utils.ts index b83a74e0a4..4c8c480fca 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,6 +4,16 @@ import { listenerHandler, hookResetter, blockClass, + eventWithTime, + EventType, + IncrementalSource, + addedNodeMutation, + removedNodeMutation, + textMutation, + attributeMutation, + mutationData, + scrollData, + inputData, } from './types'; import { INode } from 'rrweb-snapshot'; @@ -172,3 +182,222 @@ export function polyfill() { .forEach as unknown) as NodeList['forEach']; } } + +export function needCastInSyncMode(event: eventWithTime): boolean { + switch (event.type) { + case EventType.DomContentLoaded: + case EventType.Load: + case EventType.Custom: + return false; + case EventType.FullSnapshot: + case EventType.Meta: + return true; + default: + break; + } + + switch (event.data.source) { + case IncrementalSource.MouseMove: + case IncrementalSource.MouseInteraction: + case IncrementalSource.TouchMove: + case IncrementalSource.MediaInteraction: + return false; + case IncrementalSource.ViewportResize: + case IncrementalSource.StyleSheetRule: + case IncrementalSource.Scroll: + case IncrementalSource.Input: + return true; + default: + break; + } + + return true; +} + +export type TreeNode = { + id: number; + mutation: addedNodeMutation; + parent?: TreeNode; + children: Record; + texts: textMutation[]; + attributes: attributeMutation[]; +}; +export class TreeIndex { + public tree!: Record; + + private removeNodeMutations!: removedNodeMutation[]; + private textMutations!: textMutation[]; + private attributeMutations!: attributeMutation[]; + private indexes!: Map; + private removeIdSet!: Set; + private scrollMap!: Map; + private inputMap!: Map; + + constructor() { + this.reset(); + } + + public add(mutation: addedNodeMutation) { + const parentTreeNode = this.indexes.get(mutation.parentId); + const treeNode: TreeNode = { + id: mutation.node.id, + mutation, + children: [], + texts: [], + attributes: [], + }; + if (!parentTreeNode) { + this.tree[treeNode.id] = treeNode; + } else { + treeNode.parent = parentTreeNode; + parentTreeNode.children[treeNode.id] = treeNode; + } + this.indexes.set(treeNode.id, treeNode); + } + + public remove(mutation: removedNodeMutation) { + const parentTreeNode = this.indexes.get(mutation.parentId); + const treeNode = this.indexes.get(mutation.id); + + const deepRemoveFromMirror = (id: number) => { + this.removeIdSet.add(id); + const node = mirror.getNode(id); + node?.childNodes.forEach((childNode) => + deepRemoveFromMirror(((childNode as unknown) as INode).__sn.id), + ); + }; + const deepRemoveFromTreeIndex = (node: TreeNode) => { + this.removeIdSet.add(node.id); + Object.values(node.children).forEach((n) => deepRemoveFromTreeIndex(n)); + const _treeNode = this.indexes.get(node.id); + if (_treeNode) { + const _parentTreeNode = _treeNode.parent; + if (_parentTreeNode) { + delete _treeNode.parent; + delete _parentTreeNode.children[_treeNode.id]; + this.indexes.delete(mutation.id); + } + } + }; + + if (!treeNode) { + this.removeNodeMutations.push(mutation); + deepRemoveFromMirror(mutation.id); + } else if (!parentTreeNode) { + delete this.tree[treeNode.id]; + this.indexes.delete(treeNode.id); + deepRemoveFromTreeIndex(treeNode); + } else { + delete treeNode.parent; + delete parentTreeNode.children[treeNode.id]; + this.indexes.delete(mutation.id); + deepRemoveFromTreeIndex(treeNode); + } + } + + public text(mutation: textMutation) { + const treeNode = this.indexes.get(mutation.id); + if (treeNode) { + treeNode.texts.push(mutation); + } else { + this.textMutations.push(mutation); + } + } + + public attribute(mutation: attributeMutation) { + const treeNode = this.indexes.get(mutation.id); + if (treeNode) { + treeNode.attributes.push(mutation); + } else { + this.attributeMutations.push(mutation); + } + } + + public scroll(d: scrollData) { + this.scrollMap.set(d.id, d); + } + + public input(d: inputData) { + this.inputMap.set(d.id, d); + } + + public flush(): { + mutationData: mutationData; + scrollMap: TreeIndex['scrollMap']; + inputMap: TreeIndex['inputMap']; + } { + const { + tree, + removeNodeMutations, + textMutations, + attributeMutations, + } = this; + + const batchMutationData: mutationData = { + source: IncrementalSource.Mutation, + removes: removeNodeMutations, + texts: textMutations, + attributes: attributeMutations, + adds: [], + }; + + const walk = (treeNode: TreeNode, removed: boolean) => { + if (removed) { + this.removeIdSet.add(treeNode.id); + } + batchMutationData.texts = batchMutationData.texts + .concat(removed ? [] : treeNode.texts) + .filter((m) => !this.removeIdSet.has(m.id)); + batchMutationData.attributes = batchMutationData.attributes + .concat(removed ? [] : treeNode.attributes) + .filter((m) => !this.removeIdSet.has(m.id)); + if ( + !this.removeIdSet.has(treeNode.id) && + !this.removeIdSet.has(treeNode.mutation.parentId) && + !removed + ) { + batchMutationData.adds.push(treeNode.mutation); + if (treeNode.children) { + Object.values(treeNode.children).forEach((n) => walk(n, false)); + } + } else { + Object.values(treeNode.children).forEach((n) => walk(n, true)); + } + }; + + Object.values(tree).forEach((n) => walk(n, false)); + + for (const id of this.scrollMap.keys()) { + if (this.removeIdSet.has(id)) { + this.scrollMap.delete(id); + } + } + for (const id of this.inputMap.keys()) { + if (this.removeIdSet.has(id)) { + this.inputMap.delete(id); + } + } + + const scrollMap = new Map(this.scrollMap); + const inputMap = new Map(this.inputMap); + + this.reset(); + + return { + mutationData: batchMutationData, + scrollMap, + inputMap, + }; + } + + private reset() { + this.tree = []; + this.indexes = new Map(); + this.removeNodeMutations = []; + this.textMutations = []; + this.attributeMutations = []; + this.removeIdSet = new Set(); + this.scrollMap = new Map(); + this.inputMap = new Map(); + } +} From cf7ad071e0d9f00b4c4eda6a882bb9d14c440805 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Sat, 11 Jul 2020 11:01:59 +0800 Subject: [PATCH 2/2] tweak the code of getting last session, without splice events array --- src/replay/machine.ts | 43 ++++++++---- test/machine.test.ts | 19 +++++ test/replayer.test.ts | 160 +++++++----------------------------------- test/utils.ts | 109 ++++++++++++++++++++++++++-- 4 files changed, 179 insertions(+), 152 deletions(-) create mode 100644 test/machine.test.ts diff --git a/src/replay/machine.ts b/src/replay/machine.ts index af9cc85299..a30a9a5507 100644 --- a/src/replay/machine.ts +++ b/src/replay/machine.ts @@ -75,6 +75,33 @@ export type PlayerState = context: PlayerContext; }; +/** + * If the array have multiple meta and fullsnapshot events, + * return the events from last meta to the end. + */ +export function getLastSession(events: eventWithTime[]): eventWithTime[] { + const lastSession: eventWithTime[] = []; + + let hasFullSnapshot = false; + let hasMeta = false; + + for (let idx = events.length - 1; idx >= 0; idx--) { + const event = events[idx]; + lastSession.unshift(event); + if (event.type === EventType.FullSnapshot) { + hasFullSnapshot = true; + } + if (event.type === EventType.Meta) { + hasMeta = true; + } + if (hasFullSnapshot && hasMeta) { + break; + } + } + + return lastSession; +} + type PlayerAssets = { emitter: Emitter; getCastFn(event: eventWithTime, isSync: boolean): () => void; @@ -171,22 +198,10 @@ export function createPlayerService( play(ctx) { const { timer, events, baselineTime, lastPlayedEvent } = ctx; timer.clear(); - const needed_events = new Array(); - for (const event of events) { - if (event.timestamp < baselineTime && - event.type === EventType.FullSnapshot && - needed_events.length > 0 && - needed_events[needed_events.length -1].type === EventType.Meta - ) { - // delete everything before Meta - // so that we only rebuild from the latest full snapshot - needed_events.splice(0, needed_events.length -1); - } - needed_events.push(event); - } + const neededEvents = getLastSession(events); const actions = new Array(); - for (const event of needed_events) { + for (const event of neededEvents) { if ( lastPlayedEvent && (event.timestamp <= lastPlayedEvent.timestamp || diff --git a/test/machine.test.ts b/test/machine.test.ts new file mode 100644 index 0000000000..261a9a0601 --- /dev/null +++ b/test/machine.test.ts @@ -0,0 +1,19 @@ +import { expect } from 'chai'; +import { getLastSession } from '../src/replay/machine'; +import { sampleEvents } from './utils'; +import { EventType } from '../src/types'; + +const events = sampleEvents.filter( + (e) => ![EventType.DomContentLoaded, EventType.Load].includes(e.type), +); + +describe('get last session', () => { + it('will return all the events when there is only one session', () => { + expect(getLastSession(events)).to.deep.equal(events); + }); + + it('will return last session when there is more than one in the events', () => { + const multiple = events.concat(events).concat(events); + expect(getLastSession(multiple)).to.deep.equal(events); + }); +}); diff --git a/test/replayer.test.ts b/test/replayer.test.ts index fe221921fc..896cda617b 100644 --- a/test/replayer.test.ts +++ b/test/replayer.test.ts @@ -5,115 +5,7 @@ import * as path from 'path'; import * as puppeteer from 'puppeteer'; import { expect } from 'chai'; import { Suite } from 'mocha'; -import { - EventType, - eventWithTime, - IncrementalSource, - MouseInteractions, -} from '../src/types'; -import { Replayer } from '../src'; -import { launchPuppeteer } from './utils'; - -const now = Date.now(); - -const events: eventWithTime[] = [ - { - type: EventType.DomContentLoaded, - data: {}, - timestamp: now, - }, - { - type: EventType.Load, - data: {}, - timestamp: now + 1000, - }, - { - type: EventType.Meta, - data: { - href: 'http://localhost', - width: 1000, - height: 800, - }, - timestamp: now + 1000, - }, - { - type: EventType.FullSnapshot, - data: { - node: { - type: 0, - childNodes: [ - { - type: 2, - tagName: 'html', - attributes: {}, - childNodes: [ - { - type: 2, - tagName: 'head', - attributes: {}, - childNodes: [], - id: 3, - }, - { - type: 2, - tagName: 'body', - attributes: {}, - childNodes: [], - id: 4, - }, - ], - id: 2, - }, - ], - id: 1, - }, - initialOffset: { - top: 0, - left: 0, - }, - }, - timestamp: now + 1000, - }, - { - type: EventType.IncrementalSnapshot, - data: { - source: IncrementalSource.MouseInteraction, - type: MouseInteractions.Click, - id: 1, - x: 0, - y: 0, - }, - timestamp: now + 2000, - }, - { - type: EventType.IncrementalSnapshot, - data: { - source: IncrementalSource.MouseInteraction, - type: MouseInteractions.Click, - id: 1, - x: 0, - y: 0, - }, - timestamp: now + 3000, - }, - { - type: EventType.IncrementalSnapshot, - data: { - source: IncrementalSource.MouseInteraction, - type: MouseInteractions.Click, - id: 1, - x: 0, - y: 0, - }, - timestamp: now + 4000, - }, -]; - -interface IWindow extends Window { - rrweb: { - Replayer: typeof Replayer; - }; -} +import { launchPuppeteer, sampleEvents as events } from './utils'; interface ISuite extends Suite { code: string; @@ -121,7 +13,7 @@ interface ISuite extends Suite { page: puppeteer.Page; } -describe('replayer', function(this: ISuite) { +describe('replayer', function (this: ISuite) { before(async () => { this.browser = await launchPuppeteer(); @@ -136,7 +28,7 @@ describe('replayer', function(this: ISuite) { await page.evaluate(`const events = ${JSON.stringify(events)}`); this.page = page; - page.on('console', msg => console.log('PAGE LOG:', msg.text())); + page.on('console', (msg) => console.log('PAGE LOG:', msg.text())); }); afterEach(async () => { @@ -148,60 +40,62 @@ describe('replayer', function(this: ISuite) { }); it('can get meta data', async () => { - const meta = await this.page.evaluate(() => { - const { Replayer } = ((window as unknown) as IWindow).rrweb; + const meta = await this.page.evaluate(` + const { Replayer } = rrweb; const replayer = new Replayer(events); - return replayer.getMetaData(); - }); + replayer.getMetaData(); + `); expect(meta).to.deep.equal({ + startTime: events[0].timestamp, + endTime: events[events.length - 1].timestamp, totalTime: events[events.length - 1].timestamp - events[0].timestamp, }); }); it('will start actions when play', async () => { - const actionLength = await this.page.evaluate(() => { - const { Replayer } = ((window as unknown) as IWindow).rrweb; + const actionLength = await this.page.evaluate(` + const { Replayer } = rrweb; const replayer = new Replayer(events); replayer.play(); - return replayer['timer']['actions'].length; - }); + replayer['timer']['actions'].length; + `); expect(actionLength).to.equal(events.length); }); it('will clean actions when pause', async () => { - const actionLength = await this.page.evaluate(() => { - const { Replayer } = ((window as unknown) as IWindow).rrweb; + const actionLength = await this.page.evaluate(` + const { Replayer } = rrweb; const replayer = new Replayer(events); replayer.play(); replayer.pause(); - return replayer['timer']['actions'].length; - }); + replayer['timer']['actions'].length; + `); expect(actionLength).to.equal(0); }); it('can play at any time offset', async () => { - const actionLength = await this.page.evaluate(() => { - const { Replayer } = ((window as unknown) as IWindow).rrweb; + const actionLength = await this.page.evaluate(` + const { Replayer } = rrweb; const replayer = new Replayer(events); replayer.play(1500); - return replayer['timer']['actions'].length; - }); + replayer['timer']['actions'].length; + `); expect(actionLength).to.equal( - events.filter(e => e.timestamp - events[0].timestamp >= 1500).length, + events.filter((e) => e.timestamp - events[0].timestamp >= 1500).length, ); }); it('can resume at any time offset', async () => { - const actionLength = await this.page.evaluate(() => { - const { Replayer } = ((window as unknown) as IWindow).rrweb; + const actionLength = await this.page.evaluate(` + const { Replayer } = rrweb; const replayer = new Replayer(events); replayer.play(1500); replayer.pause(); replayer.resume(1500); - return replayer['timer']['actions'].length; - }); + replayer['timer']['actions'].length; + `); expect(actionLength).to.equal( - events.filter(e => e.timestamp - events[0].timestamp >= 1500).length, + events.filter((e) => e.timestamp - events[0].timestamp >= 1500).length, ); }); }); diff --git a/test/utils.ts b/test/utils.ts index 524b22e52a..2372e2ba9c 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -1,7 +1,12 @@ import { SnapshotState, toMatchSnapshot } from 'jest-snapshot'; import { NodeType } from 'rrweb-snapshot'; import { assert } from 'chai'; -import { EventType, IncrementalSource, eventWithTime } from '../src/types'; +import { + EventType, + IncrementalSource, + eventWithTime, + MouseInteractions, +} from '../src/types'; import * as puppeteer from 'puppeteer'; export async function launchPuppeteer() { @@ -42,7 +47,7 @@ export function matchSnapshot( function stringifySnapshots(snapshots: eventWithTime[]): string { return JSON.stringify( snapshots - .filter(s => { + .filter((s) => { if ( s.type === EventType.IncrementalSnapshot && s.data.source === IncrementalSource.MouseMove @@ -51,7 +56,7 @@ function stringifySnapshots(snapshots: eventWithTime[]): string { } return true; }) - .map(s => { + .map((s) => { if (s.type === EventType.Meta) { s.data.href = 'about:blank'; } @@ -68,7 +73,7 @@ function stringifySnapshots(snapshots: eventWithTime[]): string { s.type === EventType.IncrementalSnapshot && s.data.source === IncrementalSource.Mutation ) { - s.data.attributes.forEach(a => { + s.data.attributes.forEach((a) => { if ( 'style' in a.attributes && coordinatesReg.test(a.attributes.style!) @@ -76,7 +81,7 @@ function stringifySnapshots(snapshots: eventWithTime[]): string { delete a.attributes.style; } }); - s.data.adds.forEach(add => { + s.data.adds.forEach((add) => { if ( add.node.type === NodeType.Element && 'style' in add.node.attributes && @@ -103,3 +108,97 @@ export function assertSnapshot( const result = matchSnapshot(stringifySnapshots(snapshots), filename, name); assert(result.pass, result.pass ? '' : result.report()); } + +const now = Date.now(); +export const sampleEvents: eventWithTime[] = [ + { + type: EventType.DomContentLoaded, + data: {}, + timestamp: now, + }, + { + type: EventType.Load, + data: {}, + timestamp: now + 1000, + }, + { + type: EventType.Meta, + data: { + href: 'http://localhost', + width: 1000, + height: 800, + }, + timestamp: now + 1000, + }, + { + type: EventType.FullSnapshot, + data: { + node: { + type: 0, + childNodes: [ + { + type: 2, + tagName: 'html', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [], + id: 3, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [], + id: 4, + }, + ], + id: 2, + }, + ], + id: 1, + }, + initialOffset: { + top: 0, + left: 0, + }, + }, + timestamp: now + 1000, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.MouseInteraction, + type: MouseInteractions.Click, + id: 1, + x: 0, + y: 0, + }, + timestamp: now + 2000, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.MouseInteraction, + type: MouseInteractions.Click, + id: 1, + x: 0, + y: 0, + }, + timestamp: now + 3000, + }, + { + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.MouseInteraction, + type: MouseInteractions.Click, + id: 1, + x: 0, + y: 0, + }, + timestamp: now + 4000, + }, +];