diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 9ccb67b52d..dded72ebd1 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -445,8 +445,15 @@ function serializeNode( ); const tagName = getValidTagName(n as HTMLElement); let attributes: attributes = {}; - for (const { name, value } of Array.from((n as HTMLElement).attributes)) { - attributes[name] = transformAttribute(doc, tagName, name, value); + const len = (n as HTMLElement).attributes.length; + for (let i = 0; i < len; i++) { + const attr = (n as HTMLElement).attributes[i]; + attributes[attr.name] = transformAttribute( + doc, + tagName, + attr.name, + attr.value, + ); } // remote css if (tagName === 'link' && inlineStylesheet) { diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 80718f2589..fb14f8eaeb 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -281,7 +281,7 @@ export default class MutationBuffer { if (parentId === -1 || nextId === -1) { return addList.addNode(n); } - let sn = serializeNodeWithId(n, { + const sn = serializeNodeWithId(n, { doc: this.doc, mirror: this.mirror, blockClass: this.blockClass, diff --git a/packages/rrweb/test/benchmark/dom-mutation.test.ts b/packages/rrweb/test/benchmark/dom-mutation.test.ts new file mode 100644 index 0000000000..d2883ad0ae --- /dev/null +++ b/packages/rrweb/test/benchmark/dom-mutation.test.ts @@ -0,0 +1,117 @@ +// tslint:disable:no-console no-any +import * as fs from 'fs'; +import * as path from 'path'; +import type { eventWithTime, recordOptions } from '../../src/types'; +import { startServer, launchPuppeteer, replaceLast, ISuite } from '../utils'; + +function avg(v: number[]): number { + return v.reduce((prev, cur) => prev + cur, 0) / v.length; +} + +describe('benchmark: mutation observer', () => { + let code: ISuite['code']; + let page: ISuite['page']; + let browser: ISuite['browser']; + let server: ISuite['server']; + + beforeAll(async () => { + server = await startServer(); + browser = await launchPuppeteer({ + dumpio: true, + headless: true, + }); + + const bundlePath = path.resolve(__dirname, '../../dist/rrweb.min.js'); + code = fs.readFileSync(bundlePath, 'utf8'); + }); + + afterEach(async () => { + await page.close(); + }); + + afterAll(async () => { + server.close(); + await browser.close(); + }); + + const getHtml = (fileName: string): string => { + const filePath = path.resolve(__dirname, `../html/${fileName}`); + const html = fs.readFileSync(filePath, 'utf8'); + return replaceLast( + html, + '', + ` + + + `, + ); + }; + + const suites: { + title: string; + html: string; + times?: number; // default to 5 + }[] = [ + { + title: 'create 1000x10 DOM nodes', + html: 'benchmark-dom-mutation.html', + times: 10, + }, + ]; + + for (const suite of suites) { + it(suite.title, async () => { + page = await browser.newPage(); + page.on('console', (message) => + console.log(`${message.type().toUpperCase()} ${message.text()}`), + ); + + const times = suite.times ?? 5; + const durations: number[] = []; + for (let i = 0; i < times; i++) { + await page.goto('about:blank'); + await page.setContent(getHtml.call(this, suite.html)); + const duration = (await page.evaluate(() => { + return new Promise((resolve, reject) => { + let start = 0; + let lastEvent: eventWithTime | null; + const options: recordOptions = { + emit: (event) => { + // console.log(event.type, event.timestamp); + if (event.type !== 5 || event.data.tag !== 'FTAG') { + lastEvent = event; + return; + } + if (!lastEvent) { + reject('no events recorded'); + return; + } + resolve(lastEvent.timestamp - start); + }, + }; + const record = (window as any).rrweb.record; + record(options); + + (window as any).workload(); + + start = Date.now(); + setTimeout(() => { + record.addCustomEvent('FTAG', {}); + }, 0); + }); + })) as number; + durations.push(duration); + } + + console.table([ + { + ...suite, + duration: avg(durations), + durations: durations.join(', '), + }, + ]); + }); + } +}); diff --git a/packages/rrweb/test/e2e/webgl.test.ts b/packages/rrweb/test/e2e/webgl.test.ts index f77c92b193..1acdcc0037 100644 --- a/packages/rrweb/test/e2e/webgl.test.ts +++ b/packages/rrweb/test/e2e/webgl.test.ts @@ -1,4 +1,3 @@ -import type * as http from 'http'; import * as fs from 'fs'; import * as path from 'path'; import type * as puppeteer from 'puppeteer'; @@ -8,20 +7,13 @@ import { getServerURL, replaceLast, waitForRAF, + generateRecordSnippet, + ISuite, } from '../utils'; import type { recordOptions, eventWithTime } from '../../src/types'; import { toMatchImageSnapshot } from 'jest-image-snapshot'; expect.extend({ toMatchImageSnapshot }); -interface ISuite { - code: string; - browser: puppeteer.Browser; - server: http.Server; - page: puppeteer.Page; - events: eventWithTime[]; - serverURL: string; -} - describe('e2e webgl', () => { let code: ISuite['code']; let page: ISuite['page']; @@ -59,26 +51,14 @@ describe('e2e webgl', () => { ` `, ); }; - const fakeGoto = async (page: puppeteer.Page, url: string) => { + const fakeGoto = async (p: puppeteer.Page, url: string) => { const intercept = async (request: puppeteer.HTTPRequest) => { await request.respond({ status: 200, @@ -86,15 +66,15 @@ describe('e2e webgl', () => { body: ' ', // non-empty string or page will load indefinitely }); }; - await page.setRequestInterception(true); - page.on('request', intercept); - await page.goto(url); - page.off('request', intercept); - await page.setRequestInterception(false); + await p.setRequestInterception(true); + p.on('request', intercept); + await p.goto(url); + p.off('request', intercept); + await p.setRequestInterception(false); }; - const hideMouseAnimation = async (page: puppeteer.Page) => { - await page.addStyleTag({ + const hideMouseAnimation = async (p: puppeteer.Page) => { + await p.addStyleTag({ content: '.replayer-mouse-tail{display: none !important;}', }); }; diff --git a/packages/rrweb/test/html/benchmark-dom-mutation.html b/packages/rrweb/test/html/benchmark-dom-mutation.html new file mode 100644 index 0000000000..2921964c86 --- /dev/null +++ b/packages/rrweb/test/html/benchmark-dom-mutation.html @@ -0,0 +1,27 @@ + + + + diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 4c92be6218..797d537346 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -1,6 +1,6 @@ +// tslint:disable:no-console import * as fs from 'fs'; import * as path from 'path'; -import type * as http from 'http'; import type * as puppeteer from 'puppeteer'; import { assertSnapshot, @@ -9,21 +9,12 @@ import { launchPuppeteer, waitForRAF, replaceLast, + generateRecordSnippet, + ISuite, } from './utils'; import { recordOptions, eventWithTime, EventType } from '../src/types'; import { visitSnapshot, NodeType } from 'rrweb-snapshot'; -interface ISuite { - server: http.Server; - serverURL: string; - code: string; - browser: puppeteer.Browser; -} - -interface IMimeType { - [key: string]: string; -} - describe('record integration tests', function (this: ISuite) { jest.setTimeout(10_000); @@ -40,19 +31,7 @@ describe('record integration tests', function (this: ISuite) { `, @@ -73,7 +52,7 @@ describe('record integration tests', function (this: ISuite) { const pluginsCode = [ path.resolve(__dirname, '../dist/plugins/console-record.min.js'), ] - .map((path) => fs.readFileSync(path, 'utf8')) + .map((p) => fs.readFileSync(p, 'utf8')) .join(); code = fs.readFileSync(bundlePath, 'utf8') + pluginsCode; }); diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index 89f62c3fa4..2eb3a6a881 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -1,3 +1,4 @@ +// tslint:disable:no-console no-any import { NodeType } from 'rrweb-snapshot'; import { EventType, @@ -7,6 +8,7 @@ import { Optional, mouseInteractionData, event, + recordOptions, } from '../src/types'; import * as puppeteer from 'puppeteer'; import { format } from 'prettier'; @@ -15,15 +17,17 @@ import * as http from 'http'; import * as url from 'url'; import * as fs from 'fs'; -export async function launchPuppeteer() { +export async function launchPuppeteer( + options?: Parameters[0], +) { return await puppeteer.launch({ headless: process.env.PUPPETEER_HEADLESS ? true : false, defaultViewport: { width: 1920, height: 1080, }, - // devtools: true, args: ['--no-sandbox'], + ...options, }); } @@ -31,6 +35,15 @@ interface IMimeType { [key: string]: string; } +export interface ISuite { + server: http.Server; + serverURL: string; + code: string; + browser: puppeteer.Browser; + page: puppeteer.Page; + events: eventWithTime[]; +} + export const startServer = (defaultPort: number = 3030) => new Promise((resolve) => { const mimeType: IMimeType = { @@ -43,7 +56,7 @@ export const startServer = (defaultPort: number = 3030) => const sanitizePath = path .normalize(parsedUrl.pathname!) .replace(/^(\.\.[\/\\])+/, ''); - let pathname = path.join(__dirname, sanitizePath); + const pathname = path.join(__dirname, sanitizePath); try { const data = fs.readFileSync(pathname); @@ -179,9 +192,9 @@ function stringifyDomSnapshot(mhtml: string): string { .rewrite() // rewrite all links .spit(); // return all contents - const newResult: Array<{ filename: string; content: string }> = result.map( + const newResult: { filename: string; content: string }[] = result.map( (asset: { filename: string; content: string }) => { - let { filename, content } = asset; + const { filename, content } = asset; let res: string | undefined; if (filename.includes('frame')) { res = format(content, { @@ -226,7 +239,7 @@ export function stripBase64(events: eventWithTime[]) { if (!obj || typeof obj !== 'object') return obj; if (Array.isArray(obj)) return (obj.map((e) => walk(e)) as unknown) as T; const newObj: Partial = {}; - for (let prop in obj) { + for (const prop in obj) { const value = obj[prop]; if (prop === 'base64' && typeof value === 'string') { let index = base64Strings.indexOf(value); @@ -241,15 +254,15 @@ export function stripBase64(events: eventWithTime[]) { return newObj as T; } - return events.map((event) => { + return events.map((evt) => { if ( - event.type === EventType.IncrementalSnapshot && - event.data.source === IncrementalSource.CanvasMutation + evt.type === EventType.IncrementalSnapshot && + evt.data.source === IncrementalSource.CanvasMutation ) { - const newData = walk(event.data); - return { ...event, data: newData }; + const newData = walk(evt.data); + return { ...evt, data: newData }; } - return event; + return evt; }); } @@ -525,3 +538,21 @@ export async function waitForRAF(page: puppeteer.Page) { }); }); } + +export function generateRecordSnippet(options: recordOptions) { + return ` + window.snapshots = []; + rrweb.record({ + emit: event => { + window.snapshots.push(event); + }, + maskTextSelector: ${JSON.stringify(options.maskTextSelector)}, + maskAllInputs: ${options.maskAllInputs}, + maskInputOptions: ${JSON.stringify(options.maskAllInputs)}, + userTriggeredOnInput: ${options.userTriggeredOnInput}, + maskTextFn: ${options.maskTextFn}, + recordCanvas: ${options.recordCanvas}, + plugins: ${options.plugins} + }); + `; +} diff --git a/packages/rrweb/tslint.json b/packages/rrweb/tslint.json index ac74b2e5d7..c49e23ea37 100644 --- a/packages/rrweb/tslint.json +++ b/packages/rrweb/tslint.json @@ -24,7 +24,8 @@ "trailing-comma": false, "curly": false, "no-namespace": false, - "interface-name": false + "interface-name": false, + "forin": false }, "rulesDirectory": [] }