Files
rrweb/packages/all/test/utils.ts
Eoghan Murray 241ca72f0e Testing: disable puppeteer/chrome sandbox in Github Actions for Ubuntu 24.04 (#1657)
--disable-setuid-sandbox deemed acceptable by Yun as it's for Github Actions only
2026-04-01 12:00:00 +08:00

279 lines
8.6 KiB
TypeScript

import { expect } from 'vitest';
import {
NodeType,
EventType,
IncrementalSource,
eventWithTime,
Optional,
mouseInteractionData,
event,
pluginEvent,
} from '@rrweb/types';
import * as puppeteer from 'puppeteer';
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', '--disable-setuid-sandbox'],
...options,
});
}
interface IMimeType {
[key: string]: string;
}
export const startServer = (defaultPort = 3030) =>
new Promise<http.Server>((resolve) => {
const mimeType: IMimeType = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
};
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`, 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');
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[]
*/
function stringifySnapshots(snapshots: eventWithTime[]): string {
return JSON.stringify(
snapshots
.filter((s) => {
if (
s.type === EventType.IncrementalSnapshot &&
(s.data.source === IncrementalSource.MouseMove ||
s.data.source === IncrementalSource.ViewportResize)
) {
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 event;
}),
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: {
src?: string;
};
}) {
if (
'src' in node.attributes &&
node.attributes.src &&
typeof node.attributes.src === 'string' &&
node.attributes.src.startsWith('blob:')
) {
node.attributes.src = node.attributes.src
.replace(/[\w-]+$/, '...')
.replace(/:[0-9]+\//, ':xxxx/');
}
}
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 async function waitForRAF(
pageOrFrame: puppeteer.Page | puppeteer.Frame,
) {
return await pageOrFrame.evaluate(() => {
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);
});
});
});
}