Files
rrweb/packages/rrweb-snapshot/test/integration.test.ts
Justin Halsall f07682ea8b Handle negative ids in rrdom correctly + extra tests (#927)
* inline stylesheets when loaded

* set empty link elements to loaded by default

* Clean up stylesheet manager

* Remove attribute mutation code

* Update packages/rrweb/test/record.test.ts

* Update packages/rrweb/test/record.test.ts

* Update packages/rrweb/test/record.test.ts

* Update packages/rrweb/scripts/repl.js

* Update packages/rrweb/test/record.test.ts

* Update packages/rrweb/src/record/index.ts

* Add todo

* Move require out of time sensitive assert

* Add waitForRAF, its more reliable than waitForTimeout

* Remove flaky tests

* Add recording stylesheets in iframes

* Remove variability from flaky test

* Make test more robust

* Fix naming

* Add test cases for inlineImages

* Add test cases for inlineImages

* Record iframe mutations cross page

* Test: should record images inside iframe with blob url after iframe was reloaded

* Handle negative ids in rrdom correctly

When iframes get inserted they create untracked elements, both on the dom and rrdom side.
Because they are untracked they generate negative numbers when fetching the id from mirror.
This creates a problem when comparing and fetching ids across mirrors.
This commit tries to get away from using negative ids as much as possible in rrdom's comparisons

* Update packages/rrdom/src/diff.ts

Co-authored-by: Yun Feng <yun.feng@anu.edu.au>

* Start unserialized nodes at -2

This way we don't accidentally think of them as mirror misses

* Set unserialized id starting number at -2

* Remove duplication

Co-authored-by: Yun Feng <yun.feng@anu.edu.au>
2026-04-01 12:00:00 +08:00

376 lines
12 KiB
TypeScript

import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
import * as url from 'url';
import * as puppeteer from 'puppeteer';
import * as rollup from 'rollup';
import * as typescript from 'rollup-plugin-typescript2';
import * as assert from 'assert';
import { waitForRAF } from './utils';
const _typescript = (typescript as unknown) as () => rollup.Plugin;
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 = () =>
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(3030).on('listening', () => {
resolve(s);
});
});
interface ISuite {
server: http.Server;
browser: puppeteer.Browser;
code: string;
}
describe('integration tests', function (this: ISuite) {
jest.setTimeout(30_000);
let server: ISuite['server'];
let browser: ISuite['browser'];
let code: ISuite['code'];
beforeAll(async () => {
server = await startServer();
browser = await puppeteer.launch({
// headless: false,
});
const bundle = await rollup.rollup({
input: path.resolve(__dirname, '../src/index.ts'),
plugins: [_typescript()],
});
const {
output: [{ code: _code }],
} = await bundle.generate({
name: 'rrweb',
format: 'iife',
});
code = _code;
});
afterAll(async () => {
await browser.close();
await server.close();
});
for (const html of htmls) {
if (html.filePath.substring(html.filePath.length - 1) === '~') {
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(`http://localhost:3030/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(`http://localhost:3030/html`);
await page.setContent(html.src, {
waitUntil: 'load',
});
}
const rebuildHtml = (
await page.evaluate(`${code}
const x = new XMLSerializer();
const snap = rrweb.snapshot(document);
let out = x.serializeToString(rrweb.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
`)
).replace(/\n\n/g, '');
expect(rebuildHtml).toMatchSnapshot();
});
}
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('http://localhost:3030/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',
);
// 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 = rrweb.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 = rrweb.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('http://localhost:3030/html/picture.html', {
waitUntil: 'load',
});
await page.waitForSelector('img', { timeout: 1000 });
await page.evaluate(`${code}var snapshot = rrweb.snapshot(document, {
dataURLOptions: { type: "image/webp", quality: 0.8 },
inlineImages: true,
inlineStylesheet: false
})`);
await page.waitFor(100);
const snapshot = await page.evaluate('JSON.stringify(snapshot, null, 2);');
assert(snapshot.includes('"rr_dataURL"'));
assert(snapshot.includes('data:image/webp;base64,'));
});
it('correctly saves blob:images offline', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto('http://localhost:3030/html/picture-blob.html', {
waitUntil: 'load',
});
await page.waitForSelector('img', { timeout: 1000 });
await page.evaluate(`${code}var snapshot = rrweb.snapshot(document, {
dataURLOptions: { type: "image/webp", quality: 0.8 },
inlineImages: true,
inlineStylesheet: false
})`);
await page.waitFor(100);
const snapshot = await page.evaluate('JSON.stringify(snapshot, null, 2);');
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('http://localhost:3030/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}
rrweb.snapshot(document, {
dataURLOptions: { type: "image/webp", quality: 0.8 },
inlineImages: true,
inlineStylesheet: false,
onIframeLoad: function(iframe, sn) {
window.snapshot = sn;
}
})`);
await page.waitFor(100);
const snapshot = await page.evaluate(
'JSON.stringify(window.snapshot, null, 2);',
);
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('http://localhost:3030/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}
rrweb.snapshot(document, {
dataURLOptions: { type: "image/webp", quality: 0.8 },
inlineImages: true,
inlineStylesheet: false,
onIframeLoad: function(iframe, sn) {
window.snapshot = sn;
}
})`);
await page.waitFor(100);
const snapshot = await page.evaluate(
'JSON.stringify(window.snapshot, null, 2);',
);
assert(snapshot.includes('"rr_dataURL"'));
assert(snapshot.includes('data:image/webp;base64,'));
});
});
describe('iframe integration tests', function (this: ISuite) {
jest.setTimeout(30_000);
let server: ISuite['server'];
let browser: ISuite['browser'];
let code: ISuite['code'];
beforeAll(async () => {
server = await startServer();
browser = await puppeteer.launch({
// headless: false,
});
const bundle = await rollup.rollup({
input: path.resolve(__dirname, '../src/index.ts'),
plugins: [_typescript()],
});
const {
output: [{ code: _code }],
} = await bundle.generate({
name: 'rrweb',
format: 'iife',
});
code = _code;
});
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(`http://localhost:3030/iframe-html/main.html`, {
waitUntil: 'load',
});
const snapshotResult = JSON.stringify(
await page.evaluate(`${code};
rrweb.snapshot(document);
`),
null,
2,
);
expect(snapshotResult).toMatchSnapshot();
});
});
describe('shadow DOM integration tests', function (this: ISuite) {
jest.setTimeout(30_000);
let server: ISuite['server'];
let browser: ISuite['browser'];
let code: ISuite['code'];
beforeAll(async () => {
server = await startServer();
browser = await puppeteer.launch({
// headless: false,
});
const bundle = await rollup.rollup({
input: path.resolve(__dirname, '../src/index.ts'),
plugins: [_typescript()],
});
const {
output: [{ code: _code }],
} = await bundle.generate({
name: 'rrweb',
format: 'iife',
});
code = _code;
});
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(`http://localhost:3030/html/shadow-dom.html`, {
waitUntil: 'load',
});
const snapshotResult = JSON.stringify(
await page.evaluate(`${code};
rrweb.snapshot(document);
`),
null,
2,
);
expect(snapshotResult).toMatchSnapshot();
});
});