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:
yz-yu
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 2887c8c7e5
commit 4cfd9db6cf
8 changed files with 215 additions and 73 deletions

View File

@@ -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) {

View File

@@ -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,

View 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(', '),
},
]);
});
}
});

View File

@@ -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;}',
}); });
}; };

View 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>

View File

@@ -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;
}); });

View File

@@ -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}
});
`;
}

View File

@@ -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": []
} }