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..a30a9a5507 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[]; @@ -74,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; @@ -170,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 || @@ -194,6 +210,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 +226,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(); + } +} 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, + }, +];