mirror of
https://github.com/hanzoai/insights-js.git
synced 2026-10-30 18:34:19 +00:00
* chore: move posthog-rrweb into the monorepo Imports the posthog-rrweb fork (currently published as v0.0.60) into packages/rrweb/ so that the rrweb packages release from this repo via the existing changesets pipeline. This collapses the current two-step publish flow (posthog-rrweb -> npm -> posthog-js) into a single monorepo release. Scope of the move: - Snapshot taken from posthog-rrweb@main; rrvideo, rrweb-player and web-extension are intentionally dropped because we do not consume them from posthog-js. - The seven packages we publish today continue to publish from this repo: @posthog/rrweb, @posthog/rrweb-types, @posthog/rrweb-utils, @posthog/rrdom, @posthog/rrweb-snapshot, @posthog/rrweb-record and @posthog/rrweb-plugin-console-record. - Internal-only packages (rrdom-nodejs, @posthog/rrweb-replay, @posthog/rrweb-all, @posthog/rrweb-packer, the experimental plugins) are listed under changesets ignore. - packages/browser now depends on @posthog/rrweb-record, @posthog/rrweb-plugin-console-record and @posthog/rrweb-types via workspace:* instead of pinned npm versions. Tooling/config updates: - pnpm-workspace.yaml gains packages/rrweb/* and packages/rrweb/plugins/* globs and a small catalog for vite, vite-plugin-dts and esbuild-plugin-umd-wrapper. - turbo.json picks up vite.config.* as build inputs and adds prepublish / check-types tasks used by the rrweb subpackages. - Root devDependencies hoist esbuild, esbuild-plugin-umd-wrapper and rollup-plugin-visualizer so vite builds resolve correctly. - .npmrc adds a few trust-policy-exclude entries for transitive deps brought in by the rrweb tree that lost provenance attestations. - release.yml extends the publish matrix to the seven publishable rrweb packages. - library-ci.yml adds an rrweb job (vitest + puppeteer under xvfb) that only runs when packages/rrweb/** changes. No runtime behavior changes are intended: the recorder bundle in packages/browser is byte-identical to main (verified locally), and the published rrweb packages get a single patch bump via the accompanying changeset. * stop using rrweb npm package and point imports to local rrweb * fix tests * fix tests * fix docker build * fix docker build * silence false positives * fix for tue's comment * pin package version * add minimal permissions to silence alert
752 lines
24 KiB
TypeScript
752 lines
24 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as http from 'http';
|
|
import * as path from 'path';
|
|
import * as puppeteer from 'puppeteer';
|
|
import * as url from 'url';
|
|
import {
|
|
afterAll,
|
|
assert,
|
|
beforeAll,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi,
|
|
} from 'vitest';
|
|
|
|
import { getServerURL, waitForRAF } from './utils';
|
|
|
|
const htmlFolder = path.join(__dirname, 'html');
|
|
const htmls = fs.readdirSync(htmlFolder).map((filePath) => {
|
|
const raw = fs.readFileSync(path.resolve(htmlFolder, filePath), 'utf-8');
|
|
return {
|
|
filePath,
|
|
src: raw,
|
|
};
|
|
});
|
|
|
|
interface IMimeType {
|
|
[key: string]: string;
|
|
}
|
|
|
|
const startServer = (defaultPort: number = 3030) =>
|
|
new Promise<http.Server>((resolve) => {
|
|
const mimeType: IMimeType = {
|
|
'.html': 'text/html',
|
|
'.js': 'text/javascript',
|
|
'.css': 'text/css',
|
|
'.png': 'image/png',
|
|
};
|
|
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);
|
|
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');
|
|
res.end(data);
|
|
} catch (error) {
|
|
res.end();
|
|
}
|
|
});
|
|
s.listen(defaultPort)
|
|
.on('listening', () => {
|
|
resolve(s);
|
|
})
|
|
.on('error', (e) => {
|
|
s.listen().on('listening', () => {
|
|
resolve(s);
|
|
});
|
|
});
|
|
});
|
|
|
|
function sanitizeSnapshot(snapshot: string): string {
|
|
return snapshot.replace(/localhost:[0-9]+/g, 'localhost:3030');
|
|
}
|
|
|
|
async function snapshot(page: puppeteer.Page, code: string): Promise<string> {
|
|
await waitForRAF(page);
|
|
const result = (await page.evaluate(`${code}
|
|
const snapshot = rrwebSnapshot.snapshot(document);
|
|
JSON.stringify(snapshot, null, 2);
|
|
`)) as string;
|
|
return result;
|
|
}
|
|
|
|
function assertSnapshot(snapshot: string): void {
|
|
expect(sanitizeSnapshot(snapshot)).toMatchSnapshot();
|
|
}
|
|
|
|
interface ISuite {
|
|
server: http.Server;
|
|
serverURL: string;
|
|
browser: puppeteer.Browser;
|
|
page: puppeteer.Page;
|
|
code: string;
|
|
}
|
|
|
|
describe('integration tests', function (this: ISuite) {
|
|
vi.setConfig({ testTimeout: 30_000 });
|
|
let server: ISuite['server'];
|
|
let serverURL: ISuite['serverURL'];
|
|
let browser: ISuite['browser'];
|
|
let code: ISuite['code'];
|
|
|
|
beforeAll(async () => {
|
|
server = await startServer();
|
|
serverURL = getServerURL(server);
|
|
browser = await puppeteer.launch({
|
|
headless: process.env.PUPPETEER_HEADLESS ? 'new' : false,
|
|
args: ['--no-sandbox'],
|
|
});
|
|
|
|
code = fs.readFileSync(
|
|
path.resolve(__dirname, '../dist/rrweb-snapshot.umd.cjs'),
|
|
'utf-8',
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await browser?.close();
|
|
await server?.close();
|
|
});
|
|
|
|
for (const html of htmls) {
|
|
if (html.filePath.substring(html.filePath.length - 1) === '~') {
|
|
continue;
|
|
}
|
|
// monkey patching breaks rebuild code
|
|
if (html.filePath.includes('monkey-patched-elements.html')) continue;
|
|
|
|
const title = '[html file]: ' + html.filePath;
|
|
it(title, async () => {
|
|
const page: puppeteer.Page = await browser.newPage();
|
|
// console for debug
|
|
page.on('console', (msg) => console.log(msg.text()));
|
|
if (html.filePath === 'iframe.html') {
|
|
// loading directly is needed to ensure we don't trigger compatMode='BackCompat'
|
|
// which happens before setContent can be called
|
|
await page.goto(`${serverURL}/html/${html.filePath}`, {
|
|
waitUntil: 'load',
|
|
});
|
|
const outerCompatMode = await page.evaluate('document.compatMode');
|
|
const innerCompatMode = await page.evaluate(
|
|
'document.querySelector("iframe").contentDocument.compatMode',
|
|
);
|
|
assert(
|
|
outerCompatMode === 'CSS1Compat',
|
|
outerCompatMode +
|
|
' for outer iframe.html should be CSS1Compat as it has "<!DOCTYPE html>"',
|
|
);
|
|
// inner omits a doctype so gets rendered in backwards compat mode
|
|
// although this was originally accidental, we'll add a synthetic doctype to the rebuild to recreate this
|
|
assert(
|
|
innerCompatMode === 'BackCompat',
|
|
innerCompatMode +
|
|
' for iframe-inner.html should be BackCompat as it lacks "<!DOCTYPE html>"',
|
|
);
|
|
} else {
|
|
// loading indirectly is improtant for relative path testing
|
|
await page.goto(`${serverURL}/html`);
|
|
await page.setContent(html.src, {
|
|
waitUntil: 'load',
|
|
});
|
|
}
|
|
await waitForRAF(page);
|
|
const rebuildHtml = (
|
|
(await page.evaluate(`${code}
|
|
const x = new XMLSerializer();
|
|
const snap = rrwebSnapshot.snapshot(document);
|
|
let out = x.serializeToString(rrwebSnapshot.rebuild(snap, { doc: document }));
|
|
if (document.querySelector('html').getAttribute('xmlns') !== 'http://www.w3.org/1999/xhtml') {
|
|
// this is just an artefact of serializeToString
|
|
out = out.replace(' xmlns=\"http://www.w3.org/1999/xhtml\"', '');
|
|
}
|
|
out; // return
|
|
`)) as string
|
|
)
|
|
.replace(/\n\n/g, '')
|
|
.replace(
|
|
/blob:http:\/\/localhost:\d+\/[0-9a-z\-]+/,
|
|
'blob:http://localhost:xxxx/...',
|
|
)
|
|
.replace(
|
|
/data:image\/png;base64,[A-Za-z0-9+/=]{500,}/g,
|
|
'data:image/png;base64,CANVAS_GENERATED_IMAGE',
|
|
);
|
|
|
|
await assertSnapshot(rebuildHtml);
|
|
});
|
|
}
|
|
|
|
it('correctly triggers backCompat mode and rendering', async () => {
|
|
const page: puppeteer.Page = await browser.newPage();
|
|
// console for debug
|
|
page.on('console', (msg) => console.log(msg.text()));
|
|
|
|
await page.goto(`${serverURL}/html/compat-mode.html`, {
|
|
waitUntil: 'load',
|
|
});
|
|
const compatMode = await page.evaluate('document.compatMode');
|
|
assert(
|
|
compatMode === 'BackCompat',
|
|
compatMode +
|
|
' for compat-mode.html should be BackCompat as DOCTYPE is deliberately omitted',
|
|
);
|
|
const renderedHeight = (await page.evaluate(
|
|
'document.querySelector("center").clientHeight',
|
|
)) as number;
|
|
// can remove following assertion if dimensions of page change
|
|
assert(
|
|
renderedHeight < 400,
|
|
`pre-check: images will be rendered ~326px high in BackCompat mode, and ~588px in CSS1Compat mode; getting: ${renderedHeight}px`,
|
|
);
|
|
const rebuildRenderedHeight = await page.evaluate(`${code}
|
|
const snap = rrwebSnapshot.snapshot(document);
|
|
const iframe = document.createElement('iframe');
|
|
iframe.setAttribute('width', document.body.clientWidth)
|
|
iframe.setAttribute('height', document.body.clientHeight)
|
|
iframe.style.transform = 'scale(0.3)'; // mini-me
|
|
document.body.appendChild(iframe);
|
|
// magic here! rebuild in a new iframe
|
|
const rebuildNode = rrwebSnapshot.rebuild(snap, { doc: iframe.contentDocument })[0];
|
|
iframe.contentDocument.querySelector('center').clientHeight
|
|
`);
|
|
const rebuildCompatMode = await page.evaluate(
|
|
'document.querySelector("iframe").contentDocument.compatMode',
|
|
);
|
|
assert(
|
|
rebuildCompatMode === 'BackCompat',
|
|
"rebuilt compatMode should match source compatMode, but doesn't: " +
|
|
rebuildCompatMode,
|
|
);
|
|
assert(
|
|
rebuildRenderedHeight === renderedHeight,
|
|
'rebuilt height (${rebuildRenderedHeight}) should equal original height (${renderedHeight})',
|
|
);
|
|
});
|
|
|
|
it('correctly saves images offline', async () => {
|
|
const page: puppeteer.Page = await browser.newPage();
|
|
|
|
await page.goto(`${serverURL}/html/picture.html`, {
|
|
waitUntil: 'load',
|
|
});
|
|
await page.waitForSelector('img', { timeout: 1000 });
|
|
await page.evaluate(`${code}
|
|
var snapshot = rrwebSnapshot.snapshot(document, {
|
|
dataURLOptions: { type: "image/webp", quality: 0.8 },
|
|
inlineImages: true,
|
|
inlineStylesheet: false
|
|
})`);
|
|
// don't wait, as we want to ensure that the same-origin image can be inlined immediately
|
|
const bodyChildren = (await page.evaluate(`
|
|
snapshot.childNodes[0].childNodes[1].childNodes.filter((cn) => cn.type === 2);
|
|
`)) as any[];
|
|
expect(bodyChildren[1]).toEqual(
|
|
expect.objectContaining({
|
|
tagName: 'img',
|
|
attributes: {
|
|
src: expect.stringMatching(/images\/robot.png$/),
|
|
alt: 'This is a robot',
|
|
rr_dataURL: expect.stringMatching(/^data:image\/webp;base64,/),
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('correctly saves cross-origin images offline', async () => {
|
|
const page: puppeteer.Page = await browser.newPage();
|
|
await page.goto('about:blank', {
|
|
waitUntil: 'load',
|
|
});
|
|
await page.setContent(
|
|
`
|
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
<body>
|
|
<img src="${getServerURL(
|
|
server,
|
|
)}/images/rrweb-favicon-20x20.png" alt="CORS restricted but has access-control-allow-origin: *" />
|
|
</body>
|
|
</html>
|
|
`,
|
|
{
|
|
waitUntil: 'load',
|
|
},
|
|
);
|
|
|
|
await page.waitForSelector('img', { timeout: 1000 });
|
|
await page.evaluate(`${code}var snapshot = rrwebSnapshot.snapshot(document, {
|
|
dataURLOptions: { type: "image/webp", quality: 0.8 },
|
|
inlineImages: true,
|
|
inlineStylesheet: false
|
|
})`);
|
|
await waitForRAF(page); // need a small wait, as after the crossOrigin="anonymous" change, the snapshot triggers a reload of the image (after which, the snapshot is mutated)
|
|
const bodyChildren = (await page.evaluate(`
|
|
snapshot.childNodes[0].childNodes[1].childNodes.filter((cn) => cn.type === 2);
|
|
`)) as any[];
|
|
expect(bodyChildren[0]).toEqual(
|
|
expect.objectContaining({
|
|
tagName: 'img',
|
|
attributes: {
|
|
src: getServerURL(server) + '/images/rrweb-favicon-20x20.png',
|
|
alt: 'CORS restricted but has access-control-allow-origin: *',
|
|
rr_dataURL: expect.stringMatching(/^data:image\/webp;base64,/),
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('correctly saves blob:images offline', async () => {
|
|
const page: puppeteer.Page = await browser.newPage();
|
|
|
|
await page.goto(`${serverURL}/html/picture-blob.html`, {
|
|
waitUntil: 'load',
|
|
});
|
|
await page.waitForSelector('img', { timeout: 1000 });
|
|
await page.evaluate(`${code}var snapshot = rrwebSnapshot.snapshot(document, {
|
|
dataURLOptions: { type: "image/webp", quality: 0.8 },
|
|
inlineImages: true,
|
|
inlineStylesheet: false
|
|
})`);
|
|
await waitForRAF(page);
|
|
const snapshot = (await page.evaluate(
|
|
'JSON.stringify(snapshot, null, 2);',
|
|
)) as string;
|
|
assert(snapshot.includes('"rr_dataURL"'));
|
|
assert(snapshot.includes('data:image/webp;base64,'));
|
|
});
|
|
|
|
it('correctly saves images in iframes offline', async () => {
|
|
const page: puppeteer.Page = await browser.newPage();
|
|
|
|
await page.goto(`${serverURL}/html/picture-in-frame.html`, {
|
|
waitUntil: 'load',
|
|
});
|
|
await page.waitForSelector('iframe', { timeout: 1000 });
|
|
await waitForRAF(page); // wait for page to render
|
|
await page.evaluate(`${code}
|
|
rrwebSnapshot.snapshot(document, {
|
|
dataURLOptions: { type: "image/webp", quality: 0.8 },
|
|
inlineImages: true,
|
|
inlineStylesheet: false,
|
|
onIframeLoad: function(iframe, sn) {
|
|
window.snapshot = sn;
|
|
}
|
|
})`);
|
|
await waitForRAF(page);
|
|
const snapshot = (await page.evaluate(
|
|
'JSON.stringify(window.snapshot, null, 2);',
|
|
)) as string;
|
|
assert(snapshot.includes('"rr_dataURL"'));
|
|
assert(snapshot.includes('data:image/webp;base64,'));
|
|
});
|
|
|
|
it('correctly saves blob:images in iframes offline', async () => {
|
|
const page: puppeteer.Page = await browser.newPage();
|
|
|
|
await page.goto(`${serverURL}/html/picture-blob-in-frame.html`, {
|
|
waitUntil: 'load',
|
|
});
|
|
await page.waitForSelector('iframe', { timeout: 1000 });
|
|
await waitForRAF(page); // wait for page to render
|
|
await page.evaluate(`${code}
|
|
rrwebSnapshot.snapshot(document, {
|
|
dataURLOptions: { type: "image/webp", quality: 0.8 },
|
|
inlineImages: true,
|
|
inlineStylesheet: false,
|
|
onIframeLoad: function(iframe, sn) {
|
|
window.snapshot = sn;
|
|
}
|
|
})`);
|
|
await waitForRAF(page);
|
|
const snapshot = (await page.evaluate(
|
|
'JSON.stringify(window.snapshot, null, 2);',
|
|
)) as string;
|
|
assert(snapshot.includes('"rr_dataURL"'));
|
|
assert(snapshot.includes('data:image/webp;base64,'));
|
|
});
|
|
|
|
it('should save background-clip: text; as the more compatible -webkit-background-clip: test;', async () => {
|
|
const page: puppeteer.Page = await browser.newPage();
|
|
await page.goto(`${serverURL}/html/background-clip-text.html`, {
|
|
waitUntil: 'load',
|
|
});
|
|
await waitForRAF(page); // wait for page to render
|
|
await page.evaluate(`${code}
|
|
window.snapshot = rrwebSnapshot.snapshot(document, {
|
|
inlineStylesheet: true,
|
|
})`);
|
|
await waitForRAF(page);
|
|
const snapshot = (await page.evaluate(
|
|
'JSON.stringify(window.snapshot, null, 2);',
|
|
)) as string;
|
|
assert(snapshot.includes('-webkit-background-clip: text;'));
|
|
});
|
|
|
|
it('images with inline onload should work', async () => {
|
|
const page: puppeteer.Page = await browser.newPage();
|
|
|
|
await page.goto(`${serverURL}/html/picture-with-inline-onload.html`, {
|
|
waitUntil: 'load',
|
|
});
|
|
await page.waitForSelector('img', { timeout: 2000 });
|
|
await page.evaluate(`${code}`);
|
|
await page.evaluate(`
|
|
var snapshot = rrwebSnapshot.snapshot(document, {
|
|
dataURLOptions: { type: "image/webp", quality: 0.8 },
|
|
inlineImages: true,
|
|
inlineStylesheet: false
|
|
})`);
|
|
await waitForRAF(page);
|
|
const fnName = (await page.evaluate(
|
|
'document.querySelector("img").onload.name',
|
|
)) as string;
|
|
assert(fnName === 'onload');
|
|
});
|
|
|
|
it('should be able to record elements even when .childNodes has been monkey patched', async () => {
|
|
const page: puppeteer.Page = await browser.newPage();
|
|
await page.goto(`${serverURL}/html/monkey-patched-elements.html`, {
|
|
waitUntil: 'load',
|
|
});
|
|
await waitForRAF(page); // wait for page to render
|
|
const snapshotResult = JSON.stringify(
|
|
await page.evaluate(`${code};
|
|
rrwebSnapshot.snapshot(document);
|
|
`),
|
|
null,
|
|
2,
|
|
);
|
|
expect(snapshotResult).toMatchSnapshot();
|
|
});
|
|
});
|
|
|
|
describe('base64 image compression tests', function (this: ISuite) {
|
|
vi.setConfig({ testTimeout: 30_000 });
|
|
let server: ISuite['server'];
|
|
let serverURL: ISuite['serverURL'];
|
|
let browser: ISuite['browser'];
|
|
let code: ISuite['code'];
|
|
|
|
beforeAll(async () => {
|
|
server = await startServer();
|
|
serverURL = getServerURL(server);
|
|
browser = await puppeteer.launch({
|
|
headless: process.env.PUPPETEER_HEADLESS ? 'new' : false,
|
|
args: ['--no-sandbox'],
|
|
});
|
|
|
|
code = fs.readFileSync(
|
|
path.resolve(__dirname, '../dist/rrweb-snapshot.umd.cjs'),
|
|
'utf-8',
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await browser.close();
|
|
await server.close();
|
|
});
|
|
|
|
it('should recompress base64 images with configured quality', async () => {
|
|
const page: puppeteer.Page = await browser.newPage();
|
|
await page.goto(`${serverURL}/html/base64-images.html`, {
|
|
waitUntil: 'load',
|
|
});
|
|
await page.waitForSelector('#small-image', { timeout: 2000 });
|
|
await page.waitForSelector('#medium-image', { timeout: 2000 });
|
|
|
|
const result = await page.evaluate(`${code}
|
|
const snapshot = rrwebSnapshot.snapshot(document, {
|
|
dataURLOptions: { type: "image/webp", quality: 0.4, maxBase64ImageLength: 1048576 }
|
|
});
|
|
|
|
function findImages(node, images = []) {
|
|
if (node.type === 2 && node.tagName === 'img') {
|
|
images.push(node);
|
|
}
|
|
if (node.childNodes) {
|
|
node.childNodes.forEach(child => findImages(child, images));
|
|
}
|
|
return images;
|
|
}
|
|
|
|
const images = findImages(snapshot);
|
|
|
|
({
|
|
smallImageSrc: images[0].attributes.src,
|
|
mediumImageSrc: images[1].attributes.src,
|
|
smallOriginalLength: images[0].attributes.src.length,
|
|
mediumOriginalLength: images[1].attributes.src.length
|
|
});
|
|
`);
|
|
|
|
expect(result.smallImageSrc).toMatch(/^data:image\/webp;base64,/);
|
|
expect(result.mediumImageSrc).toMatch(/^data:image\/webp;base64,/);
|
|
|
|
await page.close();
|
|
});
|
|
|
|
it('should replace oversized base64 images with striped placeholder', async () => {
|
|
const page: puppeteer.Page = await browser.newPage();
|
|
await page.goto(`${serverURL}/html/base64-images.html`, {
|
|
waitUntil: 'load',
|
|
});
|
|
await page.waitForSelector('#large-image', { timeout: 5000 });
|
|
|
|
const result = await page.evaluate(`${code}
|
|
const snapshot = rrwebSnapshot.snapshot(document, {
|
|
dataURLOptions: { type: "image/webp", quality: 0.4, maxBase64ImageLength: 10000 }
|
|
});
|
|
|
|
function findImages(node, images = []) {
|
|
if (node.type === 2 && node.tagName === 'img') {
|
|
images.push(node);
|
|
}
|
|
if (node.childNodes) {
|
|
node.childNodes.forEach(child => findImages(child, images));
|
|
}
|
|
return images;
|
|
}
|
|
|
|
const images = findImages(snapshot);
|
|
const largeImage = images.find(img => img.attributes.id === 'large-image');
|
|
|
|
({
|
|
largeImageSrc: largeImage.attributes.src,
|
|
isPlaceholder: largeImage.attributes.src.startsWith('data:image/svg+xml'),
|
|
srcLength: largeImage.attributes.src.length
|
|
});
|
|
`);
|
|
|
|
expect(result.isPlaceholder).toBe(true);
|
|
expect(result.largeImageSrc).toMatch(/^data:image\/svg\+xml;base64,/);
|
|
expect(result.srcLength).toBeLessThan(1000);
|
|
|
|
await page.close();
|
|
});
|
|
|
|
it('should skip recompression for images larger than 4096px', async () => {
|
|
const page: puppeteer.Page = await browser.newPage();
|
|
await page.goto(`${serverURL}/html/base64-images.html`, {
|
|
waitUntil: 'load',
|
|
});
|
|
await page.waitForSelector('#huge-image', { timeout: 5000 });
|
|
|
|
const result = await page.evaluate(`${code}
|
|
const originalSrc = document.getElementById('huge-image').src;
|
|
|
|
const snapshot = rrwebSnapshot.snapshot(document, {
|
|
dataURLOptions: { type: "image/webp", quality: 0.4, maxBase64ImageLength: 10000000 }
|
|
});
|
|
|
|
function findImages(node, images = []) {
|
|
if (node.type === 2 && node.tagName === 'img') {
|
|
images.push(node);
|
|
}
|
|
if (node.childNodes) {
|
|
node.childNodes.forEach(child => findImages(child, images));
|
|
}
|
|
return images;
|
|
}
|
|
|
|
const images = findImages(snapshot);
|
|
const hugeImage = images.find(img => img.attributes.id === 'huge-image');
|
|
|
|
({
|
|
originalFormat: originalSrc.substring(0, 30),
|
|
snapshotFormat: hugeImage.attributes.src.substring(0, 30),
|
|
wasRecompressed: originalSrc !== hugeImage.attributes.src,
|
|
isPNG: hugeImage.attributes.src.startsWith('data:image/png')
|
|
});
|
|
`);
|
|
|
|
expect(result.isPNG).toBe(true);
|
|
expect(result.wasRecompressed).toBe(false);
|
|
|
|
await page.close();
|
|
});
|
|
|
|
it('should preserve small images under limit without replacing', async () => {
|
|
const page: puppeteer.Page = await browser.newPage();
|
|
await page.goto(`${serverURL}/html/base64-images.html`, {
|
|
waitUntil: 'load',
|
|
});
|
|
await page.waitForSelector('#small-image', { timeout: 2000 });
|
|
|
|
const result = await page.evaluate(`${code}
|
|
const snapshot = rrwebSnapshot.snapshot(document, {
|
|
dataURLOptions: { type: "image/webp", quality: 0.8, maxBase64ImageLength: 1048576 }
|
|
});
|
|
|
|
function findImages(node, images = []) {
|
|
if (node.type === 2 && node.tagName === 'img') {
|
|
images.push(node);
|
|
}
|
|
if (node.childNodes) {
|
|
node.childNodes.forEach(child => findImages(child, images));
|
|
}
|
|
return images;
|
|
}
|
|
|
|
const images = findImages(snapshot);
|
|
const smallImage = images.find(img => img.attributes.id === 'small-image');
|
|
|
|
({
|
|
isWebP: smallImage.attributes.src.startsWith('data:image/webp'),
|
|
isNotPlaceholder: !smallImage.attributes.src.includes('svg+xml'),
|
|
srcLength: smallImage.attributes.src.length
|
|
});
|
|
`);
|
|
|
|
expect(result.isWebP).toBe(true);
|
|
expect(result.isNotPlaceholder).toBe(true);
|
|
expect(result.srcLength).toBeGreaterThan(0);
|
|
expect(result.srcLength).toBeLessThan(1048576);
|
|
|
|
await page.close();
|
|
});
|
|
});
|
|
|
|
describe('iframe integration tests', function (this: ISuite) {
|
|
vi.setConfig({ testTimeout: 30_000 });
|
|
let server: ISuite['server'];
|
|
let serverURL: ISuite['serverURL'];
|
|
let browser: ISuite['browser'];
|
|
let code: ISuite['code'];
|
|
|
|
beforeAll(async () => {
|
|
server = await startServer();
|
|
serverURL = getServerURL(server);
|
|
browser = await puppeteer.launch({
|
|
headless: process.env.PUPPETEER_HEADLESS ? 'new' : false,
|
|
args: ['--no-sandbox'],
|
|
});
|
|
|
|
code = fs.readFileSync(
|
|
path.resolve(__dirname, '../dist/rrweb-snapshot.umd.cjs'),
|
|
'utf-8',
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await browser.close();
|
|
await server.close();
|
|
});
|
|
|
|
it('snapshot async iframes', async () => {
|
|
const page: puppeteer.Page = await browser.newPage();
|
|
// console for debug
|
|
page.on('console', (msg) => console.log(msg.text()));
|
|
await page.goto(`${serverURL}/iframe-html/main.html`, {
|
|
waitUntil: 'load',
|
|
});
|
|
const snapshotResult = JSON.stringify(
|
|
await page.evaluate(`${code};
|
|
rrwebSnapshot.snapshot(document);
|
|
`),
|
|
null,
|
|
2,
|
|
);
|
|
await assertSnapshot(snapshotResult);
|
|
});
|
|
});
|
|
|
|
describe('dialog integration tests', function (this: ISuite) {
|
|
vi.setConfig({ testTimeout: 30_000 });
|
|
let server: ISuite['server'];
|
|
let serverURL: ISuite['serverURL'];
|
|
let browser: ISuite['browser'];
|
|
let code: ISuite['code'];
|
|
let page: ISuite['page'];
|
|
|
|
beforeAll(async () => {
|
|
server = await startServer();
|
|
serverURL = getServerURL(server);
|
|
browser = await puppeteer.launch({
|
|
headless: process.env.PUPPETEER_HEADLESS ? 'new' : false,
|
|
args: ['--no-sandbox'],
|
|
});
|
|
|
|
code = fs.readFileSync(
|
|
path.resolve(__dirname, '../dist/rrweb-snapshot.umd.cjs'),
|
|
'utf-8',
|
|
);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
page = await browser.newPage();
|
|
page.on('console', (msg) => console.log(msg.text()));
|
|
await page.goto(`${serverURL}/html/dialog.html`, {
|
|
waitUntil: 'load',
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await browser.close();
|
|
await server.close();
|
|
});
|
|
|
|
it('should capture open attribute for non modal dialogs', async () => {
|
|
page.evaluate('document.querySelector("dialog").show()');
|
|
const snapshotResult = await snapshot(page, code);
|
|
assertSnapshot(snapshotResult);
|
|
});
|
|
|
|
it('should capture open attribute for modal dialogs', async () => {
|
|
await page.evaluate('document.querySelector("dialog").showModal()');
|
|
const snapshotResult = await snapshot(page, code);
|
|
assertSnapshot(snapshotResult);
|
|
});
|
|
});
|
|
|
|
describe('shadow DOM integration tests', function (this: ISuite) {
|
|
vi.setConfig({ testTimeout: 30_000 });
|
|
let server: ISuite['server'];
|
|
let serverURL: ISuite['serverURL'];
|
|
let browser: ISuite['browser'];
|
|
let code: ISuite['code'];
|
|
|
|
beforeAll(async () => {
|
|
server = await startServer();
|
|
serverURL = getServerURL(server);
|
|
browser = await puppeteer.launch({
|
|
headless: process.env.PUPPETEER_HEADLESS ? 'new' : false,
|
|
args: ['--no-sandbox'],
|
|
});
|
|
|
|
code = fs.readFileSync(
|
|
path.resolve(__dirname, '../dist/rrweb-snapshot.umd.cjs'),
|
|
'utf-8',
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await browser.close();
|
|
await server.close();
|
|
});
|
|
|
|
it('snapshot shadow DOM', async () => {
|
|
const page: puppeteer.Page = await browser.newPage();
|
|
// console for debug
|
|
page.on('console', (msg) => console.log(msg.text()));
|
|
await page.goto(`${serverURL}/html/shadow-dom.html`, {
|
|
waitUntil: 'load',
|
|
});
|
|
const snapshotResult = JSON.stringify(
|
|
await page.evaluate(`${code};
|
|
rrwebSnapshot.snapshot(document);
|
|
`),
|
|
null,
|
|
2,
|
|
);
|
|
await assertSnapshot(snapshotResult);
|
|
});
|
|
});
|