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:
File diff suppressed because it is too large
Load Diff
@@ -81,13 +81,80 @@ file-cid-3
|
||||
`;
|
||||
|
||||
exports[`replayer can fast forward past StyleSheetRule deletion on virtual elements 1`] = `
|
||||
"file-frame-0
|
||||
"file-frame-4
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\" />
|
||||
</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>
|
||||
|
||||
|
||||
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; }
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
BIN
packages/rrweb/test/html/assets/robot.png
Normal file
BIN
packages/rrweb/test/html/assets/robot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
11
packages/rrweb/test/html/frame-image-blob-url.html
Normal file
11
packages/rrweb/test/html/frame-image-blob-url.html
Normal 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>
|
||||
21
packages/rrweb/test/html/image-blob-url.html
Normal file
21
packages/rrweb/test/html/image-blob-url.html
Normal 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>
|
||||
@@ -499,6 +499,57 @@ describe('record integration tests', function (this: ISuite) {
|
||||
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 () => {
|
||||
const page: puppeteer.Page = await browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
@@ -589,6 +640,34 @@ describe('record integration tests', function (this: ISuite) {
|
||||
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
|
||||
it('should record shadow doms polyfilled by shadydom', async () => {
|
||||
const page: puppeteer.Page = await browser.newPage();
|
||||
|
||||
@@ -160,11 +160,7 @@ describe('replayer', function () {
|
||||
).length,
|
||||
);
|
||||
|
||||
await assertDomSnapshot(
|
||||
page,
|
||||
__filename,
|
||||
'style-sheet-rule-events-play-at-1500',
|
||||
);
|
||||
await assertDomSnapshot(page);
|
||||
});
|
||||
|
||||
it('should apply fast forwarded StyleSheetRules that where added', async () => {
|
||||
@@ -196,11 +192,7 @@ describe('replayer', function () {
|
||||
).length,
|
||||
);
|
||||
|
||||
await assertDomSnapshot(
|
||||
page,
|
||||
__filename,
|
||||
'style-sheet-remove-events-play-at-2500',
|
||||
);
|
||||
await assertDomSnapshot(page);
|
||||
});
|
||||
|
||||
it('can restore selection', async () => {
|
||||
@@ -221,11 +213,14 @@ describe('replayer', function () {
|
||||
it('can fast forward past StyleSheetRule deletion on virtual elements', async () => {
|
||||
await page.evaluate(`events = ${JSON.stringify(styleSheetRuleEvents)}`);
|
||||
|
||||
await assertDomSnapshot(
|
||||
page,
|
||||
__filename,
|
||||
'style-sheet-rule-events-play-at-2500',
|
||||
);
|
||||
const actionLength = await page.evaluate(`
|
||||
const { Replayer } = rrweb;
|
||||
const replayer = new Replayer(events);
|
||||
replayer.pause(2600);
|
||||
replayer['timer']['actions'].length;
|
||||
`);
|
||||
|
||||
await assertDomSnapshot(page);
|
||||
});
|
||||
|
||||
it('should delete fast forwarded StyleSheetRules that where removed', async () => {
|
||||
@@ -676,7 +671,7 @@ describe('replayer', function () {
|
||||
`);
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
await assertDomSnapshot(page, __filename, 'ordering-events');
|
||||
await assertDomSnapshot(page);
|
||||
});
|
||||
|
||||
it('replays same timestamp events in correct order (with addAction)', async () => {
|
||||
@@ -690,6 +685,6 @@ describe('replayer', function () {
|
||||
`);
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
await assertDomSnapshot(page, __filename, 'ordering-events');
|
||||
await assertDomSnapshot(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,20 +152,54 @@ function stringifySnapshots(snapshots: eventWithTime[]): string {
|
||||
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) => {
|
||||
if (
|
||||
add.node.type === NodeType.Element &&
|
||||
'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',
|
||||
);
|
||||
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
|
||||
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;
|
||||
@@ -223,11 +257,7 @@ export function replaceLast(str: string, find: string, replace: string) {
|
||||
return str.substring(0, index) + replace + str.substring(index + find.length);
|
||||
}
|
||||
|
||||
export async function assertDomSnapshot(
|
||||
page: puppeteer.Page,
|
||||
filename: string,
|
||||
name: string,
|
||||
) {
|
||||
export async function assertDomSnapshot(page: puppeteer.Page) {
|
||||
const cdp = await page.target().createCDPSession();
|
||||
const { data } = await cdp.send('Page.captureSnapshot', {
|
||||
format: 'mhtml',
|
||||
@@ -555,6 +585,7 @@ export function generateRecordSnippet(options: recordOptions<eventWithTime>) {
|
||||
userTriggeredOnInput: ${options.userTriggeredOnInput},
|
||||
maskTextFn: ${options.maskTextFn},
|
||||
recordCanvas: ${options.recordCanvas},
|
||||
inlineImages: ${options.inlineImages},
|
||||
plugins: ${options.plugins}
|
||||
});
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user