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>
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent c405e31e01
commit f07682ea8b
22 changed files with 2064 additions and 193 deletions

View File

@@ -272,37 +272,57 @@ function diffChildren(
let oldIdToIndex: Record<number, number> | undefined = undefined, let oldIdToIndex: Record<number, number> | undefined = undefined,
indexInOld; indexInOld;
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
const oldStartId = replayer.mirror.getId(oldStartNode);
const oldEndId = replayer.mirror.getId(oldEndNode);
const newStartId = rrnodeMirror.getId(newStartNode);
const newEndId = rrnodeMirror.getId(newEndNode);
// rrdom contains elements with negative ids, we don't want to accidentally match those to a mirror mismatch (-1) id.
// Negative oldStartId happen when nodes are not in the mirror, but are in the DOM.
// eg.iframes come with a document, html, head and body nodes.
// thats why below we always check if an id is negative.
if (oldStartNode === undefined) { if (oldStartNode === undefined) {
oldStartNode = oldChildren[++oldStartIndex]; oldStartNode = oldChildren[++oldStartIndex];
} else if (oldEndNode === undefined) { } else if (oldEndNode === undefined) {
oldEndNode = oldChildren[--oldEndIndex]; oldEndNode = oldChildren[--oldEndIndex];
} else if ( } else if (
replayer.mirror.getId(oldStartNode) === rrnodeMirror.getId(newStartNode) oldStartId !== -1 &&
// same first element?
oldStartId === newStartId
) { ) {
diff(oldStartNode, newStartNode, replayer, rrnodeMirror); diff(oldStartNode, newStartNode, replayer, rrnodeMirror);
oldStartNode = oldChildren[++oldStartIndex]; oldStartNode = oldChildren[++oldStartIndex];
newStartNode = newChildren[++newStartIndex]; newStartNode = newChildren[++newStartIndex];
} else if ( } else if (
replayer.mirror.getId(oldEndNode) === rrnodeMirror.getId(newEndNode) oldEndId !== -1 &&
// same last element?
oldEndId === newEndId
) { ) {
diff(oldEndNode, newEndNode, replayer, rrnodeMirror); diff(oldEndNode, newEndNode, replayer, rrnodeMirror);
oldEndNode = oldChildren[--oldEndIndex]; oldEndNode = oldChildren[--oldEndIndex];
newEndNode = newChildren[--newEndIndex]; newEndNode = newChildren[--newEndIndex];
} else if ( } else if (
replayer.mirror.getId(oldStartNode) === rrnodeMirror.getId(newEndNode) oldStartId !== -1 &&
// is the first old element the same as the last new element?
oldStartId === newEndId
) { ) {
parentNode.insertBefore(oldStartNode, oldEndNode.nextSibling); parentNode.insertBefore(oldStartNode, oldEndNode.nextSibling);
diff(oldStartNode, newEndNode, replayer, rrnodeMirror); diff(oldStartNode, newEndNode, replayer, rrnodeMirror);
oldStartNode = oldChildren[++oldStartIndex]; oldStartNode = oldChildren[++oldStartIndex];
newEndNode = newChildren[--newEndIndex]; newEndNode = newChildren[--newEndIndex];
} else if ( } else if (
replayer.mirror.getId(oldEndNode) === rrnodeMirror.getId(newStartNode) oldEndId !== -1 &&
// is the last old element the same as the first new element?
oldEndId === newStartId
) { ) {
parentNode.insertBefore(oldEndNode, oldStartNode); parentNode.insertBefore(oldEndNode, oldStartNode);
diff(oldEndNode, newStartNode, replayer, rrnodeMirror); diff(oldEndNode, newStartNode, replayer, rrnodeMirror);
oldEndNode = oldChildren[--oldEndIndex]; oldEndNode = oldChildren[--oldEndIndex];
newStartNode = newChildren[++newStartIndex]; newStartNode = newChildren[++newStartIndex];
} else { } else {
// none of the elements matched
if (!oldIdToIndex) { if (!oldIdToIndex) {
oldIdToIndex = {}; oldIdToIndex = {};
for (let i = oldStartIndex; i <= oldEndIndex; i++) { for (let i = oldStartIndex; i <= oldEndIndex; i++) {
@@ -378,8 +398,11 @@ export function createOrGetNode(
domMirror: NodeMirror, domMirror: NodeMirror,
rrnodeMirror: Mirror, rrnodeMirror: Mirror,
): Node { ): Node {
let node = domMirror.getNode(rrnodeMirror.getId(rrNode)); const nodeId = rrnodeMirror.getId(rrNode);
const sn = rrnodeMirror.getMeta(rrNode); const sn = rrnodeMirror.getMeta(rrNode);
let node: Node | null = null;
// negative ids shouldn't be compared accross mirrors
if (nodeId > -1) node = domMirror.getNode(nodeId);
if (node !== null) return node; if (node !== null) return node;
switch (rrNode.RRNodeType) { switch (rrNode.RRNodeType) {
case RRNodeType.Document: case RRNodeType.Document:

View File

@@ -33,10 +33,11 @@ import {
} from './document'; } from './document';
export class RRDocument extends BaseRRDocumentImpl(RRNode) { export class RRDocument extends BaseRRDocumentImpl(RRNode) {
private UNSERIALIZED_STARTING_ID = -2;
// In the rrweb replayer, there are some unserialized nodes like the element that stores the injected style rules. // In the rrweb replayer, there are some unserialized nodes like the element that stores the injected style rules.
// These unserialized nodes may interfere the execution of the diff algorithm. // These unserialized nodes may interfere the execution of the diff algorithm.
// The id of serialized node is larger than 0. So this value less than 0 is used as id for these unserialized nodes. // The id of serialized node is larger than 0. So this value less than 0 is used as id for these unserialized nodes.
private _unserializedId = -1; private _unserializedId = this.UNSERIALIZED_STARTING_ID;
/** /**
* Every time the id is used, it will minus 1 automatically to avoid collisions. * Every time the id is used, it will minus 1 automatically to avoid collisions.
@@ -135,7 +136,7 @@ export class RRDocument extends BaseRRDocumentImpl(RRNode) {
open() { open() {
super.open(); super.open();
this._unserializedId = -1; this._unserializedId = this.UNSERIALIZED_STARTING_ID;
} }
} }

View File

@@ -1,118 +1,118 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RRDocument for browser environment create a RRDocument from a html document can build from a common html 1`] = ` exports[`RRDocument for browser environment create a RRDocument from a html document can build from a common html 1`] = `
"-1 RRDocument "-2 RRDocument
-2 RRDocumentType -3 RRDocumentType
-3 HTML lang=\\"en\\" -4 HTML lang=\\"en\\"
-4 HEAD -5 HEAD
-5 RRText text=\\"\\\\n \\" -6 RRText text=\\"\\\\n \\"
-6 META charset=\\"UTF-8\\" -7 META charset=\\"UTF-8\\"
-7 RRText text=\\"\\\\n \\" -8 RRText text=\\"\\\\n \\"
-8 META name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" -9 META name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\"
-9 RRText text=\\"\\\\n \\" -10 RRText text=\\"\\\\n \\"
-10 TITLE -11 TITLE
-11 RRText text=\\"Main\\" -12 RRText text=\\"Main\\"
-12 RRText text=\\"\\\\n \\" -13 RRText text=\\"\\\\n \\"
-13 LINK rel=\\"stylesheet\\" href=\\"somelink\\" -14 LINK rel=\\"stylesheet\\" href=\\"somelink\\"
-14 RRText text=\\"\\\\n \\" -15 RRText text=\\"\\\\n \\"
-15 STYLE -16 STYLE
-16 RRText text=\\"\\\\n h1 {\\\\n color: 'black';\\\\n }\\\\n .blocks {\\\\n padding: 0;\\\\n }\\\\n .blocks1 {\\\\n margin: 0;\\\\n }\\\\n #block1 {\\\\n width: 100px;\\\\n height: 200px;\\\\n }\\\\n @import url('main.css');\\\\n \\" -17 RRText text=\\"\\\\n h1 {\\\\n color: 'black';\\\\n }\\\\n .blocks {\\\\n padding: 0;\\\\n }\\\\n .blocks1 {\\\\n margin: 0;\\\\n }\\\\n #block1 {\\\\n width: 100px;\\\\n height: 200px;\\\\n }\\\\n @import url('main.css');\\\\n \\"
-17 RRText text=\\"\\\\n \\" -18 RRText text=\\"\\\\n \\"
-18 RRText text=\\"\\\\n \\" -19 RRText text=\\"\\\\n \\"
-19 BODY -20 BODY
-20 RRText text=\\"\\\\n \\" -21 RRText text=\\"\\\\n \\"
-21 H1 -22 H1
-22 RRText text=\\"This is a h1 heading\\" -23 RRText text=\\"This is a h1 heading\\"
-23 RRText text=\\"\\\\n \\" -24 RRText text=\\"\\\\n \\"
-24 H1 style=\\"font-size: 16px\\" -25 H1 style=\\"font-size: 16px\\"
-25 RRText text=\\"This is a h1 heading with styles\\" -26 RRText text=\\"This is a h1 heading with styles\\"
-26 RRText text=\\"\\\\n \\" -27 RRText text=\\"\\\\n \\"
-27 DIV id=\\"block1\\" class=\\"blocks blocks1\\" -28 DIV id=\\"block1\\" class=\\"blocks blocks1\\"
-28 RRText text=\\"\\\\n \\" -29 RRText text=\\"\\\\n \\"
-29 DIV id=\\"block2\\" class=\\"blocks blocks1 :hover\\" -30 DIV id=\\"block2\\" class=\\"blocks blocks1 :hover\\"
-30 RRText text=\\"\\\\n Text 1\\\\n \\" -31 RRText text=\\"\\\\n Text 1\\\\n \\"
-31 DIV id=\\"block3\\" -32 DIV id=\\"block3\\"
-32 RRText text=\\"\\\\n \\" -33 RRText text=\\"\\\\n \\"
-33 P -34 P
-34 RRText text=\\"This is a paragraph\\" -35 RRText text=\\"This is a paragraph\\"
-35 RRText text=\\"\\\\n \\" -36 RRText text=\\"\\\\n \\"
-36 BUTTON -37 BUTTON
-37 RRText text=\\"button1\\" -38 RRText text=\\"button1\\"
-38 RRText text=\\"\\\\n \\" -39 RRText text=\\"\\\\n \\"
-39 RRText text=\\"\\\\n Text 2\\\\n \\" -40 RRText text=\\"\\\\n Text 2\\\\n \\"
-40 RRText text=\\"\\\\n \\" -41 RRText text=\\"\\\\n \\"
-41 IMG src=\\"somelink\\" alt=\\"This is an image\\" -42 IMG src=\\"somelink\\" alt=\\"This is an image\\"
-42 RRText text=\\"\\\\n \\" -43 RRText text=\\"\\\\n \\"
-43 RRComment text=\\" This is a line of comment \\" -44 RRComment text=\\" This is a line of comment \\"
-44 RRText text=\\"\\\\n \\" -45 RRText text=\\"\\\\n \\"
-45 FORM -46 FORM
-46 RRText text=\\"\\\\n \\" -47 RRText text=\\"\\\\n \\"
-47 INPUT type=\\"text\\" id=\\"input1\\" -48 INPUT type=\\"text\\" id=\\"input1\\"
-48 RRText text=\\"\\\\n \\" -49 RRText text=\\"\\\\n \\"
-49 RRText text=\\"\\\\n \\" -50 RRText text=\\"\\\\n \\"
-50 RRText text=\\"\\\\n \\\\n\\\\n\\" -51 RRText text=\\"\\\\n \\\\n\\\\n\\"
" "
`; `;
exports[`RRDocument for browser environment create a RRDocument from a html document can build from a html containing nested shadow doms 1`] = ` exports[`RRDocument for browser environment create a RRDocument from a html document can build from a html containing nested shadow doms 1`] = `
"-1 RRDocument "-2 RRDocument
-2 RRDocumentType -3 RRDocumentType
-3 HTML lang=\\"en\\" -4 HTML lang=\\"en\\"
-4 HEAD -5 HEAD
-5 RRText text=\\"\\\\n \\" -6 RRText text=\\"\\\\n \\"
-6 META charset=\\"UTF-8\\" -7 META charset=\\"UTF-8\\"
-7 RRText text=\\"\\\\n \\" -8 RRText text=\\"\\\\n \\"
-8 META name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" -9 META name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\"
-9 RRText text=\\"\\\\n \\" -10 RRText text=\\"\\\\n \\"
-10 TITLE -11 TITLE
-11 RRText text=\\"shadow dom\\" -12 RRText text=\\"shadow dom\\"
-12 RRText text=\\"\\\\n \\" -13 RRText text=\\"\\\\n \\"
-13 RRText text=\\"\\\\n \\" -14 RRText text=\\"\\\\n \\"
-14 BODY -15 BODY
-15 RRText text=\\"\\\\n \\" -16 RRText text=\\"\\\\n \\"
-16 DIV -17 DIV
-17 SHADOWROOT -18 SHADOWROOT
-18 RRText text=\\"\\\\n \\" -19 RRText text=\\"\\\\n \\"
-19 SPAN -20 SPAN
-20 RRText text=\\" shadow dom one \\" -21 RRText text=\\" shadow dom one \\"
-21 RRText text=\\"\\\\n \\" -22 RRText text=\\"\\\\n \\"
-22 DIV -23 DIV
-23 SHADOWROOT -24 SHADOWROOT
-24 RRText text=\\"\\\\n \\" -25 RRText text=\\"\\\\n \\"
-25 SPAN -26 SPAN
-26 RRText text=\\" shadow dom two \\" -27 RRText text=\\" shadow dom two \\"
-27 RRText text=\\"\\\\n \\" -28 RRText text=\\"\\\\n \\"
-28 RRText text=\\"\\\\n \\\\n \\" -29 RRText text=\\"\\\\n \\\\n \\"
-29 RRText text=\\"\\\\n \\" -30 RRText text=\\"\\\\n \\"
-30 RRText text=\\"\\\\n \\\\n \\" -31 RRText text=\\"\\\\n \\\\n \\"
-31 RRText text=\\"\\\\n \\\\n\\\\n\\" -32 RRText text=\\"\\\\n \\\\n\\\\n\\"
" "
`; `;
exports[`RRDocument for browser environment create a RRDocument from a html document can build from a xml page 1`] = ` exports[`RRDocument for browser environment create a RRDocument from a html document can build from a xml page 1`] = `
"-1 RRDocument "-2 RRDocument
-2 XML -3 XML
-3 RRCDATASection data=\\"Some <CDATA> data & then some\\" -4 RRCDATASection data=\\"Some <CDATA> data & then some\\"
" "
`; `;
exports[`RRDocument for browser environment create a RRDocument from a html document can build from an iframe html 1`] = ` exports[`RRDocument for browser environment create a RRDocument from a html document can build from an iframe html 1`] = `
"-1 RRDocument "-2 RRDocument
-2 RRDocumentType -3 RRDocumentType
-3 HTML lang=\\"en\\" -4 HTML lang=\\"en\\"
-4 HEAD -5 HEAD
-5 RRText text=\\"\\\\n \\" -6 RRText text=\\"\\\\n \\"
-6 META charset=\\"UTF-8\\" -7 META charset=\\"UTF-8\\"
-7 RRText text=\\"\\\\n \\" -8 RRText text=\\"\\\\n \\"
-8 META name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" -9 META name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\"
-9 RRText text=\\"\\\\n \\" -10 RRText text=\\"\\\\n \\"
-10 TITLE -11 TITLE
-11 RRText text=\\"Iframe\\" -12 RRText text=\\"Iframe\\"
-12 RRText text=\\"\\\\n \\" -13 RRText text=\\"\\\\n \\"
-13 RRText text=\\"\\\\n \\" -14 RRText text=\\"\\\\n \\"
-14 BODY -15 BODY
-15 RRText text=\\"\\\\n \\" -16 RRText text=\\"\\\\n \\"
-16 IFRAME id=\\"iframe1\\" srcdoc=\\" -17 IFRAME id=\\"iframe1\\" srcdoc=\\"
<html> <html>
<head> <head>
<meta charset='UTF-8' /> <meta charset='UTF-8' />
@@ -126,35 +126,35 @@ exports[`RRDocument for browser environment create a RRDocument from a html docu
<iframe id='iframe3' srcdoc='<div>This is a block inside the iframe3.</div>'> <iframe id='iframe3' srcdoc='<div>This is a block inside the iframe3.</div>'>
</body> </body>
</html>\\" </html>\\"
-17 RRDocument -18 RRDocument
-18 HTML -19 HTML
-19 HEAD -20 HEAD
-20 RRText text=\\"\\\\n \\" -21 RRText text=\\"\\\\n \\"
-21 META charset=\\"UTF-8\\" -22 META charset=\\"UTF-8\\"
-22 RRText text=\\"\\\\n \\" -23 RRText text=\\"\\\\n \\"
-23 META name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" -24 META name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\"
-24 RRText text=\\"\\\\n \\" -25 RRText text=\\"\\\\n \\"
-25 RRText text=\\"\\\\n \\" -26 RRText text=\\"\\\\n \\"
-26 BODY -27 BODY
-27 RRText text=\\"\\\\n \\" -28 RRText text=\\"\\\\n \\"
-28 DIV -29 DIV
-29 RRText text=\\"This is a block inside the iframe1.\\" -30 RRText text=\\"This is a block inside the iframe1.\\"
-30 RRText text=\\"\\\\n \\" -31 RRText text=\\"\\\\n \\"
-31 IFRAME id=\\"iframe3\\" srcdoc=\\"<div>This is a block inside the iframe3.</div>\\" -32 IFRAME id=\\"iframe3\\" srcdoc=\\"<div>This is a block inside the iframe3.</div>\\"
-32 RRDocument -33 RRDocument
-33 HTML -34 HTML
-34 HEAD -35 HEAD
-35 BODY -36 BODY
-36 DIV -37 DIV
-37 RRText text=\\"This is a block inside the iframe3.\\" -38 RRText text=\\"This is a block inside the iframe3.\\"
-38 RRText text=\\"\\\\n \\" -39 RRText text=\\"\\\\n \\"
-39 IFRAME id=\\"iframe2\\" srcdoc=\\"<div>This is a block inside the iframe2.</div>\\" -40 IFRAME id=\\"iframe2\\" srcdoc=\\"<div>This is a block inside the iframe2.</div>\\"
-40 RRDocument -41 RRDocument
-41 HTML -42 HTML
-42 HEAD -43 HEAD
-43 BODY -44 BODY
-44 DIV -45 DIV
-45 RRText text=\\"This is a block inside the iframe2.\\" -46 RRText text=\\"This is a block inside the iframe2.\\"
-46 RRText text=\\"\\\\n \\\\n\\\\n\\" -47 RRText text=\\"\\\\n \\\\n\\\\n\\"
" "
`; `;

View File

@@ -1074,6 +1074,112 @@ describe('diff algorithm for rrdom', () => {
expect(element.nodeType).toBe(element.DOCUMENT_TYPE_NODE); expect(element.nodeType).toBe(element.DOCUMENT_TYPE_NODE);
expect(mirror.getId(element)).toEqual(1); expect(mirror.getId(element)).toEqual(1);
}); });
it('should remove children from document before adding new nodes 2', () => {
document.write('<html><iframe></iframe></html>');
const iframe = document.querySelector('iframe')!;
// Remove everthing from the iframe but the root html element
// `buildNodeWithSn` injects docType elements to trigger compatMode in iframes
iframe.contentDocument!.write(
'<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "">',
);
replayer.mirror.add(iframe.contentDocument!, {
id: 1,
type: 0,
childNodes: [
{
id: 2,
rootId: 1,
type: 2,
tagName: 'html',
childNodes: [],
attributes: {},
},
],
} as serializedNodeWithId);
replayer.mirror.add(iframe.contentDocument!.childNodes[0], {
id: 2,
rootId: 1,
type: 2,
tagName: 'html',
childNodes: [],
attributes: {},
} as serializedNodeWithId);
const rrDocument = new RRDocument();
rrDocument.mirror.add(rrDocument, getDefaultSN(rrDocument, 1));
const docType = rrDocument.createDocumentType('html', '', '');
rrDocument.mirror.add(docType, getDefaultSN(docType, 2));
rrDocument.appendChild(docType);
const htmlEl = rrDocument.createElement('html');
rrDocument.mirror.add(htmlEl, getDefaultSN(htmlEl, 3));
rrDocument.appendChild(htmlEl);
const styleEl = rrDocument.createElement('style');
rrDocument.mirror.add(styleEl, getDefaultSN(styleEl, 4));
htmlEl.appendChild(styleEl);
const headEl = rrDocument.createElement('head');
rrDocument.mirror.add(headEl, getDefaultSN(headEl, 5));
htmlEl.appendChild(headEl);
const bodyEl = rrDocument.createElement('body');
rrDocument.mirror.add(bodyEl, getDefaultSN(bodyEl, 6));
htmlEl.appendChild(bodyEl);
diff(iframe.contentDocument!, rrDocument, replayer);
expect(iframe.contentDocument!.childNodes.length).toBe(2);
const element = iframe.contentDocument!.childNodes[0] as HTMLElement;
expect(element.nodeType).toBe(element.DOCUMENT_TYPE_NODE);
expect(mirror.getId(element)).toEqual(2);
});
it('should remove children from document before adding new nodes 3', () => {
document.write('<html><body><iframe></iframe></body></html>');
const iframeInDom = document.querySelector('iframe')!;
replayer.mirror.add(iframeInDom, {
id: 3,
type: 2,
rootId: 1,
tagName: 'iframe',
childNodes: [],
attributes: {},
} as serializedNodeWithId);
replayer.mirror.add(iframeInDom.contentDocument!, {
id: 4,
type: 0,
childNodes: [],
} as serializedNodeWithId);
const rrDocument = new RRDocument();
const rrIframeEl = rrDocument.createElement('iframe');
rrDocument.mirror.add(rrIframeEl, getDefaultSN(rrIframeEl, 3));
rrDocument.appendChild(rrIframeEl);
rrDocument.mirror.add(
rrIframeEl.contentDocument!,
getDefaultSN(rrIframeEl.contentDocument!, 4),
);
const rrDocType = rrDocument.createDocumentType('html', '', '');
rrIframeEl.contentDocument.appendChild(rrDocType);
const rrHtmlEl = rrDocument.createElement('html');
rrDocument.mirror.add(rrHtmlEl, getDefaultSN(rrHtmlEl, 6));
rrIframeEl.contentDocument.appendChild(rrHtmlEl);
const rrHeadEl = rrDocument.createElement('head');
rrDocument.mirror.add(rrHeadEl, getDefaultSN(rrHeadEl, 8));
rrHtmlEl.appendChild(rrHeadEl);
const bodyEl = rrDocument.createElement('body');
rrDocument.mirror.add(bodyEl, getDefaultSN(bodyEl, 9));
rrHtmlEl.appendChild(bodyEl);
diff(iframeInDom, rrIframeEl, replayer);
expect(iframeInDom.contentDocument!.childNodes.length).toBe(2);
const element = iframeInDom.contentDocument!.childNodes[0] as HTMLElement;
expect(element.nodeType).toBe(element.DOCUMENT_TYPE_NODE);
expect(mirror.getId(element)).toEqual(-1);
});
}); });
describe('create or get a Node', () => { describe('create or get a Node', () => {

View File

@@ -78,24 +78,24 @@ describe('RRDocument for browser environment', () => {
const rrdom = new RRDocument(); const rrdom = new RRDocument();
let rrNode = buildFromNode(document, rrdom, mirror)!; let rrNode = buildFromNode(document, rrdom, mirror)!;
expect(mirror.getMeta(document)).toBeDefined(); expect(mirror.getMeta(document)).toBeDefined();
expect(mirror.getId(document)).toEqual(-1); expect(mirror.getId(document)).toEqual(-2);
expect(rrNode).not.toBeNull(); expect(rrNode).not.toBeNull();
expect(rrdom.mirror.getMeta(rrNode)).toBeDefined(); expect(rrdom.mirror.getMeta(rrNode)).toBeDefined();
expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(RRNodeType.Document); expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(RRNodeType.Document);
expect(rrdom.mirror.getId(rrNode)).toEqual(-1); expect(rrdom.mirror.getId(rrNode)).toEqual(-2);
expect(rrNode).toBe(rrdom); expect(rrNode).toBe(rrdom);
// build from document type // build from document type
expect(mirror.getMeta(document.doctype!)).toBeNull(); expect(mirror.getMeta(document.doctype!)).toBeNull();
rrNode = buildFromNode(document.doctype!, rrdom, mirror)!; rrNode = buildFromNode(document.doctype!, rrdom, mirror)!;
expect(mirror.getMeta(document.doctype!)).toBeDefined(); expect(mirror.getMeta(document.doctype!)).toBeDefined();
expect(mirror.getId(document.doctype)).toEqual(-2); expect(mirror.getId(document.doctype)).toEqual(-3);
expect(rrNode).not.toBeNull(); expect(rrNode).not.toBeNull();
expect(rrdom.mirror.getMeta(rrNode)).toBeDefined(); expect(rrdom.mirror.getMeta(rrNode)).toBeDefined();
expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual( expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(
RRNodeType.DocumentType, RRNodeType.DocumentType,
); );
expect(rrdom.mirror.getId(rrNode)).toEqual(-2); expect(rrdom.mirror.getId(rrNode)).toEqual(-3);
// build from element // build from element
expect(mirror.getMeta(document.documentElement)).toBeNull(); expect(mirror.getMeta(document.documentElement)).toBeNull();
@@ -105,33 +105,33 @@ describe('RRDocument for browser environment', () => {
mirror, mirror,
)!; )!;
expect(mirror.getMeta(document.documentElement)).toBeDefined(); expect(mirror.getMeta(document.documentElement)).toBeDefined();
expect(mirror.getId(document.documentElement)).toEqual(-3); expect(mirror.getId(document.documentElement)).toEqual(-4);
expect(rrNode).not.toBeNull(); expect(rrNode).not.toBeNull();
expect(rrdom.mirror.getMeta(rrNode)).toBeDefined(); expect(rrdom.mirror.getMeta(rrNode)).toBeDefined();
expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(RRNodeType.Element); expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(RRNodeType.Element);
expect(rrdom.mirror.getId(rrNode)).toEqual(-3); expect(rrdom.mirror.getId(rrNode)).toEqual(-4);
// build from text // build from text
const text = document.createTextNode('text'); const text = document.createTextNode('text');
expect(mirror.getMeta(text)).toBeNull(); expect(mirror.getMeta(text)).toBeNull();
rrNode = buildFromNode(text, rrdom, mirror)!; rrNode = buildFromNode(text, rrdom, mirror)!;
expect(mirror.getMeta(text)).toBeDefined(); expect(mirror.getMeta(text)).toBeDefined();
expect(mirror.getId(text)).toEqual(-4); expect(mirror.getId(text)).toEqual(-5);
expect(rrNode).not.toBeNull(); expect(rrNode).not.toBeNull();
expect(rrdom.mirror.getMeta(rrNode)).toBeDefined(); expect(rrdom.mirror.getMeta(rrNode)).toBeDefined();
expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(RRNodeType.Text); expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(RRNodeType.Text);
expect(rrdom.mirror.getId(rrNode)).toEqual(-4); expect(rrdom.mirror.getId(rrNode)).toEqual(-5);
// build from comment // build from comment
const comment = document.createComment('comment'); const comment = document.createComment('comment');
expect(mirror.getMeta(comment)).toBeNull(); expect(mirror.getMeta(comment)).toBeNull();
rrNode = buildFromNode(comment, rrdom, mirror)!; rrNode = buildFromNode(comment, rrdom, mirror)!;
expect(mirror.getMeta(comment)).toBeDefined(); expect(mirror.getMeta(comment)).toBeDefined();
expect(mirror.getId(comment)).toEqual(-5); expect(mirror.getId(comment)).toEqual(-6);
expect(rrNode).not.toBeNull(); expect(rrNode).not.toBeNull();
expect(rrdom.mirror.getMeta(rrNode)).toBeDefined(); expect(rrdom.mirror.getMeta(rrNode)).toBeDefined();
expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(RRNodeType.Comment); expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(RRNodeType.Comment);
expect(rrdom.mirror.getId(rrNode)).toEqual(-5); expect(rrdom.mirror.getId(rrNode)).toEqual(-6);
// build from CDATASection // build from CDATASection
const xmlDoc = new DOMParser().parseFromString( const xmlDoc = new DOMParser().parseFromString(
@@ -144,11 +144,11 @@ describe('RRDocument for browser environment', () => {
expect(mirror.getMeta(cdataSection)).toBeNull(); expect(mirror.getMeta(cdataSection)).toBeNull();
rrNode = buildFromNode(cdataSection, rrdom, mirror)!; rrNode = buildFromNode(cdataSection, rrdom, mirror)!;
expect(mirror.getMeta(cdataSection)).toBeDefined(); expect(mirror.getMeta(cdataSection)).toBeDefined();
expect(mirror.getId(cdataSection)).toEqual(-6); expect(mirror.getId(cdataSection)).toEqual(-7);
expect(rrNode).not.toBeNull(); expect(rrNode).not.toBeNull();
expect(rrdom.mirror.getMeta(rrNode)).toBeDefined(); expect(rrdom.mirror.getMeta(rrNode)).toBeDefined();
expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(RRNodeType.CDATA); expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(RRNodeType.CDATA);
expect(rrdom.mirror.getId(rrNode)).toEqual(-6); expect(rrdom.mirror.getId(rrNode)).toEqual(-7);
expect(rrNode.textContent).toEqual(cdata); expect(rrNode.textContent).toEqual(cdata);
}); });
@@ -184,8 +184,8 @@ describe('RRDocument for browser environment', () => {
expect(rrNode).not.toBeNull(); expect(rrNode).not.toBeNull();
expect(rrdom.mirror.getMeta(rrNode)).toBeDefined(); expect(rrdom.mirror.getMeta(rrNode)).toBeDefined();
expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(RRNodeType.Document); expect(rrdom.mirror.getMeta(rrNode)!.type).toEqual(RRNodeType.Document);
expect(rrdom.mirror.getId(rrNode)).toEqual(-1); expect(rrdom.mirror.getId(rrNode)).toEqual(-2);
expect(mirror.getId(iframe.contentDocument)).toEqual(-1); expect(mirror.getId(iframe.contentDocument)).toEqual(-2);
expect(rrNode).toBe(RRIFrame.contentDocument); expect(rrNode).toBe(RRIFrame.contentDocument);
}); });
@@ -203,8 +203,8 @@ describe('RRDocument for browser environment', () => {
)!; )!;
expect(rrNode).not.toBeNull(); expect(rrNode).not.toBeNull();
expect(rrdom.mirror.getMeta(rrNode)).toBeDefined(); expect(rrdom.mirror.getMeta(rrNode)).toBeDefined();
expect(rrdom.mirror.getId(rrNode)).toEqual(-1); expect(rrdom.mirror.getId(rrNode)).toEqual(-2);
expect(mirror.getId(div.shadowRoot)).toEqual(-1); expect(mirror.getId(div.shadowRoot)).toEqual(-2);
expect(rrNode.RRNodeType).toEqual(RRNodeType.Element); expect(rrNode.RRNodeType).toEqual(RRNodeType.Element);
expect((rrNode as RRElement).tagName).toEqual('SHADOWROOT'); expect((rrNode as RRElement).tagName).toEqual('SHADOWROOT');
expect(rrNode).toBe(parentRRNode.shadowRoot); expect(rrNode).toBe(parentRRNode.shadowRoot);
@@ -296,7 +296,7 @@ describe('RRDocument for browser environment', () => {
describe('RRDocument build for virtual dom', () => { describe('RRDocument build for virtual dom', () => {
it('can access a unique, decremented unserializedId every time', () => { it('can access a unique, decremented unserializedId every time', () => {
const node = new RRDocument(); const node = new RRDocument();
for (let i = 1; i <= 100; i++) expect(node.unserializedId).toBe(-i); for (let i = 2; i <= 100; i++) expect(node.unserializedId).toBe(-i);
}); });
it('can create a new RRDocument', () => { it('can create a new RRDocument', () => {
@@ -357,12 +357,12 @@ describe('RRDocument for browser environment', () => {
const documentType = dom.createDocumentType('html', '', ''); const documentType = dom.createDocumentType('html', '', '');
dom.appendChild(documentType); dom.appendChild(documentType);
expect(dom.childNodes[0]).toBe(documentType); expect(dom.childNodes[0]).toBe(documentType);
expect(dom.unserializedId).toBe(-1);
expect(dom.unserializedId).toBe(-2); expect(dom.unserializedId).toBe(-2);
expect(dom.unserializedId).toBe(-3);
expect(dom.close()); expect(dom.close());
expect(dom.open()); expect(dom.open());
expect(dom.childNodes.length).toEqual(0); expect(dom.childNodes.length).toEqual(0);
expect(dom.unserializedId).toBe(-1); expect(dom.unserializedId).toBe(-2);
}); });
it('can execute a dummy getContext function in RRCanvasElement', () => { it('can execute a dummy getContext function in RRCanvasElement', () => {

View File

@@ -388,7 +388,8 @@ function onceIframeLoaded(
// iframe was already loaded, make sure we wait to trigger the listener // iframe was already loaded, make sure we wait to trigger the listener
// till _after_ the mutation that found this iframe has had time to process // till _after_ the mutation that found this iframe has had time to process
setTimeout(listener, 0); setTimeout(listener, 0);
return;
return iframeEl.addEventListener('load', listener); // keep listing for future loads
} }
// use default listener // use default listener
iframeEl.addEventListener('load', listener); iframeEl.addEventListener('load', listener);

View File

@@ -0,0 +1,5 @@
<html xmlns="http://www.w3.org/1999/xhtml">
<body>
<iframe src="picture-blob.html"></iframe>
</body>
</html>

View File

@@ -0,0 +1,16 @@
<html xmlns="http://www.w3.org/1999/xhtml">
<body>
<img src="" alt="This is a robot" />
</body>
<script>
setTimeout(async function () {
const robotFile = await fetch('/images/robot.png');
const robotBlob = await robotFile.blob();
const robotBlobUrl = URL.createObjectURL(robotBlob);
const images = document.querySelectorAll('img');
images.forEach((img) => {
img.src = robotBlobUrl;
});
}, 0);
</script>
</html>

View File

@@ -0,0 +1,5 @@
<html xmlns="http://www.w3.org/1999/xhtml">
<body>
<iframe src="picture.html"></iframe>
</body>
</html>

View File

@@ -6,6 +6,7 @@ import * as puppeteer from 'puppeteer';
import * as rollup from 'rollup'; import * as rollup from 'rollup';
import * as typescript from 'rollup-plugin-typescript2'; import * as typescript from 'rollup-plugin-typescript2';
import * as assert from 'assert'; import * as assert from 'assert';
import { waitForRAF } from './utils';
const _typescript = (typescript as unknown) as () => rollup.Plugin; const _typescript = (typescript as unknown) as () => rollup.Plugin;
@@ -207,6 +208,74 @@ iframe.contentDocument.querySelector('center').clientHeight
assert(snapshot.includes('"rr_dataURL"')); assert(snapshot.includes('"rr_dataURL"'));
assert(snapshot.includes('data:image/webp;base64,')); 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) { describe('iframe integration tests', function (this: ISuite) {

View File

@@ -0,0 +1,11 @@
import * as puppeteer from 'puppeteer';
export async function waitForRAF(page: puppeteer.Page) {
return await page.evaluate(() => {
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);
});
});
});
}

View File

@@ -249,6 +249,7 @@ function record<T = eventWithTime>(
iframeManager, iframeManager,
stylesheetManager, stylesheetManager,
canvasManager, canvasManager,
keepIframeSrcFn,
}, },
mirror, mirror,
}); });
@@ -455,6 +456,7 @@ function record<T = eventWithTime>(
doc, doc,
maskInputFn, maskInputFn,
maskTextFn, maskTextFn,
keepIframeSrcFn,
blockSelector, blockSelector,
slimDOMOptions, slimDOMOptions,
mirror, mirror,

View File

@@ -165,6 +165,7 @@ export default class MutationBuffer {
private maskInputOptions: observerParam['maskInputOptions']; private maskInputOptions: observerParam['maskInputOptions'];
private maskTextFn: observerParam['maskTextFn']; private maskTextFn: observerParam['maskTextFn'];
private maskInputFn: observerParam['maskInputFn']; private maskInputFn: observerParam['maskInputFn'];
private keepIframeSrcFn: observerParam['keepIframeSrcFn'];
private recordCanvas: observerParam['recordCanvas']; private recordCanvas: observerParam['recordCanvas'];
private inlineImages: observerParam['inlineImages']; private inlineImages: observerParam['inlineImages'];
private slimDOMOptions: observerParam['slimDOMOptions']; private slimDOMOptions: observerParam['slimDOMOptions'];
@@ -186,6 +187,7 @@ export default class MutationBuffer {
'maskInputOptions', 'maskInputOptions',
'maskTextFn', 'maskTextFn',
'maskInputFn', 'maskInputFn',
'keepIframeSrcFn',
'recordCanvas', 'recordCanvas',
'inlineImages', 'inlineImages',
'slimDOMOptions', 'slimDOMOptions',
@@ -485,6 +487,19 @@ export default class MutationBuffer {
let item: attributeCursor | undefined = this.attributes.find( let item: attributeCursor | undefined = this.attributes.find(
(a) => a.node === m.target, (a) => a.node === m.target,
); );
if (
target.tagName === 'IFRAME' &&
m.attributeName === 'src' &&
!this.keepIframeSrcFn(value as string)
) {
if (!(target as HTMLIFrameElement).contentDocument) {
// we can't record it directly as we can't see into it
// preserve the src attribute so a decision can be taken at replay time
m.attributeName = 'rr_src';
} else {
return;
}
}
if (!item) { if (!item) {
item = { item = {
node: m.target, node: m.target,
@@ -528,7 +543,7 @@ export default class MutationBuffer {
// overwrite attribute if the mutations was triggered in same time // overwrite attribute if the mutations was triggered in same time
item.attributes[m.attributeName!] = transformAttribute( item.attributes[m.attributeName!] = transformAttribute(
this.doc, this.doc,
(m.target as HTMLElement).tagName, target.tagName,
m.attributeName!, m.attributeName!,
value!, value!,
); );

View File

@@ -276,6 +276,7 @@ export type observerParam = {
maskInputOptions: MaskInputOptions; maskInputOptions: MaskInputOptions;
maskInputFn?: MaskInputFn; maskInputFn?: MaskInputFn;
maskTextFn?: MaskTextFn; maskTextFn?: MaskTextFn;
keepIframeSrcFn: KeepIframeSrcFn;
inlineStylesheet: boolean; inlineStylesheet: boolean;
styleSheetRuleCb: styleSheetRuleCallback; styleSheetRuleCb: styleSheetRuleCallback;
styleDeclarationCb: styleDeclarationCallback; styleDeclarationCb: styleDeclarationCallback;
@@ -315,6 +316,7 @@ export type MutationBufferParam = Pick<
| 'maskInputOptions' | 'maskInputOptions'
| 'maskTextFn' | 'maskTextFn'
| 'maskInputFn' | 'maskInputFn'
| 'keepIframeSrcFn'
| 'recordCanvas' | 'recordCanvas'
| 'inlineImages' | 'inlineImages'
| 'slimDOMOptions' | 'slimDOMOptions'

File diff suppressed because it is too large Load Diff

View File

@@ -81,13 +81,80 @@ file-cid-3
`; `;
exports[`replayer can fast forward past StyleSheetRule deletion on virtual elements 1`] = ` exports[`replayer can fast forward past StyleSheetRule deletion on virtual elements 1`] = `
"file-frame-0 "file-frame-4
<html> <html>
<head> <head>
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\" /> <meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\" />
</head> </head>
<body></body> <body>
<div class=\\"replayer-wrapper\\">
<div class=\\"replayer-mouse\\"></div>
<canvas
class=\\"replayer-mouse-tail\\"
width=\\"1000\\"
height=\\"800\\"
style=\\"display: inherit\\"
></canvas
><iframe
sandbox=\\"allow-same-origin\\"
scrolling=\\"no\\"
width=\\"1000\\"
height=\\"800\\"
style=\\"display: inherit; pointer-events: none\\"
></iframe>
</div>
</body>
</html> </html>
file-frame-5
<!DOCTYPE html>
<html lang=\\"en\\" class=\\"rrweb-paused\\">
<head>
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\" />
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-0\\" />
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-1\\" />
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-2\\" />
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-3\\" />
</head>
<body>
<a class=\\"css-added-at-1000-deleted-at-2500\\">string</a>
</body>
</html>
file-cid-0
@charset \\"utf-8\\";
.rr-block { background: currentcolor; }
noscript { display: none !important; }
html.rrweb-paused *, html.rrweb-paused ::before, html.rrweb-paused ::after { animation-play-state: paused !important; }
file-cid-1
@charset \\"utf-8\\";
.css-added-at-400 { padding: 1.3125rem; flex: 0 0 auto; width: 100%; }
file-cid-2
@charset \\"utf-8\\";
.css-added-at-200-overwritten-at-3000 { opacity: 1; transform: translateX(0px); }
.css-added-at-500-overwritten-at-3000 { border: 1px solid blue; }
file-cid-3
@charset \\"utf-8\\";
.css-added-at-200 { position: fixed; top: 0px; right: 0px; left: 4rem; z-index: 15; flex-shrink: 0; height: 0.25rem; overflow: hidden; background-color: rgb(17, 171, 209); }
.css-added-at-200.alt { height: 0.25rem; background-color: rgb(190, 232, 242); opacity: 0; transition: opacity 0.5s ease 0s; }
.css-added-at-200.alt2 { padding-left: 4rem; }
" "
`; `;

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Frame with image</title>
</head>
<body>
<iframe id="four" src="/html/image-blob-url.html" frameborder="0"></iframe>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Image with blob:url</title>
</head>
<body>
<script>
setTimeout(async function () {
const robotFile = await fetch('/html/assets/robot.png');
const robotBlob = await robotFile.blob();
const robotBlobUrl = URL.createObjectURL(robotBlob);
const el = document.createElement('img');
el.src = robotBlobUrl;
document.body.append(el);
}, 10);
</script>
</body>
</html>

View File

@@ -499,6 +499,57 @@ describe('record integration tests', function (this: ISuite) {
assertSnapshot(snapshots); assertSnapshot(snapshots);
}); });
it('should record images with blob url', async () => {
const page: puppeteer.Page = await browser.newPage();
page.on('console', (msg) => console.log(msg.text()));
await page.goto(`${serverURL}/html`);
page.setContent(
getHtml.call(this, 'image-blob-url.html', { inlineImages: true }),
);
await page.waitForResponse(`${serverURL}/html/assets/robot.png`);
await page.waitForSelector('img'); // wait for image to get added
await waitForRAF(page); // wait for image to be captured
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots);
});
it('should record images inside iframe with blob url', async () => {
const page: puppeteer.Page = await browser.newPage();
page.on('console', (msg) => console.log(msg.text()));
await page.goto(`${serverURL}/html`);
await page.setContent(
getHtml.call(this, 'frame-image-blob-url.html', { inlineImages: true }),
);
await page.waitForResponse(`${serverURL}/html/assets/robot.png`);
await page.waitForTimeout(50); // wait for image to get added
await waitForRAF(page); // wait for image to be captured
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots);
});
it('should record images inside iframe with blob url after iframe was reloaded', async () => {
const page: puppeteer.Page = await browser.newPage();
page.on('console', (msg) => console.log(msg.text()));
await page.goto(`${serverURL}/html`);
await page.setContent(
getHtml.call(this, 'frame2.html', { inlineImages: true }),
);
await page.waitForSelector('iframe'); // wait for iframe to get added
await waitForRAF(page); // wait for iframe to load
page.evaluate(() => {
const iframe = document.querySelector('iframe')!;
iframe.setAttribute('src', '/html/image-blob-url.html');
});
await page.waitForResponse(`${serverURL}/html/assets/robot.png`); // wait for image to get loaded
await page.waitForTimeout(50); // wait for image to get added
await waitForRAF(page); // wait for image to be captured
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots);
});
it('should record shadow DOM', async () => { it('should record shadow DOM', async () => {
const page: puppeteer.Page = await browser.newPage(); const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank'); await page.goto('about:blank');
@@ -589,6 +640,34 @@ describe('record integration tests', function (this: ISuite) {
assertSnapshot(snapshots); assertSnapshot(snapshots);
}); });
it('should record mutations in iframes accross pages', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto(`${serverURL}/html`);
page.on('console', (msg) => console.log(msg.text()));
await page.setContent(getHtml.call(this, 'frame2.html'));
await page.waitForSelector('iframe'); // wait for iframe to get added
await waitForRAF(page); // wait for iframe to load
page.evaluate((serverURL) => {
const iframe = document.querySelector('iframe')!;
iframe.setAttribute('src', `${serverURL}/html`); // load new page
}, serverURL);
await page.waitForResponse(`${serverURL}/html`); // wait for iframe to load pt1
await waitForRAF(page); // wait for iframe to load pt2
await page.evaluate(() => {
const iframeDocument = document.querySelector('iframe')!.contentDocument!;
const div = iframeDocument.createElement('div');
iframeDocument.body.appendChild(div);
});
await waitForRAF(page); // wait for snapshot to be updated
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots);
});
// https://github.com/webcomponents/polyfills/tree/master/packages/shadydom // https://github.com/webcomponents/polyfills/tree/master/packages/shadydom
it('should record shadow doms polyfilled by shadydom', async () => { it('should record shadow doms polyfilled by shadydom', async () => {
const page: puppeteer.Page = await browser.newPage(); const page: puppeteer.Page = await browser.newPage();

View File

@@ -160,11 +160,7 @@ describe('replayer', function () {
).length, ).length,
); );
await assertDomSnapshot( await assertDomSnapshot(page);
page,
__filename,
'style-sheet-rule-events-play-at-1500',
);
}); });
it('should apply fast forwarded StyleSheetRules that where added', async () => { it('should apply fast forwarded StyleSheetRules that where added', async () => {
@@ -196,11 +192,7 @@ describe('replayer', function () {
).length, ).length,
); );
await assertDomSnapshot( await assertDomSnapshot(page);
page,
__filename,
'style-sheet-remove-events-play-at-2500',
);
}); });
it('can restore selection', async () => { it('can restore selection', async () => {
@@ -221,11 +213,14 @@ describe('replayer', function () {
it('can fast forward past StyleSheetRule deletion on virtual elements', async () => { it('can fast forward past StyleSheetRule deletion on virtual elements', async () => {
await page.evaluate(`events = ${JSON.stringify(styleSheetRuleEvents)}`); await page.evaluate(`events = ${JSON.stringify(styleSheetRuleEvents)}`);
await assertDomSnapshot( const actionLength = await page.evaluate(`
page, const { Replayer } = rrweb;
__filename, const replayer = new Replayer(events);
'style-sheet-rule-events-play-at-2500', replayer.pause(2600);
); replayer['timer']['actions'].length;
`);
await assertDomSnapshot(page);
}); });
it('should delete fast forwarded StyleSheetRules that where removed', async () => { it('should delete fast forwarded StyleSheetRules that where removed', async () => {
@@ -676,7 +671,7 @@ describe('replayer', function () {
`); `);
await page.waitForTimeout(50); await page.waitForTimeout(50);
await assertDomSnapshot(page, __filename, 'ordering-events'); await assertDomSnapshot(page);
}); });
it('replays same timestamp events in correct order (with addAction)', async () => { it('replays same timestamp events in correct order (with addAction)', async () => {
@@ -690,6 +685,6 @@ describe('replayer', function () {
`); `);
await page.waitForTimeout(50); await page.waitForTimeout(50);
await assertDomSnapshot(page, __filename, 'ordering-events'); await assertDomSnapshot(page);
}); });
}); });

View File

@@ -152,20 +152,54 @@ function stringifySnapshots(snapshots: eventWithTime[]): string {
coordinatesReg.lastIndex = 0; // wow, a real wart in ECMAScript coordinatesReg.lastIndex = 0; // wow, a real wart in ECMAScript
} }
} }
// strip blob:urls as they are different every time
console.log(
a.attributes.src,
'src' in a.attributes &&
a.attributes.src &&
typeof a.attributes.src === 'string',
);
}); });
s.data.adds.forEach((add) => { s.data.adds.forEach((add) => {
if ( if (add.node.type === NodeType.Element) {
add.node.type === NodeType.Element && if (
'style' in add.node.attributes && 'style' in add.node.attributes &&
typeof add.node.attributes.style === 'string' && typeof add.node.attributes.style === 'string' &&
coordinatesReg.test(add.node.attributes.style) coordinatesReg.test(add.node.attributes.style)
) { ) {
add.node.attributes.style = add.node.attributes.style.replace( add.node.attributes.style = add.node.attributes.style.replace(
coordinatesReg, coordinatesReg,
'$1: Npx', '$1: Npx',
); );
}
coordinatesReg.lastIndex = 0; // wow, a real wart in ECMAScript
// strip blob:urls as they are different every time
if (
'src' in add.node.attributes &&
add.node.attributes.src &&
typeof add.node.attributes.src === 'string' &&
add.node.attributes.src.startsWith('blob:')
) {
add.node.attributes.src = add.node.attributes.src.replace(
/[\w-]+$/,
'...',
);
}
// 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(
/,.+$/,
',...',
);
}
} }
coordinatesReg.lastIndex = 0; // wow, a real wart in ECMAScript
}); });
} }
delete (s as Optional<eventWithTime, 'timestamp'>).timestamp; delete (s as Optional<eventWithTime, 'timestamp'>).timestamp;
@@ -223,11 +257,7 @@ export function replaceLast(str: string, find: string, replace: string) {
return str.substring(0, index) + replace + str.substring(index + find.length); return str.substring(0, index) + replace + str.substring(index + find.length);
} }
export async function assertDomSnapshot( export async function assertDomSnapshot(page: puppeteer.Page) {
page: puppeteer.Page,
filename: string,
name: string,
) {
const cdp = await page.target().createCDPSession(); const cdp = await page.target().createCDPSession();
const { data } = await cdp.send('Page.captureSnapshot', { const { data } = await cdp.send('Page.captureSnapshot', {
format: 'mhtml', format: 'mhtml',
@@ -555,6 +585,7 @@ export function generateRecordSnippet(options: recordOptions<eventWithTime>) {
userTriggeredOnInput: ${options.userTriggeredOnInput}, userTriggeredOnInput: ${options.userTriggeredOnInput},
maskTextFn: ${options.maskTextFn}, maskTextFn: ${options.maskTextFn},
recordCanvas: ${options.recordCanvas}, recordCanvas: ${options.recordCanvas},
inlineImages: ${options.inlineImages},
plugins: ${options.plugins} plugins: ${options.plugins}
}); });
`; `;