Introduce benchmark tests and improve snapshot attributes traversing (#897)
* housekeeping: refine test utils * setup benchmark tests * improve snapshot attributes loop perf
This commit is contained in:
@@ -445,8 +445,15 @@ function serializeNode(
|
|||||||
);
|
);
|
||||||
const tagName = getValidTagName(n as HTMLElement);
|
const tagName = getValidTagName(n as HTMLElement);
|
||||||
let attributes: attributes = {};
|
let attributes: attributes = {};
|
||||||
for (const { name, value } of Array.from((n as HTMLElement).attributes)) {
|
const len = (n as HTMLElement).attributes.length;
|
||||||
attributes[name] = transformAttribute(doc, tagName, name, value);
|
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
|
// remote css
|
||||||
if (tagName === 'link' && inlineStylesheet) {
|
if (tagName === 'link' && inlineStylesheet) {
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ export default class MutationBuffer {
|
|||||||
if (parentId === -1 || nextId === -1) {
|
if (parentId === -1 || nextId === -1) {
|
||||||
return addList.addNode(n);
|
return addList.addNode(n);
|
||||||
}
|
}
|
||||||
let sn = serializeNodeWithId(n, {
|
const sn = serializeNodeWithId(n, {
|
||||||
doc: this.doc,
|
doc: this.doc,
|
||||||
mirror: this.mirror,
|
mirror: this.mirror,
|
||||||
blockClass: this.blockClass,
|
blockClass: this.blockClass,
|
||||||
|
|||||||
117
packages/rrweb/test/benchmark/dom-mutation.test.ts
Normal file
117
packages/rrweb/test/benchmark/dom-mutation.test.ts
Normal file
@@ -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,
|
||||||
|
'</body>',
|
||||||
|
`
|
||||||
|
<script>
|
||||||
|
${code}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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<eventWithTime> = {
|
||||||
|
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(', '),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import type * as http from 'http';
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type * as puppeteer from 'puppeteer';
|
import type * as puppeteer from 'puppeteer';
|
||||||
@@ -8,20 +7,13 @@ import {
|
|||||||
getServerURL,
|
getServerURL,
|
||||||
replaceLast,
|
replaceLast,
|
||||||
waitForRAF,
|
waitForRAF,
|
||||||
|
generateRecordSnippet,
|
||||||
|
ISuite,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import type { recordOptions, eventWithTime } from '../../src/types';
|
import type { recordOptions, eventWithTime } from '../../src/types';
|
||||||
import { toMatchImageSnapshot } from 'jest-image-snapshot';
|
import { toMatchImageSnapshot } from 'jest-image-snapshot';
|
||||||
expect.extend({ toMatchImageSnapshot });
|
expect.extend({ toMatchImageSnapshot });
|
||||||
|
|
||||||
interface ISuite {
|
|
||||||
code: string;
|
|
||||||
browser: puppeteer.Browser;
|
|
||||||
server: http.Server;
|
|
||||||
page: puppeteer.Page;
|
|
||||||
events: eventWithTime[];
|
|
||||||
serverURL: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('e2e webgl', () => {
|
describe('e2e webgl', () => {
|
||||||
let code: ISuite['code'];
|
let code: ISuite['code'];
|
||||||
let page: ISuite['page'];
|
let page: ISuite['page'];
|
||||||
@@ -59,26 +51,14 @@ describe('e2e webgl', () => {
|
|||||||
`
|
`
|
||||||
<script>
|
<script>
|
||||||
${code}
|
${code}
|
||||||
window.snapshots = [];
|
${generateRecordSnippet(options)}
|
||||||
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}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fakeGoto = async (page: puppeteer.Page, url: string) => {
|
const fakeGoto = async (p: puppeteer.Page, url: string) => {
|
||||||
const intercept = async (request: puppeteer.HTTPRequest) => {
|
const intercept = async (request: puppeteer.HTTPRequest) => {
|
||||||
await request.respond({
|
await request.respond({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -86,15 +66,15 @@ describe('e2e webgl', () => {
|
|||||||
body: ' ', // non-empty string or page will load indefinitely
|
body: ' ', // non-empty string or page will load indefinitely
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
await page.setRequestInterception(true);
|
await p.setRequestInterception(true);
|
||||||
page.on('request', intercept);
|
p.on('request', intercept);
|
||||||
await page.goto(url);
|
await p.goto(url);
|
||||||
page.off('request', intercept);
|
p.off('request', intercept);
|
||||||
await page.setRequestInterception(false);
|
await p.setRequestInterception(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hideMouseAnimation = async (page: puppeteer.Page) => {
|
const hideMouseAnimation = async (p: puppeteer.Page) => {
|
||||||
await page.addStyleTag({
|
await p.addStyleTag({
|
||||||
content: '.replayer-mouse-tail{display: none !important;}',
|
content: '.replayer-mouse-tail{display: none !important;}',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
27
packages/rrweb/test/html/benchmark-dom-mutation.html
Normal file
27
packages/rrweb/test/html/benchmark-dom-mutation.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<html>
|
||||||
|
<body></body>
|
||||||
|
<script>
|
||||||
|
window.workload = () => {
|
||||||
|
const branches = 1000;
|
||||||
|
const depth = 10;
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (let b = 0; b < branches; b++) {
|
||||||
|
const node = document.createElement('div');
|
||||||
|
let d = 0;
|
||||||
|
node.setAttribute('branch', b.toString());
|
||||||
|
node.setAttribute('depth', d.toString());
|
||||||
|
let current = node;
|
||||||
|
while (d < depth - 1) {
|
||||||
|
d++;
|
||||||
|
const child = document.createElement('div');
|
||||||
|
child.setAttribute('branch', b.toString());
|
||||||
|
child.setAttribute('depth', d.toString());
|
||||||
|
current.appendChild(child);
|
||||||
|
current = child;
|
||||||
|
}
|
||||||
|
frag.appendChild(node);
|
||||||
|
}
|
||||||
|
document.body.appendChild(frag);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</html>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
// tslint:disable:no-console
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type * as http from 'http';
|
|
||||||
import type * as puppeteer from 'puppeteer';
|
import type * as puppeteer from 'puppeteer';
|
||||||
import {
|
import {
|
||||||
assertSnapshot,
|
assertSnapshot,
|
||||||
@@ -9,21 +9,12 @@ import {
|
|||||||
launchPuppeteer,
|
launchPuppeteer,
|
||||||
waitForRAF,
|
waitForRAF,
|
||||||
replaceLast,
|
replaceLast,
|
||||||
|
generateRecordSnippet,
|
||||||
|
ISuite,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import { recordOptions, eventWithTime, EventType } from '../src/types';
|
import { recordOptions, eventWithTime, EventType } from '../src/types';
|
||||||
import { visitSnapshot, NodeType } from 'rrweb-snapshot';
|
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) {
|
describe('record integration tests', function (this: ISuite) {
|
||||||
jest.setTimeout(10_000);
|
jest.setTimeout(10_000);
|
||||||
|
|
||||||
@@ -40,19 +31,7 @@ describe('record integration tests', function (this: ISuite) {
|
|||||||
<script>
|
<script>
|
||||||
${code}
|
${code}
|
||||||
window.Date.now = () => new Date(Date.UTC(2018, 10, 15, 8)).valueOf();
|
window.Date.now = () => new Date(Date.UTC(2018, 10, 15, 8)).valueOf();
|
||||||
window.snapshots = [];
|
${generateRecordSnippet(options)}
|
||||||
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}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
`,
|
`,
|
||||||
@@ -73,7 +52,7 @@ describe('record integration tests', function (this: ISuite) {
|
|||||||
const pluginsCode = [
|
const pluginsCode = [
|
||||||
path.resolve(__dirname, '../dist/plugins/console-record.min.js'),
|
path.resolve(__dirname, '../dist/plugins/console-record.min.js'),
|
||||||
]
|
]
|
||||||
.map((path) => fs.readFileSync(path, 'utf8'))
|
.map((p) => fs.readFileSync(p, 'utf8'))
|
||||||
.join();
|
.join();
|
||||||
code = fs.readFileSync(bundlePath, 'utf8') + pluginsCode;
|
code = fs.readFileSync(bundlePath, 'utf8') + pluginsCode;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// tslint:disable:no-console no-any
|
||||||
import { NodeType } from 'rrweb-snapshot';
|
import { NodeType } from 'rrweb-snapshot';
|
||||||
import {
|
import {
|
||||||
EventType,
|
EventType,
|
||||||
@@ -7,6 +8,7 @@ import {
|
|||||||
Optional,
|
Optional,
|
||||||
mouseInteractionData,
|
mouseInteractionData,
|
||||||
event,
|
event,
|
||||||
|
recordOptions,
|
||||||
} from '../src/types';
|
} from '../src/types';
|
||||||
import * as puppeteer from 'puppeteer';
|
import * as puppeteer from 'puppeteer';
|
||||||
import { format } from 'prettier';
|
import { format } from 'prettier';
|
||||||
@@ -15,15 +17,17 @@ import * as http from 'http';
|
|||||||
import * as url from 'url';
|
import * as url from 'url';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
|
||||||
export async function launchPuppeteer() {
|
export async function launchPuppeteer(
|
||||||
|
options?: Parameters<typeof puppeteer['launch']>[0],
|
||||||
|
) {
|
||||||
return await puppeteer.launch({
|
return await puppeteer.launch({
|
||||||
headless: process.env.PUPPETEER_HEADLESS ? true : false,
|
headless: process.env.PUPPETEER_HEADLESS ? true : false,
|
||||||
defaultViewport: {
|
defaultViewport: {
|
||||||
width: 1920,
|
width: 1920,
|
||||||
height: 1080,
|
height: 1080,
|
||||||
},
|
},
|
||||||
// devtools: true,
|
|
||||||
args: ['--no-sandbox'],
|
args: ['--no-sandbox'],
|
||||||
|
...options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +35,15 @@ interface IMimeType {
|
|||||||
[key: string]: string;
|
[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) =>
|
export const startServer = (defaultPort: number = 3030) =>
|
||||||
new Promise<http.Server>((resolve) => {
|
new Promise<http.Server>((resolve) => {
|
||||||
const mimeType: IMimeType = {
|
const mimeType: IMimeType = {
|
||||||
@@ -43,7 +56,7 @@ export const startServer = (defaultPort: number = 3030) =>
|
|||||||
const sanitizePath = path
|
const sanitizePath = path
|
||||||
.normalize(parsedUrl.pathname!)
|
.normalize(parsedUrl.pathname!)
|
||||||
.replace(/^(\.\.[\/\\])+/, '');
|
.replace(/^(\.\.[\/\\])+/, '');
|
||||||
let pathname = path.join(__dirname, sanitizePath);
|
const pathname = path.join(__dirname, sanitizePath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = fs.readFileSync(pathname);
|
const data = fs.readFileSync(pathname);
|
||||||
@@ -179,9 +192,9 @@ function stringifyDomSnapshot(mhtml: string): string {
|
|||||||
.rewrite() // rewrite all links
|
.rewrite() // rewrite all links
|
||||||
.spit(); // return all contents
|
.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 }) => {
|
(asset: { filename: string; content: string }) => {
|
||||||
let { filename, content } = asset;
|
const { filename, content } = asset;
|
||||||
let res: string | undefined;
|
let res: string | undefined;
|
||||||
if (filename.includes('frame')) {
|
if (filename.includes('frame')) {
|
||||||
res = format(content, {
|
res = format(content, {
|
||||||
@@ -226,7 +239,7 @@ export function stripBase64(events: eventWithTime[]) {
|
|||||||
if (!obj || typeof obj !== 'object') return obj;
|
if (!obj || typeof obj !== 'object') return obj;
|
||||||
if (Array.isArray(obj)) return (obj.map((e) => walk(e)) as unknown) as T;
|
if (Array.isArray(obj)) return (obj.map((e) => walk(e)) as unknown) as T;
|
||||||
const newObj: Partial<T> = {};
|
const newObj: Partial<T> = {};
|
||||||
for (let prop in obj) {
|
for (const prop in obj) {
|
||||||
const value = obj[prop];
|
const value = obj[prop];
|
||||||
if (prop === 'base64' && typeof value === 'string') {
|
if (prop === 'base64' && typeof value === 'string') {
|
||||||
let index = base64Strings.indexOf(value);
|
let index = base64Strings.indexOf(value);
|
||||||
@@ -241,15 +254,15 @@ export function stripBase64(events: eventWithTime[]) {
|
|||||||
return newObj as T;
|
return newObj as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
return events.map((event) => {
|
return events.map((evt) => {
|
||||||
if (
|
if (
|
||||||
event.type === EventType.IncrementalSnapshot &&
|
evt.type === EventType.IncrementalSnapshot &&
|
||||||
event.data.source === IncrementalSource.CanvasMutation
|
evt.data.source === IncrementalSource.CanvasMutation
|
||||||
) {
|
) {
|
||||||
const newData = walk(event.data);
|
const newData = walk(evt.data);
|
||||||
return { ...event, data: newData };
|
return { ...evt, data: newData };
|
||||||
}
|
}
|
||||||
return event;
|
return evt;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,3 +538,21 @@ export async function waitForRAF(page: puppeteer.Page) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateRecordSnippet(options: recordOptions<eventWithTime>) {
|
||||||
|
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}
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
"trailing-comma": false,
|
"trailing-comma": false,
|
||||||
"curly": false,
|
"curly": false,
|
||||||
"no-namespace": false,
|
"no-namespace": false,
|
||||||
"interface-name": false
|
"interface-name": false,
|
||||||
|
"forin": false
|
||||||
},
|
},
|
||||||
"rulesDirectory": []
|
"rulesDirectory": []
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user