Files
rrweb/packages/rrweb/test/utils.ts
Eoghan Murray 67657a8710 Single style capture (#1437)
Support a contrived/rare case where a <style> element has multiple text node children (this is usually only possible to recreate via javascript append) ... this PR fixes cases where there are subsequent text mutations to these nodes; previously these would have been lost

* In this scenario, a new CSS comment may now be inserted into the captured `_cssText` for a <style> element to show where it should be broken up into text elements upon replay: `/* rr_split */`
* The new 'can record and replay style mutations' test is the principal way to the problematic scenarios, and is a detailed 'catch-all' test with many checks to cover most of the ways things can fail
* There are new tests for splitting/rebuilding the css using the rr_split marker
* The prior 'dynamic stylesheet' route is now the main route for serializing a stylesheet; dynamic stylesheet were missed out in #1533 but that case is now covered with this PR

This PR was originally extracted from #1475 so the  initial motivation was to change the approach on stringifying <style> elements to do so in a single place.  This is also the motivating factor for always serializing <style> elements via the `_cssText` attribute rather than in it's childNodes; in #1475 we will be delaying populating `_cssText` for performance and instead recorrding them as assets.

Thanks for the detailed review to  Justin Halsall <Juice10@users.noreply.github.com> & Yun Feng <https://github.com/YunFeng0817>
2026-04-01 12:00:00 +08:00

739 lines
20 KiB
TypeScript

import { NodeType } from 'rrweb-snapshot';
import {
EventType,
IncrementalSource,
eventWithTime,
eventWithoutTime,
MouseInteractions,
Optional,
mouseInteractionData,
pluginEvent,
} from '@rrweb/types';
import type { recordOptions } from '../src/types';
import * as puppeteer from 'puppeteer';
import { format } from 'prettier';
import * as path from 'path';
import * as http from 'http';
import * as url from 'url';
import * as fs from 'fs';
export async function launchPuppeteer(
options?: Parameters<(typeof puppeteer)['launch']>[0],
) {
return await puppeteer.launch({
headless: process.env.PUPPETEER_HEADLESS ? 'new' : false,
defaultViewport: {
width: 1920,
height: 1080,
},
args: ['--no-sandbox'],
...options,
});
}
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 = 3030) =>
new Promise<http.Server>((resolve) => {
const mimeType: IMimeType = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.webm': 'video/webm',
};
const s = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url!);
const sanitizePath = path
.normalize(parsedUrl.pathname!)
.replace(/^(\.\.[\/\\])+/, '');
let pathname = path.join(__dirname, sanitizePath);
if (/^\/rrweb.*\.c?js.*/.test(sanitizePath)) {
pathname = path.join(__dirname, `../dist/main`, sanitizePath);
}
try {
const data = fs.readFileSync(pathname);
const ext = path.parse(pathname).ext;
res.setHeader('Content-type', mimeType[ext] || 'text/plain');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET');
res.setHeader('Access-Control-Allow-Headers', 'Content-type');
if (ext === '.webm') res.setHeader('Accept-Ranges', 'bytes');
setTimeout(() => {
res.end(data);
// mock delay
}, 100);
} catch (error) {
res.end();
}
});
s.listen(defaultPort)
.on('listening', () => {
resolve(s);
})
.on('error', (e) => {
s.listen().on('listening', () => {
resolve(s);
});
});
});
export function getServerURL(server: http.Server): string {
const address = server.address();
if (address && typeof address !== 'string') {
return `http://localhost:${address.port}`;
} else {
return `${address}`;
}
}
/**
* Puppeteer may cast random mouse move which make our tests flaky.
* So we only do snapshot test with filtered events.
* Also remove timestamp from event.
* @param snapshots incrementalSnapshotEvent[]
*/
export function stringifySnapshots(snapshots: eventWithTime[]): string {
return JSON.stringify(
snapshots
.filter((s) => {
if (
// mouse move or viewport resize can happen on accidental user interference
// so we ignore them
(s.type === EventType.IncrementalSnapshot &&
(s.data.source === IncrementalSource.MouseMove ||
s.data.source === IncrementalSource.ViewportResize)) ||
// ignore '[vite] connected' messages from vite
(s.type === EventType.Plugin &&
s.data.plugin === 'rrweb/console@1' &&
(s.data.payload as { payload: string[] })?.payload?.find((msg) =>
msg.includes('[vite] connected'),
))
) {
return false;
}
return true;
})
.map((s) => {
if (s.type === EventType.Meta) {
s.data.href = 'about:blank';
}
// FIXME: travis coordinates seems different with my laptop
const coordinatesReg =
/(bottom|top|left|right|width|height): \d+(\.\d+)?px/g;
if (
s.type === EventType.IncrementalSnapshot &&
s.data.source === IncrementalSource.MouseInteraction
) {
delete (s.data as Optional<mouseInteractionData, 'x'>).x;
delete (s.data as Optional<mouseInteractionData, 'y'>).y;
}
if (
s.type === EventType.IncrementalSnapshot &&
s.data.source === IncrementalSource.Mutation
) {
s.data.attributes.forEach((a) => {
if ('style' in a.attributes && a.attributes.style) {
if (typeof a.attributes.style === 'object') {
for (const [k, v] of Object.entries(a.attributes.style)) {
if (Array.isArray(v)) {
if (coordinatesReg.test(k + ': ' + v[0])) {
// TODO: could round the number here instead depending on what's coming out of various test envs
a.attributes.style[k] = ['Npx', v[1]];
}
} else if (typeof v === 'string') {
if (coordinatesReg.test(k + ': ' + v)) {
a.attributes.style[k] = 'Npx';
}
}
coordinatesReg.lastIndex = 0; // wow, a real wart in ECMAScript
}
} else if (coordinatesReg.test(a.attributes.style)) {
a.attributes.style = a.attributes.style.replace(
coordinatesReg,
'$1: Npx',
);
}
}
// strip blob:urls as they are different every time
stripBlobURLsFromAttributes(a);
});
s.data.adds.forEach((add) => {
if (add.node.type === NodeType.Element) {
if (
'style' in add.node.attributes &&
typeof add.node.attributes.style === 'string' &&
coordinatesReg.test(add.node.attributes.style)
) {
add.node.attributes.style = add.node.attributes.style.replace(
coordinatesReg,
'$1: Npx',
);
}
coordinatesReg.lastIndex = 0; // wow, a real wart in ECMAScript
// strip blob:urls as they are different every time
stripBlobURLsFromAttributes(add.node);
// strip rr_dataURL as they are not consistent
if (
'rr_dataURL' in add.node.attributes &&
add.node.attributes.rr_dataURL &&
typeof add.node.attributes.rr_dataURL === 'string'
) {
add.node.attributes.rr_dataURL =
add.node.attributes.rr_dataURL.replace(/,.+$/, ',...');
}
}
});
} else if (
s.type === EventType.IncrementalSnapshot &&
s.data.source === IncrementalSource.MediaInteraction
) {
// round the currentTime to 1 decimal place
if (s.data.currentTime) {
s.data.currentTime = Math.round(s.data.currentTime * 10) / 10;
}
} else if (
s.type === EventType.Plugin &&
s.data.plugin === 'rrweb/console@1'
) {
const pluginPayload = (
s as pluginEvent<{
trace: string[];
payload: string[];
}>
).data.payload;
if (pluginPayload?.trace.length) {
pluginPayload.trace = pluginPayload.trace.map((trace) => {
return trace.replace(
/^pptr:evaluate;.*?:(\d+:\d+)/,
'__puppeteer_evaluation_script__:$1',
);
});
}
if (pluginPayload?.payload.length) {
pluginPayload.payload = pluginPayload.payload.map((payload) => {
return payload.replace(
/pptr:evaluate;.*?:(\d+:\d+)/g,
'__puppeteer_evaluation_script__:$1',
);
});
}
}
delete (s as Optional<eventWithTime, 'timestamp'>).timestamp;
return s as eventWithoutTime;
}),
null,
2,
).replace(
// servers might get run on a random port,
// so we need to normalize the port number
/http:\/\/localhost:\d+/g,
'http://localhost:3030',
);
}
function stripBlobURLsFromAttributes(node: {
attributes: {
[key: string]: any;
};
}) {
for (const attr in node.attributes) {
if (
typeof node.attributes[attr] === 'string' &&
node.attributes[attr].startsWith('blob:')
) {
node.attributes[attr] = node.attributes[attr]
.replace(/[\w-]+$/, '...')
.replace(/:[0-9]+\//, ':xxxx/');
}
}
}
function stringifyDomSnapshot(mhtml: string): string {
const { Parser } = require('fast-mhtml');
const resources: string[] = [];
const p = new Parser({
rewriteFn: (filename: string): string => {
const index = resources.indexOf(filename);
const prefix = /^\w+/.exec(filename);
if (index !== -1) {
return `file-${prefix}-${index}`;
} else {
return `file-${prefix}-${resources.push(filename) - 1}`;
}
},
});
const result = p
.parse(mhtml) // parse file
.rewrite() // rewrite all links
.spit(); // return all contents
const newResult: { filename: string; content: string }[] = result.map(
(asset: { filename: string; content: string }) => {
const { filename, content } = asset;
let res: string | undefined;
if (filename.includes('frame')) {
res = format(content, {
parser: 'html',
});
}
return { filename, content: res || content };
},
);
return newResult.map((asset) => Object.values(asset).join('\n')).join('\n\n');
}
export async function assertSnapshot(
snapshotsOrPage: eventWithTime[] | puppeteer.Page,
) {
let snapshots: eventWithTime[];
if (!Array.isArray(snapshotsOrPage)) {
// make sure page has finished executing js
await waitForRAF(snapshotsOrPage);
await snapshotsOrPage.waitForFunction(
'window.snapshots && window.snapshots.length > 0',
);
snapshots = (await snapshotsOrPage.evaluate(
'window.snapshots',
)) as eventWithTime[];
} else {
snapshots = snapshotsOrPage;
}
expect(snapshots).toBeDefined();
expect(stringifySnapshots(snapshots)).toMatchSnapshot();
}
export function replaceLast(str: string, find: string, replace: string) {
const index = str.lastIndexOf(find);
if (index === -1) {
return str;
}
return str.substring(0, index) + replace + str.substring(index + find.length);
}
export async function assertDomSnapshot(page: puppeteer.Page) {
const cdp = await page.target().createCDPSession();
const { data } = await cdp.send('Page.captureSnapshot', {
format: 'mhtml',
});
expect(stringifyDomSnapshot(data)).toMatchSnapshot();
}
export function stripBase64(events: eventWithTime[]) {
const base64Strings: string[] = [];
function walk<T>(obj: T): T {
if (!obj || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map((e) => walk(e)) as unknown as T;
const newObj: Partial<T> = {};
for (const prop in obj) {
const value = obj[prop];
if (prop === 'base64' && typeof value === 'string') {
let index = base64Strings.indexOf(value);
if (index === -1) {
index = base64Strings.push(value) - 1;
}
(newObj as any)[prop] = `base64-${index}`;
} else {
(newObj as any)[prop] = walk(value);
}
}
return newObj as T;
}
return events.map((evt) => {
if (
evt.type === EventType.IncrementalSnapshot &&
evt.data.source === IncrementalSource.CanvasMutation
) {
const newData = walk(evt.data);
return { ...evt, data: newData };
}
return evt;
});
}
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,
},
];
export const sampleStyleSheetRemoveEvents: 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: [
{
type: 2,
tagName: 'style',
attributes: {
'data-jss': '',
'data-meta': 'OverlayDrawer',
_cssText:
'.OverlayDrawer-modal-187 { }.OverlayDrawer-paper-188 { width: 100%; }@media (min-width: 48em) {\n .OverlayDrawer-paper-188 { width: 38rem; }\n}@media (min-width: 48em) {\n}@media (min-width: 48em) {\n}',
},
childNodes: [
{
type: 3,
textContent: '\n',
isStyle: true,
id: 5,
},
],
id: 4,
},
],
id: 3,
},
{
type: 2,
tagName: 'body',
attributes: {},
childNodes: [],
id: 6,
},
],
id: 2,
},
],
id: 1,
},
initialOffset: {
top: 0,
left: 0,
},
},
timestamp: now + 1000,
},
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
texts: [],
attributes: [],
removes: [
{
parentId: 3,
id: 4,
},
],
adds: [],
},
timestamp: now + 2000,
},
];
export const polyfillWebGLGlobals = () => {
// polyfill as jsdom does not have support for these classes
// consider replacing with https://www.npmjs.com/package/canvas
class WebGLActiveInfo {
constructor() {}
}
global.WebGLActiveInfo = WebGLActiveInfo as any;
class WebGLBuffer {
constructor() {}
}
global.WebGLBuffer = WebGLBuffer as any;
class WebGLFramebuffer {
constructor() {}
}
global.WebGLFramebuffer = WebGLFramebuffer as any;
class WebGLProgram {
constructor() {}
}
global.WebGLProgram = WebGLProgram as any;
class WebGLRenderbuffer {
constructor() {}
}
global.WebGLRenderbuffer = WebGLRenderbuffer as any;
class WebGLShader {
constructor() {}
}
global.WebGLShader = WebGLShader as any;
class WebGLShaderPrecisionFormat {
constructor() {}
}
global.WebGLShaderPrecisionFormat = WebGLShaderPrecisionFormat as any;
class WebGLTexture {
constructor() {}
}
global.WebGLTexture = WebGLTexture as any;
class WebGLUniformLocation {
constructor() {}
}
global.WebGLUniformLocation = WebGLUniformLocation as any;
class WebGLVertexArrayObject {
constructor() {}
}
global.WebGLVertexArrayObject = WebGLVertexArrayObject as any;
class ImageData {
public data: Uint8ClampedArray;
public width: number;
public height: number;
constructor(data: Uint8ClampedArray, width: number, height: number) {
this.data = data;
this.width = width;
this.height = height;
}
}
global.ImageData = ImageData as any;
class WebGL2RenderingContext {
constructor() {}
}
global.WebGL2RenderingContext = WebGL2RenderingContext as any;
};
export async function waitForRAF(
pageOrFrame: puppeteer.Page | puppeteer.Frame,
) {
return await pageOrFrame.evaluate(() => {
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);
});
});
});
}
export async function waitForIFrameLoad(
page: puppeteer.Frame | puppeteer.Page,
iframeSelector: string,
timeout = 10000,
): Promise<puppeteer.Frame> {
const el = await page.waitForSelector(iframeSelector);
if (!el)
throw new Error('Waiting for iframe load has timed out - no element found');
let frame = await el.contentFrame();
if (frame && frame.isDetached()) {
throw new Error(
'Waiting for iframe load has timed out - frame is detached',
);
}
if (frame && frame.url() !== '') {
return frame;
}
await page.$eval(
iframeSelector,
(el, timeout) => {
const p = new Promise((resolve, reject) => {
(el as HTMLIFrameElement).onload = () => {
resolve(el as HTMLIFrameElement);
};
setTimeout(() => {
reject(
new Error(
'Waiting for iframe load has timed out - onload not fired',
),
);
}, timeout);
});
return p;
},
timeout,
);
frame = await el.contentFrame();
if (!frame)
throw new Error('Waiting for iframe load has timed out - no frame found');
return frame;
}
export function generateRecordSnippet(options: recordOptions<eventWithTime>) {
return `
rrweb.record({
emit: event => {
if (!window.snapshots) window.snapshots = [];
window.snapshots.push(event);
},
ignoreSelector: ${JSON.stringify(options.ignoreSelector)},
maskTextSelector: ${JSON.stringify(options.maskTextSelector)},
maskAllInputs: ${options.maskAllInputs},
maskInputOptions: ${JSON.stringify(options.maskAllInputs)},
userTriggeredOnInput: ${options.userTriggeredOnInput},
maskTextClass: ${options.maskTextClass},
maskTextFn: ${options.maskTextFn},
maskInputFn: ${options.maskInputFn},
recordCanvas: ${options.recordCanvas},
recordAfter: '${options.recordAfter || 'load'}',
inlineImages: ${options.inlineImages},
plugins: ${options.plugins}
});
`;
}
export async function hideMouseAnimation(p: puppeteer.Page): Promise<void> {
await p.addStyleTag({
content: `.replayer-mouse-tail{display: none !important;}
html, body { margin: 0; padding: 0; }
iframe { border: none; }`,
});
}
export const fakeGoto = async (p: puppeteer.Page, url: string) => {
const intercept = async (request: puppeteer.HTTPRequest) => {
await request.respond({
status: 200,
contentType: 'text/html',
body: ' ', // non-empty string or page will load indefinitely
});
};
await p.setRequestInterception(true);
p.on('request', intercept);
await p.goto(url);
p.off('request', intercept);
await p.setRequestInterception(false);
};