From d2cb4112c9d94d53618a866d490b42c70b8e6f8b Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 29 Jun 2022 17:31:24 +0200 Subject: [PATCH 1/6] Add test cases for inlineImages --- .../test/html/picture-blob-in-frame.html | 5 + .../test/html/picture-blob.html | 16 + .../test/html/picture-in-frame.html | 5 + .../rrweb-snapshot/test/integration.test.ts | 69 ++ packages/rrweb-snapshot/test/utils.ts | 11 + .../__snapshots__/integration.test.ts.snap | 634 ++++++++++++++++++ packages/rrweb/test/html/assets/robot.png | Bin 0 -> 11004 bytes .../rrweb/test/html/frame-image-blob-url.html | 11 + packages/rrweb/test/html/image-blob-url.html | 21 + packages/rrweb/test/integration.test.ts | 30 + packages/rrweb/test/utils.ts | 57 +- 11 files changed, 848 insertions(+), 11 deletions(-) create mode 100644 packages/rrweb-snapshot/test/html/picture-blob-in-frame.html create mode 100644 packages/rrweb-snapshot/test/html/picture-blob.html create mode 100644 packages/rrweb-snapshot/test/html/picture-in-frame.html create mode 100644 packages/rrweb-snapshot/test/utils.ts create mode 100644 packages/rrweb/test/html/assets/robot.png create mode 100644 packages/rrweb/test/html/frame-image-blob-url.html create mode 100644 packages/rrweb/test/html/image-blob-url.html diff --git a/packages/rrweb-snapshot/test/html/picture-blob-in-frame.html b/packages/rrweb-snapshot/test/html/picture-blob-in-frame.html new file mode 100644 index 0000000000..f6237f22b2 --- /dev/null +++ b/packages/rrweb-snapshot/test/html/picture-blob-in-frame.html @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/rrweb-snapshot/test/html/picture-blob.html b/packages/rrweb-snapshot/test/html/picture-blob.html new file mode 100644 index 0000000000..d2a32658a1 --- /dev/null +++ b/packages/rrweb-snapshot/test/html/picture-blob.html @@ -0,0 +1,16 @@ + + + This is a robot + + + diff --git a/packages/rrweb-snapshot/test/html/picture-in-frame.html b/packages/rrweb-snapshot/test/html/picture-in-frame.html new file mode 100644 index 0000000000..31684d2cda --- /dev/null +++ b/packages/rrweb-snapshot/test/html/picture-in-frame.html @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/rrweb-snapshot/test/integration.test.ts b/packages/rrweb-snapshot/test/integration.test.ts index e34432fd7b..b6a0c85193 100644 --- a/packages/rrweb-snapshot/test/integration.test.ts +++ b/packages/rrweb-snapshot/test/integration.test.ts @@ -6,6 +6,7 @@ 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; @@ -209,6 +210,74 @@ iframe.contentDocument.querySelector('center').clientHeight 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) { diff --git a/packages/rrweb-snapshot/test/utils.ts b/packages/rrweb-snapshot/test/utils.ts new file mode 100644 index 0000000000..43d4484bb4 --- /dev/null +++ b/packages/rrweb-snapshot/test/utils.ts @@ -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); + }); + }); + }); +} diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index 5af8b6dcf1..b48eac5ade 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -9096,6 +9096,640 @@ exports[`record integration tests should record dynamic CSS changes 1`] = ` ]" `; +exports[`record integration tests should record images inside iframe with blob url 1`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Frame with image\\", + \\"id\\": 11 + } + ], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 12 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 13 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 15 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"id\\": \\"four\\", + \\"frameborder\\": \\"0\\" + }, + \\"childNodes\\": [], + \\"id\\": 16 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\", + \\"id\\": 17 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 19 + } + ], + \\"id\\": 18 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", + \\"id\\": 20 + } + ], + \\"id\\": 14 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 16, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"rootId\\": 21, + \\"id\\": 22 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 21, + \\"id\\": 25 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 21, + \\"id\\": 26 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 21, + \\"id\\": 27 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"http-equiv\\": \\"X-UA-Compatible\\", + \\"content\\": \\"IE=edge\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 21, + \\"id\\": 28 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 21, + \\"id\\": 29 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 21, + \\"id\\": 30 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 21, + \\"id\\": 31 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Image with blob:url\\", + \\"rootId\\": 21, + \\"id\\": 33 + } + ], + \\"rootId\\": 21, + \\"id\\": 32 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 21, + \\"id\\": 34 + } + ], + \\"rootId\\": 21, + \\"id\\": 24 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 21, + \\"id\\": 35 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 21, + \\"id\\": 37 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"rootId\\": 21, + \\"id\\": 39 + } + ], + \\"rootId\\": 21, + \\"id\\": 38 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n\\\\n\\", + \\"rootId\\": 21, + \\"id\\": 40 + } + ], + \\"rootId\\": 21, + \\"id\\": 36 + } + ], + \\"rootId\\": 21, + \\"id\\": 23 + } + ], + \\"id\\": 21 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 36, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"img\\", + \\"attributes\\": { + \\"src\\": \\"blob:http://localhost:3030/...\\", + \\"rr_dataURL\\": \\"data:image/png;base64,...\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 21, + \\"id\\": 41 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 41, + \\"attributes\\": { + \\"crossorigin\\": \\"anonymous\\" + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 41, + \\"attributes\\": { + \\"crossorigin\\": null + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + } +]" +`; + +exports[`record integration tests should record images with blob url 1`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"http-equiv\\": \\"X-UA-Compatible\\", + \\"content\\": \\"IE=edge\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 11 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Image with blob:url\\", + \\"id\\": 13 + } + ], + \\"id\\": 12 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 14 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 15 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 17 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 19 + } + ], + \\"id\\": 18 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\", + \\"id\\": 20 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 22 + } + ], + \\"id\\": 21 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", + \\"id\\": 23 + } + ], + \\"id\\": 16 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 16, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"img\\", + \\"attributes\\": { + \\"src\\": \\"blob:http://localhost:3030/...\\", + \\"rr_dataURL\\": \\"data:image/png;base64,...\\" + }, + \\"childNodes\\": [], + \\"id\\": 24 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 24, + \\"attributes\\": { + \\"crossorigin\\": \\"anonymous\\" + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 24, + \\"attributes\\": { + \\"crossorigin\\": null + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + } +]" +`; + exports[`record integration tests should record input userTriggered values if userTriggeredOnInput is enabled 1`] = ` "[ { diff --git a/packages/rrweb/test/html/assets/robot.png b/packages/rrweb/test/html/assets/robot.png new file mode 100644 index 0000000000000000000000000000000000000000..cc486cc8b70680b0dfd72828b6530e0a4e9183ad GIT binary patch literal 11004 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 qTg~2LrAnpQZV>`Mi|_sS + + + + + Frame with image + + + + + diff --git a/packages/rrweb/test/html/image-blob-url.html b/packages/rrweb/test/html/image-blob-url.html new file mode 100644 index 0000000000..4dd3f60855 --- /dev/null +++ b/packages/rrweb/test/html/image-blob-url.html @@ -0,0 +1,21 @@ + + + + + + + Image with blob:url + + + + + diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 797d537346..5a3cc5dfce 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -495,6 +495,36 @@ 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 shadow DOM', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index 89bc28453b..487f1aab48 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -153,20 +153,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).timestamp; @@ -556,6 +590,7 @@ export function generateRecordSnippet(options: recordOptions) { userTriggeredOnInput: ${options.userTriggeredOnInput}, maskTextFn: ${options.maskTextFn}, recordCanvas: ${options.recordCanvas}, + inlineImages: ${options.inlineImages}, plugins: ${options.plugins} }); `; From 18c44758f238b4f5b69cd84ac8a6da1635d74295 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Wed, 29 Jun 2022 17:31:24 +0200 Subject: [PATCH 2/6] Add test cases for inlineImages --- .../test/html/picture-blob-in-frame.html | 5 + .../test/html/picture-blob.html | 16 + .../test/html/picture-in-frame.html | 5 + .../rrweb-snapshot/test/integration.test.ts | 69 ++ packages/rrweb-snapshot/test/utils.ts | 11 + .../__snapshots__/integration.test.ts.snap | 634 ++++++++++++++++++ packages/rrweb/test/html/assets/robot.png | Bin 0 -> 11004 bytes .../rrweb/test/html/frame-image-blob-url.html | 11 + packages/rrweb/test/html/image-blob-url.html | 21 + packages/rrweb/test/integration.test.ts | 30 + packages/rrweb/test/utils.ts | 57 +- 11 files changed, 848 insertions(+), 11 deletions(-) create mode 100644 packages/rrweb-snapshot/test/html/picture-blob-in-frame.html create mode 100644 packages/rrweb-snapshot/test/html/picture-blob.html create mode 100644 packages/rrweb-snapshot/test/html/picture-in-frame.html create mode 100644 packages/rrweb-snapshot/test/utils.ts create mode 100644 packages/rrweb/test/html/assets/robot.png create mode 100644 packages/rrweb/test/html/frame-image-blob-url.html create mode 100644 packages/rrweb/test/html/image-blob-url.html diff --git a/packages/rrweb-snapshot/test/html/picture-blob-in-frame.html b/packages/rrweb-snapshot/test/html/picture-blob-in-frame.html new file mode 100644 index 0000000000..f6237f22b2 --- /dev/null +++ b/packages/rrweb-snapshot/test/html/picture-blob-in-frame.html @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/rrweb-snapshot/test/html/picture-blob.html b/packages/rrweb-snapshot/test/html/picture-blob.html new file mode 100644 index 0000000000..d2a32658a1 --- /dev/null +++ b/packages/rrweb-snapshot/test/html/picture-blob.html @@ -0,0 +1,16 @@ + + + This is a robot + + + diff --git a/packages/rrweb-snapshot/test/html/picture-in-frame.html b/packages/rrweb-snapshot/test/html/picture-in-frame.html new file mode 100644 index 0000000000..31684d2cda --- /dev/null +++ b/packages/rrweb-snapshot/test/html/picture-in-frame.html @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/rrweb-snapshot/test/integration.test.ts b/packages/rrweb-snapshot/test/integration.test.ts index e34432fd7b..b6a0c85193 100644 --- a/packages/rrweb-snapshot/test/integration.test.ts +++ b/packages/rrweb-snapshot/test/integration.test.ts @@ -6,6 +6,7 @@ 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; @@ -209,6 +210,74 @@ iframe.contentDocument.querySelector('center').clientHeight 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) { diff --git a/packages/rrweb-snapshot/test/utils.ts b/packages/rrweb-snapshot/test/utils.ts new file mode 100644 index 0000000000..43d4484bb4 --- /dev/null +++ b/packages/rrweb-snapshot/test/utils.ts @@ -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); + }); + }); + }); +} diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index 5af8b6dcf1..b48eac5ade 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -9096,6 +9096,640 @@ exports[`record integration tests should record dynamic CSS changes 1`] = ` ]" `; +exports[`record integration tests should record images inside iframe with blob url 1`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Frame with image\\", + \\"id\\": 11 + } + ], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 12 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 13 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 15 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"id\\": \\"four\\", + \\"frameborder\\": \\"0\\" + }, + \\"childNodes\\": [], + \\"id\\": 16 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\", + \\"id\\": 17 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 19 + } + ], + \\"id\\": 18 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", + \\"id\\": 20 + } + ], + \\"id\\": 14 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 16, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"rootId\\": 21, + \\"id\\": 22 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 21, + \\"id\\": 25 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 21, + \\"id\\": 26 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 21, + \\"id\\": 27 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"http-equiv\\": \\"X-UA-Compatible\\", + \\"content\\": \\"IE=edge\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 21, + \\"id\\": 28 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 21, + \\"id\\": 29 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 21, + \\"id\\": 30 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 21, + \\"id\\": 31 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Image with blob:url\\", + \\"rootId\\": 21, + \\"id\\": 33 + } + ], + \\"rootId\\": 21, + \\"id\\": 32 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 21, + \\"id\\": 34 + } + ], + \\"rootId\\": 21, + \\"id\\": 24 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 21, + \\"id\\": 35 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 21, + \\"id\\": 37 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"rootId\\": 21, + \\"id\\": 39 + } + ], + \\"rootId\\": 21, + \\"id\\": 38 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n\\\\n\\", + \\"rootId\\": 21, + \\"id\\": 40 + } + ], + \\"rootId\\": 21, + \\"id\\": 36 + } + ], + \\"rootId\\": 21, + \\"id\\": 23 + } + ], + \\"id\\": 21 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 36, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"img\\", + \\"attributes\\": { + \\"src\\": \\"blob:http://localhost:3030/...\\", + \\"rr_dataURL\\": \\"data:image/png;base64,...\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 21, + \\"id\\": 41 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 41, + \\"attributes\\": { + \\"crossorigin\\": \\"anonymous\\" + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 41, + \\"attributes\\": { + \\"crossorigin\\": null + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + } +]" +`; + +exports[`record integration tests should record images with blob url 1`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"http-equiv\\": \\"X-UA-Compatible\\", + \\"content\\": \\"IE=edge\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 11 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Image with blob:url\\", + \\"id\\": 13 + } + ], + \\"id\\": 12 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 14 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 15 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 17 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 19 + } + ], + \\"id\\": 18 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\", + \\"id\\": 20 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 22 + } + ], + \\"id\\": 21 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", + \\"id\\": 23 + } + ], + \\"id\\": 16 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 16, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"img\\", + \\"attributes\\": { + \\"src\\": \\"blob:http://localhost:3030/...\\", + \\"rr_dataURL\\": \\"data:image/png;base64,...\\" + }, + \\"childNodes\\": [], + \\"id\\": 24 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 24, + \\"attributes\\": { + \\"crossorigin\\": \\"anonymous\\" + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 24, + \\"attributes\\": { + \\"crossorigin\\": null + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + } +]" +`; + exports[`record integration tests should record input userTriggered values if userTriggeredOnInput is enabled 1`] = ` "[ { diff --git a/packages/rrweb/test/html/assets/robot.png b/packages/rrweb/test/html/assets/robot.png new file mode 100644 index 0000000000000000000000000000000000000000..cc486cc8b70680b0dfd72828b6530e0a4e9183ad GIT binary patch literal 11004 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 qTg~2LrAnpQZV>`Mi|_sS + + + + + Frame with image + + + + + diff --git a/packages/rrweb/test/html/image-blob-url.html b/packages/rrweb/test/html/image-blob-url.html new file mode 100644 index 0000000000..4dd3f60855 --- /dev/null +++ b/packages/rrweb/test/html/image-blob-url.html @@ -0,0 +1,21 @@ + + + + + + + Image with blob:url + + + + + diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 396743a4ae..9a32f12e52 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -495,6 +495,36 @@ 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 shadow DOM', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index 89bc28453b..487f1aab48 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -153,20 +153,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).timestamp; @@ -556,6 +590,7 @@ export function generateRecordSnippet(options: recordOptions) { userTriggeredOnInput: ${options.userTriggeredOnInput}, maskTextFn: ${options.maskTextFn}, recordCanvas: ${options.recordCanvas}, + inlineImages: ${options.inlineImages}, plugins: ${options.plugins} }); `; From da245bdada651fe6b7f01815384fe7aa0e810464 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Thu, 30 Jun 2022 17:25:38 +0200 Subject: [PATCH 3/6] Record iframe mutations cross page --- packages/rrweb-snapshot/src/snapshot.ts | 3 +- packages/rrweb/src/record/index.ts | 2 + packages/rrweb/src/record/mutation.ts | 17 +- packages/rrweb/src/types.ts | 2 + .../__snapshots__/integration.test.ts.snap | 309 ++++++++++++++++++ packages/rrweb/test/integration.test.ts | 29 ++ packages/rrweb/typings/record/mutation.d.ts | 1 + packages/rrweb/typings/types.d.ts | 3 +- 8 files changed, 363 insertions(+), 3 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index d2682dd820..b415208272 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -372,7 +372,8 @@ function onceIframeLoaded( // 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 setTimeout(listener, 0); - return; + + return iframeEl.addEventListener('load', listener); // keep listing for future loads } // use default listener iframeEl.addEventListener('load', listener); diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index fffb4ce8b8..955e20b101 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -249,6 +249,7 @@ function record( iframeManager, stylesheetManager, canvasManager, + keepIframeSrcFn, }, mirror, }); @@ -444,6 +445,7 @@ function record( doc, maskInputFn, maskTextFn, + keepIframeSrcFn, blockSelector, slimDOMOptions, mirror, diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index a7b4dbe0fc..06247692f8 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -164,6 +164,7 @@ export default class MutationBuffer { private maskInputOptions: observerParam['maskInputOptions']; private maskTextFn: observerParam['maskTextFn']; private maskInputFn: observerParam['maskInputFn']; + private keepIframeSrcFn: observerParam['keepIframeSrcFn']; private recordCanvas: observerParam['recordCanvas']; private inlineImages: observerParam['inlineImages']; private slimDOMOptions: observerParam['slimDOMOptions']; @@ -185,6 +186,7 @@ export default class MutationBuffer { 'maskInputOptions', 'maskTextFn', 'maskInputFn', + 'keepIframeSrcFn', 'recordCanvas', 'inlineImages', 'slimDOMOptions', @@ -484,6 +486,19 @@ export default class MutationBuffer { let item: attributeCursor | undefined = this.attributes.find( (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) { item = { node: m.target, @@ -527,7 +542,7 @@ export default class MutationBuffer { // overwrite attribute if the mutations was triggered in same time item.attributes[m.attributeName!] = transformAttribute( this.doc, - (m.target as HTMLElement).tagName, + target.tagName, m.attributeName!, value!, ); diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 7a8e6a8d99..6440eab005 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -267,6 +267,7 @@ export type observerParam = { maskInputOptions: MaskInputOptions; maskInputFn?: MaskInputFn; maskTextFn?: MaskTextFn; + keepIframeSrcFn: KeepIframeSrcFn; inlineStylesheet: boolean; styleSheetRuleCb: styleSheetRuleCallback; styleDeclarationCb: styleDeclarationCallback; @@ -302,6 +303,7 @@ export type MutationBufferParam = Pick< | 'maskInputOptions' | 'maskTextFn' | 'maskInputFn' + | 'keepIframeSrcFn' | 'recordCanvas' | 'inlineImages' | 'slimDOMOptions' diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index b48eac5ade..747ba7790d 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -10613,6 +10613,315 @@ exports[`record integration tests should record input userTriggered values if us ]" `; +exports[`record integration tests should record mutations in iframes accross pages 1`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Frame 2\\", + \\"id\\": 11 + } + ], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 12 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 13 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n frame 2\\\\n \\\\n \\", + \\"id\\": 15 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 17 + } + ], + \\"id\\": 16 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 18 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 20 + } + ], + \\"id\\": 19 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n\\\\n\\", + \\"id\\": 21 + } + ], + \\"id\\": 14 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 14, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"id\\": \\"five\\" + }, + \\"childNodes\\": [], + \\"id\\": 22 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 22, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 23, + \\"id\\": 25 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 23, + \\"id\\": 26 + } + ], + \\"rootId\\": 23, + \\"id\\": 24 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 23 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 22, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 27, + \\"id\\": 29 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 27, + \\"id\\": 30 + } + ], + \\"rootId\\": 27, + \\"id\\": 28 + } + ], + \\"id\\": 27 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 30, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 27, + \\"id\\": 31 + } + } + ] + } + } +]" +`; + exports[`record integration tests should record nested iframes and shadow doms 1`] = ` "[ { diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 9a32f12e52..83618f6a72 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -613,6 +613,35 @@ 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); + }); + it('should mask texts', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); diff --git a/packages/rrweb/typings/record/mutation.d.ts b/packages/rrweb/typings/record/mutation.d.ts index e554eabe1e..c2108a959c 100644 --- a/packages/rrweb/typings/record/mutation.d.ts +++ b/packages/rrweb/typings/record/mutation.d.ts @@ -19,6 +19,7 @@ export default class MutationBuffer { private maskInputOptions; private maskTextFn; private maskInputFn; + private keepIframeSrcFn; private recordCanvas; private inlineImages; private slimDOMOptions; diff --git a/packages/rrweb/typings/types.d.ts b/packages/rrweb/typings/types.d.ts index c40a9f2a29..f3ed7615b5 100644 --- a/packages/rrweb/typings/types.d.ts +++ b/packages/rrweb/typings/types.d.ts @@ -180,6 +180,7 @@ export declare type observerParam = { maskInputOptions: MaskInputOptions; maskInputFn?: MaskInputFn; maskTextFn?: MaskTextFn; + keepIframeSrcFn: KeepIframeSrcFn; inlineStylesheet: boolean; styleSheetRuleCb: styleSheetRuleCallback; styleDeclarationCb: styleDeclarationCallback; @@ -203,7 +204,7 @@ export declare type observerParam = { options: unknown; }>; }; -export declare type MutationBufferParam = Pick; +export declare type MutationBufferParam = Pick; export declare type hooksParam = { mutation?: mutationCallBack; mousemove?: mousemoveCallBack; From be618d3ff1c2e91f690e3c870ac0be0e0c546b8c Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Thu, 30 Jun 2022 17:51:06 +0200 Subject: [PATCH 4/6] Test: should record images inside iframe with blob url after iframe was reloaded --- .../__snapshots__/integration.test.ts.snap | 468 ++++++++++++++++++ packages/rrweb/test/integration.test.ts | 21 + 2 files changed, 489 insertions(+) diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index 747ba7790d..657094725f 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -9489,6 +9489,474 @@ exports[`record integration tests should record images inside iframe with blob u ]" `; +exports[`record integration tests should record images inside iframe with blob url after iframe was reloaded 1`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Frame 2\\", + \\"id\\": 11 + } + ], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 12 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 13 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n frame 2\\\\n \\\\n \\", + \\"id\\": 15 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 17 + } + ], + \\"id\\": 16 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 18 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 20 + } + ], + \\"id\\": 19 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n\\\\n\\", + \\"id\\": 21 + } + ], + \\"id\\": 14 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 14, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"id\\": \\"five\\" + }, + \\"childNodes\\": [], + \\"id\\": 22 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 22, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 23, + \\"id\\": 25 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 23, + \\"id\\": 26 + } + ], + \\"rootId\\": 23, + \\"id\\": 24 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 23 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 22, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"rootId\\": 27, + \\"id\\": 28 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 27, + \\"id\\": 31 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 27, + \\"id\\": 32 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 27, + \\"id\\": 33 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"http-equiv\\": \\"X-UA-Compatible\\", + \\"content\\": \\"IE=edge\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 27, + \\"id\\": 34 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 27, + \\"id\\": 35 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 27, + \\"id\\": 36 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 27, + \\"id\\": 37 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Image with blob:url\\", + \\"rootId\\": 27, + \\"id\\": 39 + } + ], + \\"rootId\\": 27, + \\"id\\": 38 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 27, + \\"id\\": 40 + } + ], + \\"rootId\\": 27, + \\"id\\": 30 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 27, + \\"id\\": 41 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"rootId\\": 27, + \\"id\\": 43 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"rootId\\": 27, + \\"id\\": 45 + } + ], + \\"rootId\\": 27, + \\"id\\": 44 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n\\\\n\\", + \\"rootId\\": 27, + \\"id\\": 46 + } + ], + \\"rootId\\": 27, + \\"id\\": 42 + } + ], + \\"rootId\\": 27, + \\"id\\": 29 + } + ], + \\"id\\": 27 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 42, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"img\\", + \\"attributes\\": { + \\"src\\": \\"blob:http://localhost:3030/...\\", + \\"rr_dataURL\\": \\"data:image/png;base64,...\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 27, + \\"id\\": 47 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 47, + \\"attributes\\": { + \\"crossorigin\\": \\"anonymous\\" + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 47, + \\"attributes\\": { + \\"crossorigin\\": null + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + } +]" +`; + exports[`record integration tests should record images with blob url 1`] = ` "[ { diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 83618f6a72..6d519998b2 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -525,6 +525,27 @@ describe('record integration tests', function (this: ISuite) { 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'); From a31e272bf2252c9fe7fefe50972ffcb8cf770a32 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Fri, 1 Jul 2022 06:26:24 +0200 Subject: [PATCH 5/6] Remove children of Document even if doc not in mirror (#923) * Remove children of Document even if doc not in mirror * fix flaky test * Update packages/rrdom/test/diff.test.ts Co-authored-by: Yun Feng Co-authored-by: Yun Feng --- packages/rrdom/src/diff.ts | 2 +- packages/rrdom/test/diff.test.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/rrdom/src/diff.ts b/packages/rrdom/src/diff.ts index 73850d6453..c3c9acd544 100644 --- a/packages/rrdom/src/diff.ts +++ b/packages/rrdom/src/diff.ts @@ -316,7 +316,7 @@ function diffChildren( * We should delete it before insert a serialized one. Otherwise, an error 'Only one element on document allowed' will be thrown. */ if ( - replayer.mirror.getMeta(parentNode)?.type === RRNodeType.Document && + parentNode.nodeName === '#document' && replayer.mirror.getMeta(newNode)?.type === RRNodeType.Element && (parentNode as Document).documentElement ) { diff --git a/packages/rrdom/test/diff.test.ts b/packages/rrdom/test/diff.test.ts index bb4544e888..efb5aaaa8f 100644 --- a/packages/rrdom/test/diff.test.ts +++ b/packages/rrdom/test/diff.test.ts @@ -1056,6 +1056,24 @@ describe('diff algorithm for rrdom', () => { expect(element.tagName).toBe('DIV'); expect(mirror.getId(element)).toEqual(2); }); + + it('should remove children from document before adding new nodes', () => { + document.write(''); // old document with elements that need removing + + const rrDocument = new RRDocument(); + const docType = rrDocument.createDocumentType('html', '', ''); + rrDocument.mirror.add(docType, getDefaultSN(docType, 1)); + rrDocument.appendChild(docType); + const htmlEl = rrDocument.createElement('html'); + rrDocument.mirror.add(htmlEl, getDefaultSN(htmlEl, 2)); + rrDocument.appendChild(htmlEl); + + diff(document, rrDocument, replayer); + expect(document.childNodes.length).toBe(2); + const element = document.childNodes[0] as HTMLElement; + expect(element.nodeType).toBe(element.DOCUMENT_TYPE_NODE); + expect(mirror.getId(element)).toEqual(1); + }); }); describe('create or get a Node', () => { From d5d877e3808870daefb0dc5ad2ae3e1073098564 Mon Sep 17 00:00:00 2001 From: Justin Halsall Date: Fri, 1 Jul 2022 07:29:09 +0200 Subject: [PATCH 6/6] Inline stylesheets on load (#909) * 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 --- packages/rrdom-nodejs/test/polyfill.test.ts | 6 +- packages/rrweb-snapshot/src/snapshot.ts | 114 +++- packages/rrweb-snapshot/src/types.ts | 5 + packages/rrweb-snapshot/typings/snapshot.d.ts | 12 +- packages/rrweb-snapshot/typings/types.d.ts | 1 + packages/rrweb/src/record/index.ts | 14 + packages/rrweb/src/record/mutation.ts | 12 +- .../rrweb/src/record/stylesheet-manager.ts | 45 ++ packages/rrweb/src/types.ts | 3 + packages/rrweb/src/utils.ts | 13 + .../test/__snapshots__/record.test.ts.snap | 595 ++++++++++++++++++ packages/rrweb/test/record.test.ts | 172 ++++- packages/rrweb/typings/record/mutation.d.ts | 1 + .../typings/record/stylesheet-manager.d.ts | 12 + packages/rrweb/typings/types.d.ts | 4 +- packages/rrweb/typings/utils.d.ts | 1 + 16 files changed, 985 insertions(+), 25 deletions(-) create mode 100644 packages/rrweb/src/record/stylesheet-manager.ts create mode 100644 packages/rrweb/typings/record/stylesheet-manager.d.ts diff --git a/packages/rrdom-nodejs/test/polyfill.test.ts b/packages/rrdom-nodejs/test/polyfill.test.ts index a9d5f381f1..240976ad83 100644 --- a/packages/rrdom-nodejs/test/polyfill.test.ts +++ b/packages/rrdom-nodejs/test/polyfill.test.ts @@ -7,6 +7,7 @@ import { polyfillNode, polyfillDocument, } from '../src/polyfill'; +import { performance as nativePerformance } from 'perf_hooks'; describe('polyfill for nodejs', () => { it('should polyfill performance api', () => { @@ -16,10 +17,7 @@ describe('polyfill for nodejs', () => { expect(global.performance).toBeDefined(); expect(performance).toBeDefined(); expect(performance.now).toBeDefined(); - expect(performance.now()).toBeCloseTo( - require('perf_hooks').performance.now(), - 1e-10, - ); + expect(performance.now()).toBeCloseTo(nativePerformance.now(), 1e-10); }); it('should not polyfill performance if it already exists', () => { diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 1694d3f222..d2682dd820 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -10,6 +10,7 @@ import { MaskInputFn, KeepIframeSrcFn, ICanvas, + serializedElementNodeWithId, } from './types'; import { Mirror, @@ -377,6 +378,40 @@ function onceIframeLoaded( iframeEl.addEventListener('load', listener); } +function isStylesheetLoaded(link: HTMLLinkElement) { + if (!link.getAttribute('href')) return true; // nothing to load + return link.sheet !== null; +} + +function onceStylesheetLoaded( + link: HTMLLinkElement, + listener: () => unknown, + styleSheetLoadTimeout: number, +) { + let fired = false; + let styleSheetLoaded: StyleSheet | null; + try { + styleSheetLoaded = link.sheet; + } catch (error) { + return; + } + + if (styleSheetLoaded) return; + + const timer = setTimeout(() => { + if (!fired) { + listener(); + fired = true; + } + }, styleSheetLoadTimeout); + + link.addEventListener('load', () => { + clearTimeout(timer); + fired = true; + listener(); + }); +} + function serializeNode( n: Node, options: { @@ -876,6 +911,7 @@ export function serializeNodeWithId( maskTextSelector: string | null; skipChild: boolean; inlineStylesheet: boolean; + newlyAddedElement?: boolean; maskInputOptions?: MaskInputOptions; maskTextFn: MaskTextFn | undefined; maskInputFn: MaskInputFn | undefined; @@ -888,10 +924,14 @@ export function serializeNodeWithId( onSerialize?: (n: Node) => unknown; onIframeLoad?: ( iframeNode: HTMLIFrameElement, - node: serializedNodeWithId, + node: serializedElementNodeWithId, ) => unknown; iframeLoadTimeout?: number; - newlyAddedElement?: boolean; + onStylesheetLoad?: ( + linkNode: HTMLLinkElement, + node: serializedElementNodeWithId, + ) => unknown; + stylesheetLoadTimeout?: number; }, ): serializedNodeWithId | null { const { @@ -913,6 +953,8 @@ export function serializeNodeWithId( onSerialize, onIframeLoad, iframeLoadTimeout = 5000, + onStylesheetLoad, + stylesheetLoadTimeout = 5000, keepIframeSrcFn = () => false, newlyAddedElement = false, } = options; @@ -1006,6 +1048,8 @@ export function serializeNodeWithId( onSerialize, onIframeLoad, iframeLoadTimeout, + onStylesheetLoad, + stylesheetLoadTimeout, keepIframeSrcFn, }; for (const childN of Array.from(n.childNodes)) { @@ -1059,11 +1103,16 @@ export function serializeNodeWithId( onSerialize, onIframeLoad, iframeLoadTimeout, + onStylesheetLoad, + stylesheetLoadTimeout, keepIframeSrcFn, }); if (serializedIframeNode) { - onIframeLoad(n as HTMLIFrameElement, serializedIframeNode); + onIframeLoad( + n as HTMLIFrameElement, + serializedIframeNode as serializedElementNodeWithId, + ); } } }, @@ -1071,6 +1120,54 @@ export function serializeNodeWithId( ); } + // + if ( + serializedNode.type === NodeType.Element && + serializedNode.tagName === 'link' && + serializedNode.attributes.rel === 'stylesheet' + ) { + onceStylesheetLoaded( + n as HTMLLinkElement, + () => { + if (onStylesheetLoad) { + const serializedLinkNode = serializeNodeWithId(n, { + doc, + mirror, + blockClass, + blockSelector, + maskTextClass, + maskTextSelector, + skipChild: false, + inlineStylesheet, + maskInputOptions, + maskTextFn, + maskInputFn, + slimDOMOptions, + dataURLOptions, + inlineImages, + recordCanvas, + preserveWhiteSpace, + onSerialize, + onIframeLoad, + iframeLoadTimeout, + onStylesheetLoad, + stylesheetLoadTimeout, + keepIframeSrcFn, + }); + + if (serializedLinkNode) { + onStylesheetLoad( + n as HTMLLinkElement, + serializedLinkNode as serializedElementNodeWithId, + ); + } + } + }, + stylesheetLoadTimeout, + ); + if (isStylesheetLoaded(n as HTMLLinkElement) === false) return null; // add stylesheet in later mutation + } + return serializedNode; } @@ -1094,9 +1191,14 @@ function snapshot( onSerialize?: (n: Node) => unknown; onIframeLoad?: ( iframeNode: HTMLIFrameElement, - node: serializedNodeWithId, + node: serializedElementNodeWithId, ) => unknown; iframeLoadTimeout?: number; + onStylesheetLoad?: ( + linkNode: HTMLLinkElement, + node: serializedElementNodeWithId, + ) => unknown; + stylesheetLoadTimeout?: number; keepIframeSrcFn?: KeepIframeSrcFn; }, ): serializedNodeWithId | null { @@ -1118,6 +1220,8 @@ function snapshot( onSerialize, onIframeLoad, iframeLoadTimeout, + onStylesheetLoad, + stylesheetLoadTimeout, keepIframeSrcFn = () => false, } = options || {}; const maskInputOptions: MaskInputOptions = @@ -1183,6 +1287,8 @@ function snapshot( onSerialize, onIframeLoad, iframeLoadTimeout, + onStylesheetLoad, + stylesheetLoadTimeout, keepIframeSrcFn, newlyAddedElement: false, }); diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index 6a09c633b4..488be6eaa1 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -63,6 +63,11 @@ export type serializedNode = ( export type serializedNodeWithId = serializedNode & { id: number }; +export type serializedElementNodeWithId = Extract< + serializedNodeWithId, + Record<'type', NodeType.Element> +>; + export type tagMap = { [key: string]: string; }; diff --git a/packages/rrweb-snapshot/typings/snapshot.d.ts b/packages/rrweb-snapshot/typings/snapshot.d.ts index 72a7c4ca20..da722cd397 100644 --- a/packages/rrweb-snapshot/typings/snapshot.d.ts +++ b/packages/rrweb-snapshot/typings/snapshot.d.ts @@ -1,4 +1,4 @@ -import { serializedNodeWithId, MaskInputOptions, SlimDOMOptions, DataURLOptions, MaskTextFn, MaskInputFn, KeepIframeSrcFn } from './types'; +import { serializedNodeWithId, MaskInputOptions, SlimDOMOptions, DataURLOptions, MaskTextFn, MaskInputFn, KeepIframeSrcFn, serializedElementNodeWithId } from './types'; import { Mirror } from './utils'; export declare const IGNORED_NODE = -2; export declare function absoluteToStylesheet(cssText: string | null, href: string): string; @@ -16,6 +16,7 @@ export declare function serializeNodeWithId(n: Node, options: { maskTextSelector: string | null; skipChild: boolean; inlineStylesheet: boolean; + newlyAddedElement?: boolean; maskInputOptions?: MaskInputOptions; maskTextFn: MaskTextFn | undefined; maskInputFn: MaskInputFn | undefined; @@ -26,9 +27,10 @@ export declare function serializeNodeWithId(n: Node, options: { recordCanvas?: boolean; preserveWhiteSpace?: boolean; onSerialize?: (n: Node) => unknown; - onIframeLoad?: (iframeNode: HTMLIFrameElement, node: serializedNodeWithId) => unknown; + onIframeLoad?: (iframeNode: HTMLIFrameElement, node: serializedElementNodeWithId) => unknown; iframeLoadTimeout?: number; - newlyAddedElement?: boolean; + onStylesheetLoad?: (linkNode: HTMLLinkElement, node: serializedElementNodeWithId) => unknown; + stylesheetLoadTimeout?: number; }): serializedNodeWithId | null; declare function snapshot(n: Document, options?: { mirror?: Mirror; @@ -46,8 +48,10 @@ declare function snapshot(n: Document, options?: { recordCanvas?: boolean; preserveWhiteSpace?: boolean; onSerialize?: (n: Node) => unknown; - onIframeLoad?: (iframeNode: HTMLIFrameElement, node: serializedNodeWithId) => unknown; + onIframeLoad?: (iframeNode: HTMLIFrameElement, node: serializedElementNodeWithId) => unknown; iframeLoadTimeout?: number; + onStylesheetLoad?: (linkNode: HTMLLinkElement, node: serializedElementNodeWithId) => unknown; + stylesheetLoadTimeout?: number; keepIframeSrcFn?: KeepIframeSrcFn; }): serializedNodeWithId | null; export declare function visitSnapshot(node: serializedNodeWithId, onVisit: (node: serializedNodeWithId) => unknown): void; diff --git a/packages/rrweb-snapshot/typings/types.d.ts b/packages/rrweb-snapshot/typings/types.d.ts index bac3fcfb1f..5282993e41 100644 --- a/packages/rrweb-snapshot/typings/types.d.ts +++ b/packages/rrweb-snapshot/typings/types.d.ts @@ -49,6 +49,7 @@ export declare type serializedNode = (documentNode | documentTypeNode | elementN export declare type serializedNodeWithId = serializedNode & { id: number; }; +export declare type serializedElementNodeWithId = Extract>; export declare type tagMap = { [key: string]: string; }; diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 3240e2f42a..fffb4ce8b8 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -12,6 +12,7 @@ import { polyfill, hasShadowRoot, isSerializedIframe, + isSerializedStylesheet, } from '../utils'; import { EventType, @@ -27,6 +28,7 @@ import { import { IframeManager } from './iframe-manager'; import { ShadowDomManager } from './shadow-dom-manager'; import { CanvasManager } from './observers/canvas/canvas-manager'; +import { StylesheetManager } from './stylesheet-manager'; function wrapEvent(e: event): eventWithTime { return { @@ -215,6 +217,10 @@ function record( mutationCb: wrappedMutationEmit, }); + const stylesheetManager = new StylesheetManager({ + mutationCb: wrappedMutationEmit, + }); + const canvasManager = new CanvasManager({ recordCanvas, mutationCb: wrappedCanvasMutationEmit, @@ -241,6 +247,7 @@ function record( sampling, slimDOMOptions, iframeManager, + stylesheetManager, canvasManager, }, mirror, @@ -276,6 +283,9 @@ function record( if (isSerializedIframe(n, mirror)) { iframeManager.addIframe(n as HTMLIFrameElement); } + if (isSerializedStylesheet(n, mirror)) { + stylesheetManager.addStylesheet(n as HTMLLinkElement); + } if (hasShadowRoot(n)) { shadowDomManager.addShadowRoot(n.shadowRoot, document); } @@ -284,6 +294,9 @@ function record( iframeManager.attachIframe(iframe, childSn, mirror); shadowDomManager.observeAttachShadow(iframe); }, + onStylesheetLoad: (linkEl, childSn) => { + stylesheetManager.attachStylesheet(linkEl, childSn, mirror); + }, keepIframeSrcFn, }); @@ -435,6 +448,7 @@ function record( slimDOMOptions, mirror, iframeManager, + stylesheetManager, shadowDomManager, canvasManager, plugins: diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index aef3aa4cb4..a7b4dbe0fc 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -25,6 +25,7 @@ import { isSerialized, hasShadowRoot, isSerializedIframe, + isSerializedStylesheet, } from '../utils'; type DoubleLinkedListNode = { @@ -169,6 +170,7 @@ export default class MutationBuffer { private doc: observerParam['doc']; private mirror: observerParam['mirror']; private iframeManager: observerParam['iframeManager']; + private stylesheetManager: observerParam['stylesheetManager']; private shadowDomManager: observerParam['shadowDomManager']; private canvasManager: observerParam['canvasManager']; @@ -189,6 +191,7 @@ export default class MutationBuffer { 'doc', 'mirror', 'iframeManager', + 'stylesheetManager', 'shadowDomManager', 'canvasManager', ] as const).forEach((key) => { @@ -289,6 +292,7 @@ export default class MutationBuffer { maskTextClass: this.maskTextClass, maskTextSelector: this.maskTextSelector, skipChild: true, + newlyAddedElement: true, inlineStylesheet: this.inlineStylesheet, maskInputOptions: this.maskInputOptions, maskTextFn: this.maskTextFn, @@ -300,6 +304,9 @@ export default class MutationBuffer { if (isSerializedIframe(currentN, this.mirror)) { this.iframeManager.addIframe(currentN as HTMLIFrameElement); } + if (isSerializedStylesheet(currentN, this.mirror)) { + this.stylesheetManager.addStylesheet(currentN as HTMLLinkElement); + } if (hasShadowRoot(n)) { this.shadowDomManager.addShadowRoot(n.shadowRoot, document); } @@ -308,7 +315,9 @@ export default class MutationBuffer { this.iframeManager.attachIframe(iframe, childSn, this.mirror); this.shadowDomManager.observeAttachShadow(iframe); }, - newlyAddedElement: true, + onStylesheetLoad: (link, childSn) => { + this.stylesheetManager.attachStylesheet(link, childSn, this.mirror); + }, }); if (sn) { adds.push({ @@ -471,6 +480,7 @@ export default class MutationBuffer { ) { return; } + let item: attributeCursor | undefined = this.attributes.find( (a) => a.node === m.target, ); diff --git a/packages/rrweb/src/record/stylesheet-manager.ts b/packages/rrweb/src/record/stylesheet-manager.ts new file mode 100644 index 0000000000..01a69744d7 --- /dev/null +++ b/packages/rrweb/src/record/stylesheet-manager.ts @@ -0,0 +1,45 @@ +import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot'; +import type { mutationCallBack } from '../types'; + +export class StylesheetManager { + private trackedStylesheets: WeakSet = new WeakSet(); + private mutationCb: mutationCallBack; + + constructor(options: { mutationCb: mutationCallBack }) { + this.mutationCb = options.mutationCb; + } + + public addStylesheet(linkEl: HTMLLinkElement) { + if (this.trackedStylesheets.has(linkEl)) return; + + this.trackedStylesheets.add(linkEl); + this.trackStylesheet(linkEl); + } + + // TODO: take snapshot on stylesheet reload by applying event listener + private trackStylesheet(linkEl: HTMLLinkElement) { + // linkEl.addEventListener('load', () => { + // // re-loaded, maybe take another snapshot? + // }); + } + + public attachStylesheet( + linkEl: HTMLLinkElement, + childSn: serializedNodeWithId, + mirror: Mirror, + ) { + this.mutationCb({ + adds: [ + { + parentId: mirror.getId(linkEl), + nextId: null, + node: childSn, + }, + ], + removes: [], + texts: [], + attributes: [], + }); + this.addStylesheet(linkEl); + } +} diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 63a10943dd..7a8e6a8d99 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -13,6 +13,7 @@ import type { ShadowDomManager } from './record/shadow-dom-manager'; import type { Replayer } from './replay'; import type { RRNode } from 'rrdom'; import type { CanvasManager } from './record/observers/canvas/canvas-manager'; +import type { StylesheetManager } from './record/stylesheet-manager'; export enum EventType { DomContentLoaded, @@ -280,6 +281,7 @@ export type observerParam = { doc: Document; mirror: Mirror; iframeManager: IframeManager; + stylesheetManager: StylesheetManager; shadowDomManager: ShadowDomManager; canvasManager: CanvasManager; plugins: Array<{ @@ -306,6 +308,7 @@ export type MutationBufferParam = Pick< | 'doc' | 'mirror' | 'iframeManager' + | 'stylesheetManager' | 'shadowDomManager' | 'canvasManager' >; diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index 81108b58b5..d3c16a8919 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -354,6 +354,19 @@ export function isSerializedIframe( return Boolean(n.nodeName === 'IFRAME' && mirror.getMeta(n)); } +export function isSerializedStylesheet( + n: TNode, + mirror: IMirror, +): boolean { + return Boolean( + n.nodeName === 'LINK' && + n.nodeType === n.ELEMENT_NODE && + (n as HTMLElement).getAttribute && + (n as HTMLElement).getAttribute('rel') === 'stylesheet' && + mirror.getMeta(n), + ); +} + export function getBaseDimension( node: Node, rootIframe: Node, diff --git a/packages/rrweb/test/__snapshots__/record.test.ts.snap b/packages/rrweb/test/__snapshots__/record.test.ts.snap index e65b2372c8..2c2b7c8d0d 100644 --- a/packages/rrweb/test/__snapshots__/record.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/record.test.ts.snap @@ -94,6 +94,109 @@ exports[`record can add custom event 1`] = ` ]" `; +exports[`record captures CORS stylesheets that are still loading 1`] = ` +"[ + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 4 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 6 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"text\\", + \\"size\\": \\"40\\" + }, + \\"childNodes\\": [], + \\"id\\": 7 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 8 + } + ], + \\"id\\": 5 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 9, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"link\\", + \\"attributes\\": { + \\"rel\\": \\"stylesheet\\", + \\"href\\": \\"https://cdn.jsdelivr.net/npm/pure@2.85.0/index.css\\" + }, + \\"childNodes\\": [], + \\"id\\": 9 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [] + } + } +]" +`; + exports[`record captures inserted style text nodes correctly 1`] = ` "[ { @@ -640,6 +743,498 @@ exports[`record captures stylesheet rules 1`] = ` ]" `; +exports[`record captures stylesheets in iframes that are still loading 1`] = ` +"[ + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 4 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 6 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"text\\", + \\"size\\": \\"40\\" + }, + \\"childNodes\\": [], + \\"id\\": 7 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 8 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 9 + } + ], + \\"id\\": 5 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 9, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 10, + \\"id\\": 12 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 10, + \\"id\\": 14 + } + ], + \\"rootId\\": 10, + \\"id\\": 11 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 10 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 13, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"link\\", + \\"attributes\\": { + \\"_cssText\\": \\"body { color: pink; }\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 10, + \\"id\\": 13 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [] + } + } +]" +`; + +exports[`record captures stylesheets in iframes with \`blob:\` url 1`] = ` +"[ + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 4 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 6 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"text\\", + \\"size\\": \\"40\\" + }, + \\"childNodes\\": [], + \\"id\\": 7 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 8 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 9 + } + ], + \\"id\\": 5 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 9, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"link\\", + \\"attributes\\": { + \\"_cssText\\": \\"body { color: pink; }\\" + }, + \\"childNodes\\": [], + \\"rootId\\": 10, + \\"id\\": 13 + } + ], + \\"rootId\\": 10, + \\"id\\": 12 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 10, + \\"id\\": 14 + } + ], + \\"rootId\\": 10, + \\"id\\": 11 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 10 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + } +]" +`; + +exports[`record captures stylesheets that are still loading 1`] = ` +"[ + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 4 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 6 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"text\\", + \\"size\\": \\"40\\" + }, + \\"childNodes\\": [], + \\"id\\": 7 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 8 + } + ], + \\"id\\": 5 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 9, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"link\\", + \\"attributes\\": { + \\"_cssText\\": \\"body { color: pink; }\\" + }, + \\"childNodes\\": [], + \\"id\\": 9 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [] + } + } +]" +`; + +exports[`record captures stylesheets with \`blob:\` url 1`] = ` +"[ + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"link\\", + \\"attributes\\": { + \\"_cssText\\": \\"body { color: pink; }\\" + }, + \\"childNodes\\": [], + \\"id\\": 5 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"text\\", + \\"size\\": \\"40\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 9 + } + ], + \\"id\\": 6 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + } +]" +`; + exports[`record iframes captures stylesheet mutations in iframes 1`] = ` "[ { diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 969f11ff5d..b45a6ce46c 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -34,7 +34,9 @@ const setup = function (this: ISuite, content: string): ISuite { const ctx = {} as ISuite; beforeAll(async () => { - ctx.browser = await launchPuppeteer(); + ctx.browser = await launchPuppeteer({ + devtools: true, + }); const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js'); ctx.code = fs.readFileSync(bundlePath, 'utf8'); @@ -143,16 +145,20 @@ describe('record', function (this: ISuite) { checkoutEveryNms: 500, }); }); - let count = 30; - while (count--) { - await ctx.page.type('input', 'a'); - } + await ctx.page.type('input', 'a'); await ctx.page.waitForTimeout(300); - expect(ctx.events.length).toEqual(33); // before first automatic snapshot - await ctx.page.waitForTimeout(200); // could be 33 or 35 events by now depending on speed of test env + expect( + ctx.events.filter((event: eventWithTime) => event.type === EventType.Meta) + .length, + ).toEqual(1); // before first automatic snapshot + expect( + ctx.events.filter( + (event: eventWithTime) => event.type === EventType.FullSnapshot, + ).length, + ).toEqual(1); // before first automatic snapshot + await ctx.page.waitForTimeout(200); await ctx.page.type('input', 'a'); await ctx.page.waitForTimeout(10); - expect(ctx.events.length).toEqual(36); // additionally includes the 2 checkout events expect( ctx.events.filter((event: eventWithTime) => event.type === EventType.Meta) .length, @@ -162,8 +168,6 @@ describe('record', function (this: ISuite) { (event: eventWithTime) => event.type === EventType.FullSnapshot, ).length, ).toEqual(2); - expect(ctx.events[1].type).toEqual(EventType.FullSnapshot); - expect(ctx.events[35].type).toEqual(EventType.FullSnapshot); }); it('is safe to checkout during async callbacks', async () => { @@ -381,6 +385,151 @@ describe('record', function (this: ISuite) { await waitForRAF(ctx.page); assertSnapshot(ctx.events); }); + + it('captures stylesheets with `blob:` url', async () => { + await ctx.page.evaluate(() => { + const link1 = document.createElement('link'); + link1.setAttribute('rel', 'stylesheet'); + link1.setAttribute( + 'href', + URL.createObjectURL( + new Blob(['body { color: pink; }'], { + type: 'text/css', + }), + ), + ); + document.head.appendChild(link1); + }); + await waitForRAF(ctx.page); + await ctx.page.evaluate(() => { + const { record } = ((window as unknown) as IWindow).rrweb; + + record({ + inlineStylesheet: true, + emit: ((window as unknown) as IWindow).emit, + }); + }); + await waitForRAF(ctx.page); + assertSnapshot(ctx.events); + }); + + it('captures stylesheets in iframes with `blob:` url', async () => { + await ctx.page.evaluate(() => { + const iframe = document.createElement('iframe'); + iframe.setAttribute('src', 'about:blank'); + document.body.appendChild(iframe); + + const linkEl = document.createElement('link'); + linkEl.setAttribute('rel', 'stylesheet'); + linkEl.setAttribute( + 'href', + URL.createObjectURL( + new Blob(['body { color: pink; }'], { + type: 'text/css', + }), + ), + ); + const iframeDoc = iframe.contentDocument!; + iframeDoc.head.appendChild(linkEl); + }); + await waitForRAF(ctx.page); + await ctx.page.evaluate(() => { + const { record } = ((window as unknown) as IWindow).rrweb; + + record({ + inlineStylesheet: true, + emit: ((window as unknown) as IWindow).emit, + }); + }); + await waitForRAF(ctx.page); + assertSnapshot(ctx.events); + }); + + it('captures stylesheets that are still loading', async () => { + await ctx.page.evaluate(() => { + const { record } = ((window as unknown) as IWindow).rrweb; + + record({ + inlineStylesheet: true, + emit: ((window as unknown) as IWindow).emit, + }); + + const link1 = document.createElement('link'); + link1.setAttribute('rel', 'stylesheet'); + link1.setAttribute( + 'href', + URL.createObjectURL( + new Blob(['body { color: pink; }'], { + type: 'text/css', + }), + ), + ); + document.head.appendChild(link1); + }); + + // `blob:` URLs are not available immediately, so we need to wait for the browser to load them + await waitForRAF(ctx.page); + + assertSnapshot(ctx.events); + }); + + it('captures stylesheets in iframes that are still loading', async () => { + await ctx.page.evaluate(() => { + const iframe = document.createElement('iframe'); + iframe.setAttribute('src', 'about:blank'); + document.body.appendChild(iframe); + const iframeDoc = iframe.contentDocument!; + + const { record } = ((window as unknown) as IWindow).rrweb; + + record({ + inlineStylesheet: true, + emit: ((window as unknown) as IWindow).emit, + }); + + const linkEl = document.createElement('link'); + linkEl.setAttribute('rel', 'stylesheet'); + linkEl.setAttribute( + 'href', + URL.createObjectURL( + new Blob(['body { color: pink; }'], { + type: 'text/css', + }), + ), + ); + iframeDoc.head.appendChild(linkEl); + }); + + // `blob:` URLs are not available immediately, so we need to wait for the browser to load them + await waitForRAF(ctx.page); + + assertSnapshot(ctx.events); + }); + + it('captures CORS stylesheets that are still loading', async () => { + const corsStylesheetURL = + 'https://cdn.jsdelivr.net/npm/pure@2.85.0/index.css'; + + // do not `await` the following function, otherwise `waitForResponse` _might_ not be called + void ctx.page.evaluate((corsStylesheetURL) => { + const { record } = ((window as unknown) as IWindow).rrweb; + + record({ + inlineStylesheet: true, + emit: ((window as unknown) as IWindow).emit, + }); + + const link1 = document.createElement('link'); + link1.setAttribute('rel', 'stylesheet'); + link1.setAttribute('href', corsStylesheetURL); + document.head.appendChild(link1); + }, corsStylesheetURL); + + await ctx.page.waitForResponse(corsStylesheetURL); // wait for stylesheet to be loaded + await waitForRAF(ctx.page); // wait for rrweb to emit events + + assertSnapshot(ctx.events); + }); }); describe('record iframes', function (this: ISuite) { @@ -463,7 +612,8 @@ describe('record iframes', function (this: ISuite) { }, 10); }, 10); }); - await ctx.page.waitForTimeout(50); + await ctx.page.waitForTimeout(50); // wait till setTimeout is called + await waitForRAF(ctx.page); // wait till events get sent const styleRelatedEvents = ctx.events.filter( (e) => e.type === EventType.IncrementalSnapshot && diff --git a/packages/rrweb/typings/record/mutation.d.ts b/packages/rrweb/typings/record/mutation.d.ts index 930d247848..e554eabe1e 100644 --- a/packages/rrweb/typings/record/mutation.d.ts +++ b/packages/rrweb/typings/record/mutation.d.ts @@ -25,6 +25,7 @@ export default class MutationBuffer { private doc; private mirror; private iframeManager; + private stylesheetManager; private shadowDomManager; private canvasManager; init(options: MutationBufferParam): void; diff --git a/packages/rrweb/typings/record/stylesheet-manager.d.ts b/packages/rrweb/typings/record/stylesheet-manager.d.ts new file mode 100644 index 0000000000..022fc54445 --- /dev/null +++ b/packages/rrweb/typings/record/stylesheet-manager.d.ts @@ -0,0 +1,12 @@ +import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot'; +import type { mutationCallBack } from '../types'; +export declare class StylesheetManager { + private trackedStylesheets; + private mutationCb; + constructor(options: { + mutationCb: mutationCallBack; + }); + addStylesheet(linkEl: HTMLLinkElement): void; + private trackStylesheet; + attachStylesheet(linkEl: HTMLLinkElement, childSn: serializedNodeWithId, mirror: Mirror): void; +} diff --git a/packages/rrweb/typings/types.d.ts b/packages/rrweb/typings/types.d.ts index 50a7109a75..c40a9f2a29 100644 --- a/packages/rrweb/typings/types.d.ts +++ b/packages/rrweb/typings/types.d.ts @@ -5,6 +5,7 @@ import type { ShadowDomManager } from './record/shadow-dom-manager'; import type { Replayer } from './replay'; import type { RRNode } from 'rrdom'; import type { CanvasManager } from './record/observers/canvas/canvas-manager'; +import type { StylesheetManager } from './record/stylesheet-manager'; export declare enum EventType { DomContentLoaded = 0, Load = 1, @@ -193,6 +194,7 @@ export declare type observerParam = { doc: Document; mirror: Mirror; iframeManager: IframeManager; + stylesheetManager: StylesheetManager; shadowDomManager: ShadowDomManager; canvasManager: CanvasManager; plugins: Array<{ @@ -201,7 +203,7 @@ export declare type observerParam = { options: unknown; }>; }; -export declare type MutationBufferParam = Pick; +export declare type MutationBufferParam = Pick; export declare type hooksParam = { mutation?: mutationCallBack; mousemove?: mousemoveCallBack; diff --git a/packages/rrweb/typings/utils.d.ts b/packages/rrweb/typings/utils.d.ts index 0fabf6f199..ed87d5fee1 100644 --- a/packages/rrweb/typings/utils.d.ts +++ b/packages/rrweb/typings/utils.d.ts @@ -28,6 +28,7 @@ export declare type AppendedIframe = { builtNode: HTMLIFrameElement | RRIFrameElement; }; export declare function isSerializedIframe(n: TNode, mirror: IMirror): boolean; +export declare function isSerializedStylesheet(n: TNode, mirror: IMirror): boolean; export declare function getBaseDimension(node: Node, rootIframe: Node): DocumentDimension; export declare function hasShadowRoot(n: T): n is T & { shadowRoot: ShadowRoot;