From ea9f096cc29c3855e2f1ec3befa0d94f0c0ee005 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Fri, 5 Apr 2024 17:04:01 +0100 Subject: [PATCH 01/44] Unrelated, but the presence of a blank `` element when checking the replay took me ages to debug as I thought it was something I introducedg --- packages/rrweb/src/replay/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 4ac17df053..bf597a266b 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -881,6 +881,9 @@ export class Replayer { 'html.rrweb-paused *, html.rrweb-paused *:before, html.rrweb-paused *:after { animation-play-state: paused !important; }', ); } + if (!injectStylesRules.length) { + return; + } if (this.usingVirtualDom) { const styleEl = this.virtualDom.createElement('style'); this.virtualDom.mirror.add( From 385dee84659b56bd24d4515a45128609249b7250 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Thu, 28 Mar 2024 15:45:31 +0000 Subject: [PATCH 02/44] Default to headless on retest --- packages/rrweb/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/rrweb/package.json b/packages/rrweb/package.json index d8ff22b763..af9d01a65c 100644 --- a/packages/rrweb/package.json +++ b/packages/rrweb/package.json @@ -5,7 +5,8 @@ "scripts": { "prepare": "npm run prepack", "prepack": "npm run build", - "retest": "vitest run --exclude test/benchmark", + "retest": "PUPPETEER_HEADLESS=true yarn retest:headful", + "retest:headful": "vitest run --exclude test/benchmark", "build-and-test": "yarn build && yarn retest", "test:headless": "cross-env PUPPETEER_HEADLESS=true yarn build-and-test", "test:headful": "cross-env PUPPETEER_HEADLESS=false yarn build-and-test", From 945bde39b9dd676c77e63c87647b0c4bcd69b575 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Fri, 5 Apr 2024 17:05:36 +0100 Subject: [PATCH 03/44] Add motivating test which can be used against this PR as well as in #1417 --- .../__snapshots__/integration.test.ts.snap | 219 ++++++++++++++++++ packages/rrweb/test/html/style.html | 13 ++ packages/rrweb/test/integration.test.ts | 68 ++++++ 3 files changed, 300 insertions(+) create mode 100644 packages/rrweb/test/html/style.html diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index 032bdbec63..4801cab5a3 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -1034,6 +1034,225 @@ exports[`record integration tests > can mask character data mutations with regex ]" `; +exports[`record integration tests > can record and replay style mutations 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\\": \\"style\\", + \\"id\\": 11 + } + ], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 12 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"style\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"body { background-color: black; }\\", + \\"isStyle\\": true, + \\"id\\": 14 + } + ], + \\"id\\": 13 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 15 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 16 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\", + \\"id\\": 18 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 20 + } + ], + \\"id\\": 19 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", + \\"id\\": 21 + } + ], + \\"id\\": 17 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 13, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 3, + \\"textContent\\": \\"body { background-color: darkgreen; }\\", + \\"isStyle\\": true, + \\"id\\": 22 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [ + { + \\"id\\": 22, + \\"value\\": \\"body { background-color: purple; }\\" + } + ], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [ + { + \\"id\\": 14, + \\"value\\": \\"\\\\n body { background-color: black !important; }\\\\n \\" + } + ], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [] + } + } +]" +`; + exports[`record integration tests > can record attribute mutation 1`] = ` "[ { diff --git a/packages/rrweb/test/html/style.html b/packages/rrweb/test/html/style.html new file mode 100644 index 0000000000..0cffb2e516 --- /dev/null +++ b/packages/rrweb/test/html/style.html @@ -0,0 +1,13 @@ + + + + + + style + + + + + diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index a569d7e035..d93a174d1f 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -166,6 +166,74 @@ describe('record integration tests', function (this: ISuite) { ]); }); + it('can record and replay style mutations', async () => { + // TODO: we could get a lot more elaborate here with mixed textContent and insertRule mutations + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent(getHtml.call(this, 'style.html')); + + await waitForRAF(page); // ensure mutations aren't included in fullsnapshot + + await page.evaluate(() => { + let styleEl = document.querySelector('style'); + if (styleEl) { + styleEl.append( + document.createTextNode('body { background-color: darkgreen; }'), + ); + } + }); + await waitForRAF(page); + await page.evaluate(() => { + let styleEl = document.querySelector('style'); + if (styleEl) { + styleEl.childNodes.forEach((cn) => { + if (cn.textContent) { + cn.textContent = cn.textContent.replace('darkgreen', 'purple'); + } + }); + } + }); + await waitForRAF(page); + await page.evaluate(() => { + let styleEl = document.querySelector('style'); + if (styleEl) { + styleEl.childNodes.forEach((cn) => { + if (cn.textContent) { + cn.textContent = cn.textContent.replace( + 'black', + 'black !important', + ); + } + }); + } + }); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + + // following ensures that the ./rel url has been absolutized (in a mutation) + await assertSnapshot(snapshots); + + // check after each mutation and text input + const replayStyleValues = await page.evaluate(` + const { Replayer } = rrweb; + const replayer = new Replayer(window.snapshots); + const vals = []; + window.snapshots.filter((e)=>e.data.attributes || e.data.source === 5).forEach((e)=>{ + replayer.pause((e.timestamp - window.snapshots[0].timestamp)+1); + vals.push(getComputedStyle(replayer.iframe.contentDocument.querySelector('body'))['background-color']); +}); + vals; +`); + + expect(replayStyleValues).toEqual([ + 'rgb(0, 100, 0)', // darkgreen + 'rgb(128, 0, 128)', // purple + 'rgb(0, 0, 0)', // black !important + ]); + }); + it('can record childList mutations', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); From a6ef9c4fcd2d4b422bab6abf8b9c34b8186e00e8 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Fri, 12 Apr 2024 17:32:57 +0100 Subject: [PATCH 04/44] Recognize that snapshot.ts::serializeTextNode does important work for mutations with `absoluteToStylesheet` - I had accidentally removed that with initial work --- .../test/__snapshots__/integration.test.ts.snap | 14 ++++++++++++-- packages/rrweb/test/integration.test.ts | 7 ++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index 4801cab5a3..04743b4efd 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -1212,10 +1212,20 @@ exports[`record integration tests > can record and replay style mutations 1`] = \\"nextId\\": null, \\"node\\": { \\"type\\": 3, - \\"textContent\\": \\"body { background-color: darkgreen; }\\", + \\"textContent\\": \\".absolutify { background-image: url(\\\\\\"http://localhost:3030/rel\\\\\\"); }\\", \\"isStyle\\": true, \\"id\\": 22 } + }, + { + \\"parentId\\": 13, + \\"nextId\\": 22, + \\"node\\": { + \\"type\\": 3, + \\"textContent\\": \\"body { background-color: darkgreen; }\\", + \\"isStyle\\": true, + \\"id\\": 23 + } } ] } @@ -1226,7 +1236,7 @@ exports[`record integration tests > can record and replay style mutations 1`] = \\"source\\": 0, \\"texts\\": [ { - \\"id\\": 22, + \\"id\\": 23, \\"value\\": \\"body { background-color: purple; }\\" } ], diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index d93a174d1f..ee11b17e0d 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -169,7 +169,7 @@ describe('record integration tests', function (this: ISuite) { it('can record and replay style mutations', async () => { // TODO: we could get a lot more elaborate here with mixed textContent and insertRule mutations const page: puppeteer.Page = await browser.newPage(); - await page.goto('about:blank'); + await page.goto(`${serverURL}/html`); await page.setContent(getHtml.call(this, 'style.html')); await waitForRAF(page); // ensure mutations aren't included in fullsnapshot @@ -180,6 +180,11 @@ describe('record integration tests', function (this: ISuite) { styleEl.append( document.createTextNode('body { background-color: darkgreen; }'), ); + styleEl.append( + document.createTextNode( + '.absolutify { background-image: url("./rel"); }', + ), + ); } }); await waitForRAF(page); From bc769aa8301fbcca35c1d608af382f01d670bf77 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Fri, 12 Apr 2024 17:33:19 +0100 Subject: [PATCH 05/44] Prep PR for async `); styleEl.sheet?.insertRule('section { color: blue; }'); - expect(serializeNode(styleEl.childNodes[0])).toMatchObject({ - isStyle: true, + expect(serializeNode(styleEl)).toMatchObject({ rootId: undefined, - textContent: 'section {color: blue;}body {color: red;}', - type: 3, + attributes: { + _cssText: 'section {color: blue;}body {color: red;}', + }, + type: 2, }); }); - it('should serialize individual text nodes on stylesheets with multiple child nodes', () => { + it('should serialize all rules on stylesheets with mix of insertion type', () => { const styleEl = render(``); + styleEl.sheet?.insertRule('section.lost { color: unseeable; }'); // browser throws this away after append styleEl.append(document.createTextNode('section { color: blue; }')); - expect(serializeNode(styleEl.childNodes[1])).toMatchObject({ - isStyle: true, + styleEl.sheet?.insertRule('section.working { color: pink; }'); + expect(serializeNode(styleEl)).toMatchObject({ rootId: undefined, - textContent: 'section { color: blue; }', - type: 3, + attributes: { + _cssText: + 'section.working {color: pink;}body {color: red;}section {color: blue;}', + }, + type: 2, }); }); }); diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index 04743b4efd..16ae8b1352 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -1132,12 +1132,13 @@ exports[`record integration tests > can record and replay style mutations 1`] = { \\"type\\": 2, \\"tagName\\": \\"style\\", - \\"attributes\\": {}, + \\"attributes\\": { + \\"_cssText\\": \\"body { background-color: black; }\\" + }, \\"childNodes\\": [ { \\"type\\": 3, - \\"textContent\\": \\"body { background-color: black; }\\", - \\"isStyle\\": true, + \\"textContent\\": \\"\\", \\"id\\": 14 } ], @@ -1213,7 +1214,6 @@ exports[`record integration tests > can record and replay style mutations 1`] = \\"node\\": { \\"type\\": 3, \\"textContent\\": \\".absolutify { background-image: url(\\\\\\"http://localhost:3030/rel\\\\\\"); }\\", - \\"isStyle\\": true, \\"id\\": 22 } }, @@ -1223,7 +1223,6 @@ exports[`record integration tests > can record and replay style mutations 1`] = \\"node\\": { \\"type\\": 3, \\"textContent\\": \\"body { background-color: darkgreen; }\\", - \\"isStyle\\": true, \\"id\\": 23 } } @@ -5445,12 +5444,13 @@ exports[`record integration tests > mutations should work when blocked class is { \\"type\\": 2, \\"tagName\\": \\"style\\", - \\"attributes\\": {}, + \\"attributes\\": { + \\"_cssText\\": \\"#b-class, #b-class-2 { height: 33px; width: 200px; }\\" + }, \\"childNodes\\": [ { \\"type\\": 3, - \\"textContent\\": \\"#b-class, #b-class-2 { height: 33px; width: 200px; }\\", - \\"isStyle\\": true, + \\"textContent\\": \\"\\", \\"id\\": 9 } ], @@ -8161,12 +8161,13 @@ exports[`record integration tests > should nest record iframe 1`] = ` { \\"type\\": 2, \\"tagName\\": \\"style\\", - \\"attributes\\": {}, + \\"attributes\\": { + \\"_cssText\\": \\"iframe { width: 500px; height: 500px; }\\" + }, \\"childNodes\\": [ { \\"type\\": 3, - \\"textContent\\": \\"iframe { width: 500px; height: 500px; }\\", - \\"isStyle\\": true, + \\"textContent\\": \\"\\", \\"id\\": 14 } ], @@ -11704,7 +11705,6 @@ exports[`record integration tests > should record dynamic CSS changes 1`] = ` { \\"type\\": 3, \\"textContent\\": \\"\\", - \\"isStyle\\": true, \\"id\\": 18 } ], @@ -14972,12 +14972,13 @@ exports[`record integration tests > should record shadow DOM 1`] = ` { \\"type\\": 2, \\"tagName\\": \\"style\\", - \\"attributes\\": {}, + \\"attributes\\": { + \\"_cssText\\": \\".my-element { margin: 0px 0px 1rem; }iframe { border: 0px; width: 100%; padding: 0px; }body { max-width: 400px; margin: 1rem auto; padding: 0px 1rem; font-family: \\\\\\"comic sans ms\\\\\\"; }\\" + }, \\"childNodes\\": [ { \\"type\\": 3, - \\"textContent\\": \\".my-element { margin: 0px 0px 1rem; }iframe { border: 0px; width: 100%; padding: 0px; }body { max-width: 400px; margin: 1rem auto; padding: 0px 1rem; font-family: \\\\\\"comic sans ms\\\\\\"; }\\", - \\"isStyle\\": true, + \\"textContent\\": \\"\\", \\"id\\": 14 } ], @@ -15055,12 +15056,13 @@ exports[`record integration tests > should record shadow DOM 1`] = ` { \\"type\\": 2, \\"tagName\\": \\"style\\", - \\"attributes\\": {}, + \\"attributes\\": { + \\"_cssText\\": \\"body { margin: 0px; }p { border: 1px solid rgb(204, 204, 204); padding: 1rem; color: red; font-family: sans-serif; }\\" + }, \\"childNodes\\": [ { \\"type\\": 3, - \\"textContent\\": \\"body { margin: 0px; }p { border: 1px solid rgb(204, 204, 204); padding: 1rem; color: red; font-family: sans-serif; }\\", - \\"isStyle\\": true, + \\"textContent\\": \\"\\", \\"id\\": 28 } ], @@ -16129,8 +16131,7 @@ exports[`record integration tests > should record style mutations and replay the \\"childNodes\\": [ { \\"type\\": 3, - \\"textContent\\": \\"#one { color: rgb(255, 0, 0); }\\", - \\"isStyle\\": true, + \\"textContent\\": \\"\\", \\"id\\": 7 } ], @@ -16260,7 +16261,6 @@ exports[`record integration tests > should record style mutations and replay the \\"node\\": { \\"type\\": 3, \\"textContent\\": \\"#two { color: rgb(255, 0, 0); }\\", - \\"isStyle\\": true, \\"id\\": 22 } } diff --git a/packages/rrweb/test/__snapshots__/record.test.ts.snap b/packages/rrweb/test/__snapshots__/record.test.ts.snap index 8d540c0e2d..22c6980a57 100644 --- a/packages/rrweb/test/__snapshots__/record.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/record.test.ts.snap @@ -1386,18 +1386,18 @@ exports[`record > captures inserted style text nodes correctly 1`] = ` { \\"type\\": 2, \\"tagName\\": \\"style\\", - \\"attributes\\": {}, + \\"attributes\\": { + \\"_cssText\\": \\"div { color: red; }section { color: blue; }\\" + }, \\"childNodes\\": [ { \\"type\\": 3, - \\"textContent\\": \\"div { color: red; }\\", - \\"isStyle\\": true, + \\"textContent\\": \\"\\", \\"id\\": 6 }, { \\"type\\": 3, - \\"textContent\\": \\"section { color: blue; }\\", - \\"isStyle\\": true, + \\"textContent\\": \\"\\", \\"id\\": 7 } ], @@ -1460,7 +1460,6 @@ exports[`record > captures inserted style text nodes correctly 1`] = ` \\"node\\": { \\"type\\": 3, \\"textContent\\": \\"h1 { color: pink; }\\", - \\"isStyle\\": true, \\"id\\": 12 } }, @@ -1470,7 +1469,6 @@ exports[`record > captures inserted style text nodes correctly 1`] = ` \\"node\\": { \\"type\\": 3, \\"textContent\\": \\"span { color: orange; }\\", - \\"isStyle\\": true, \\"id\\": 13 } } diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index ee11b17e0d..5abc1eee65 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -167,6 +167,7 @@ describe('record integration tests', function (this: ISuite) { }); it('can record and replay style mutations', async () => { + // This test shows that the `isStyle` attribute on textContent is not needed in a mutation // TODO: we could get a lot more elaborate here with mixed textContent and insertRule mutations const page: puppeteer.Page = await browser.newPage(); await page.goto(`${serverURL}/html`); From b98c062e1d38efa4070d5ba11260a69436bdf53e Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Fri, 12 Apr 2024 17:34:35 +0100 Subject: [PATCH 06/44] Add a test to show how mutations on multiple text nodes within the + diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 5abc1eee65..56171b302a 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -228,15 +228,28 @@ describe('record integration tests', function (this: ISuite) { const vals = []; window.snapshots.filter((e)=>e.data.attributes || e.data.source === 5).forEach((e)=>{ replayer.pause((e.timestamp - window.snapshots[0].timestamp)+1); - vals.push(getComputedStyle(replayer.iframe.contentDocument.querySelector('body'))['background-color']); -}); + let bodyStyle = getComputedStyle(replayer.iframe.contentDocument.querySelector('body')) + vals.push({ + 'background-color': bodyStyle['background-color'], + 'color': bodyStyle['color'], + }); + }); vals; `); expect(replayStyleValues).toEqual([ - 'rgb(0, 100, 0)', // darkgreen - 'rgb(128, 0, 128)', // purple - 'rgb(0, 0, 0)', // black !important + { + 'background-color': 'rgb(0, 100, 0)', // darkgreen + color: 'rgb(0, 100, 0)', // darkgreen (from style.html) + }, + { + 'background-color': 'rgb(128, 0, 128)', // purple + color: 'rgb(128, 0, 128)', // purple + }, + { + 'background-color': 'rgb(0, 0, 0)', // black !important + color: 'rgb(128, 0, 128)', // purple + }, ]); }); From 6df62e125089a9078b5d218214226f06851c2031 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Fri, 12 Apr 2024 17:59:25 +0100 Subject: [PATCH 07/44] Multiple `).querySelector('style'); + if (style) { + // as authored, e.g. no spaces + style.appendChild(JSDOM.fragment('.a{background-color:red;}')); + style.appendChild(JSDOM.fragment('.a{background-color:black;}')); + + // how it is currently stringified (spaces present) + let browserSheet = '.a { background-color: red; }'; + let expectedSplit = browserSheet.length; + browserSheet += '.a { background-color: black; }'; + + // can't do this as JSDOM doesn't have style.sheet + //expect(stringifyStylesheet(style.sheet!)).toEqual(browserSheet); + + expect(findCssTextSplits(browserSheet, style)).toEqual([expectedSplit]); + } + }); +}); From 173cd0f11ed03d9dad9ead2e41444ac25ad172d2 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Fri, 12 Apr 2024 17:56:12 +0100 Subject: [PATCH 09/44] Missed this which is 'happy path' - add a test to catch catch case --- packages/rrweb-snapshot/src/rebuild.ts | 3 + .../__snapshots__/integration.test.ts.snap | 77 ++++++++++++++++--- packages/rrweb/test/html/style.html | 11 ++- packages/rrweb/test/integration.test.ts | 4 + 4 files changed, 83 insertions(+), 12 deletions(-) diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index fa9d1b0adc..6f744b55cf 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -189,6 +189,9 @@ function buildNode( } } continue; + } else if (hackCss && isRemoteOrDynamicCss) { + // element or dynamic + + + diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index a2651b66ed..b53fc17720 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -238,6 +238,8 @@ describe('record integration tests', function (this: ISuite) { 'color': bodyStyle['color'], }); }); + vals.push(replayer.iframe.contentDocument.getElementById('single-textContent').innerText); + vals.push(replayer.iframe.contentDocument.getElementById('empty').innerText); vals; `); @@ -254,6 +256,8 @@ describe('record integration tests', function (this: ISuite) { 'background-color': 'rgb(0, 0, 0)', // black !important color: 'rgb(255, 255, 0)', // yellow }, + 'a:hover,\na.\\:hover { outline: red solid 1px; }', // has run adaptCssForReplay + 'a:hover,\na.\\:hover { outline: blue solid 1px; }', // has run adaptCssForReplay ]); }); From 2983a19d07dfa48e0bec234b1ac899b6fb6aa8dc Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Fri, 12 Apr 2024 17:48:19 +0100 Subject: [PATCH 10/44] Create single-style-capture.md --- .changeset/single-style-capture.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/single-style-capture.md diff --git a/.changeset/single-style-capture.md b/.changeset/single-style-capture.md new file mode 100644 index 0000000000..96f81ed621 --- /dev/null +++ b/.changeset/single-style-capture.md @@ -0,0 +1,6 @@ +--- +"rrweb-snapshot": patch +"rrweb": patch +--- + +Edge case: Provide support for mutations on a `).querySelector('style'); + if (style) { + // as authored, with newlines + style.appendChild( + JSDOM.fragment(`.x { + -webkit-transition: all 4s ease; + content: 'try to keep a newline'; + transition: all 4s ease; +}`), + ); + style.appendChild( + JSDOM.fragment(`.y { + -moz-transition: all 5s ease; + transition: all 5s ease; +}`), + ); + // browser .rules would usually omit the vendored versions and modifies the transition value + let browserSheet = + '.x { content: "try to keep a newline"; background: red; transition: 4s; }'; + let expectedSplit = browserSheet.length; + browserSheet += '.y { transition: 5s; }'; + + // can't do this as JSDOM doesn't have style.sheet + //expect(stringifyStylesheet(style.sheet!)).toEqual(browserSheet); + + expect(findCssTextSplits(browserSheet, style)).toEqual([ + expectedSplit, + browserSheet.length, + ]); } }); }); diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index 95d711582b..50718aa5ba 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -1135,7 +1135,7 @@ exports[`record integration tests > can record and replay style mutations 1`] = \\"attributes\\": { \\"id\\": \\"dual-textContent\\", \\"_cssText\\": \\"body { background-color: black; }body { color: orange !important; }\\", - \\"_cssTextSplits\\": \\"33\\" + \\"_cssTextSplits\\": \\"33 67\\" }, \\"childNodes\\": [ { diff --git a/packages/rrweb/test/__snapshots__/record.test.ts.snap b/packages/rrweb/test/__snapshots__/record.test.ts.snap index 94f70ade63..d24ccc79c6 100644 --- a/packages/rrweb/test/__snapshots__/record.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/record.test.ts.snap @@ -1388,7 +1388,7 @@ exports[`record > captures inserted style text nodes correctly 1`] = ` \\"tagName\\": \\"style\\", \\"attributes\\": { \\"_cssText\\": \\"div { color: red; }section { color: blue; }\\", - \\"_cssTextSplits\\": \\"19\\" + \\"_cssTextSplits\\": \\"19 43\\" }, \\"childNodes\\": [ { From 7b5dda77efc2b1e8f42f1799f288ea6fe163cab4 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Tue, 7 May 2024 12:00:15 +0100 Subject: [PATCH 16/44] Refactor out `applyCssSplits` for clarity and so we can test it --- packages/rrweb-snapshot/src/rebuild.ts | 84 ++++++++++++++---------- packages/rrweb-snapshot/src/types.ts | 14 +++- packages/rrweb-snapshot/test/css.test.ts | 49 +++++++++++++- 3 files changed, 110 insertions(+), 37 deletions(-) diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 002342c82f..80e2f6c2ff 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -79,6 +79,49 @@ export function createCache(): BuildCache { }; } +/** + * undo findCssTextSplits + * (would move to utils.ts but uses `adaptCssForReplay`) + */ +export function applyCssSplits( + n: serializedElementNodeWithId, + cssText: string, + cssTextSplits: number[], + hackCss: boolean, + cache: BuildCache, +): void { + const lenCheckOk = + cssTextSplits.length && + cssTextSplits[cssTextSplits.length - 1] === cssText.length; + for (let j = n.childNodes.length - 1; j >= 0; j--) { + const scn = n.childNodes[j]; + let ix = 0; + if (cssTextSplits.length > j && j > 0) { + ix = cssTextSplits[j - 1]; + } + if (scn.type === NodeType.Text) { + let remainder = ''; + if (ix !== 0 && lenCheckOk) { + remainder = cssText.substring(0, ix); + cssText = cssText.substring(ix); + } else if (j > 1) { + continue; + } + if (hackCss) { + cssText = adaptCssForReplay(cssText, cache); + } + // id will be assigned when these child nodes are + // iterated over in buildNodeWithSN + scn.textContent = cssText; + cssText = remainder; + } + } + if (cssText.length) { + // something has gone wrong + console.warn('Leftover css content after applyCssSplits:', cssText); + } +} + /** * Normally a diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 5538ecdd64..a0b5c5f07f 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -216,6 +216,14 @@ describe('record integration tests', function (this: ISuite) { } }); } + let hoverMutationStyleEl = document.getElementById('hover-mutation'); + if (hoverMutationStyleEl) { + hoverMutationStyleEl.childNodes.forEach((cn) => { + if (cn.textContent) { + cn.textContent = 'a:hover { outline: cyan solid 1px; }'; + } + }); + } }); const snapshots = (await page.evaluate( @@ -240,6 +248,7 @@ describe('record integration tests', function (this: ISuite) { }); vals.push(replayer.iframe.contentDocument.getElementById('single-textContent').innerText); vals.push(replayer.iframe.contentDocument.getElementById('empty').innerText); + vals.push(replayer.iframe.contentDocument.getElementById('hover-mutation').innerText); vals; `); @@ -258,6 +267,7 @@ describe('record integration tests', function (this: ISuite) { }, 'a:hover,\na.\\:hover { outline: red solid 1px; }', // has run adaptCssForReplay 'a:hover,\na.\\:hover { outline: blue solid 1px; }', // has run adaptCssForReplay + 'a:hover,\na.\\:hover { outline: cyan solid 1px; }', // has run adaptCssForReplay after text mutation ]); }); From 46aa8f2d8144624976cdc0b93b4a2ce2949f58df Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Mon, 8 Jul 2024 11:37:44 +0100 Subject: [PATCH 27/44] Don't record css content twice when a + + +
+
+ + + `, + ); + // Start rrweb recording + await page.evaluate( + (code, recordSnippet) => { + const script = document.createElement('script'); + script.textContent = `${code};${recordSnippet}`; + document.head.appendChild(script); + }, + code, + generateRecordSnippet({}), + ); + + await page.evaluate(async (Color) => { + // Create a new style element with the same content as the existing style element and apply it to the #two div element + const incrementalStyle = document.createElement( + 'style', + ) as HTMLStyleElement; + incrementalStyle.append(document.createTextNode('/* hello */')); + incrementalStyle.append(document.createTextNode('/* world */')); + document.head.appendChild(incrementalStyle); + incrementalStyle.sheet!.insertRule(`#two { color: ${Color}; }`, 0); + }, Color); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + await assertSnapshot(snapshots); + + /** + * Replay the recorded events and check if the style mutation is applied correctly + */ + const changedColors = await page.evaluate(` + const { Replayer } = rrweb; + const replayer = new Replayer(window.snapshots); + replayer.pause(1000); + + // Get the color of the element after applying the style mutation event + [ + window.getComputedStyle( + replayer.iframe.contentDocument.querySelector('#one'), + ).color, + window.getComputedStyle( + replayer.iframe.contentDocument.querySelector('#two'), + ).color, + ]; + `); + expect(changedColors).toEqual([Color, Color]); + await page.close(); + }); }); From b3588276b5bec9801817a83c7ea40ec22deedce2 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Tue, 9 Jul 2024 11:57:47 +0100 Subject: [PATCH 33/44] This is the effect of the following two changesets: - Don't record css content twice when a
From 876b10947fafab7ab503e11b20998ee62aff8aac Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Tue, 9 Jul 2024 12:53:48 +0100 Subject: [PATCH 36/44] Fixup eslint --- packages/rrweb-snapshot/src/rebuild.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 2c9de06e49..76e6db2510 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -90,8 +90,8 @@ export function applyCssSplits( hackCss: boolean, cache: BuildCache, ): void { - let childTextNodes: serializedTextNodeWithId[] = []; - for (let scn of n.childNodes) { + const childTextNodes: serializedTextNodeWithId[] = []; + for (const scn of n.childNodes) { if (scn.type === NodeType.Text) { childTextNodes.push(scn); } From 39007f1dad536d9d17301d698d2f76606d709115 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Tue, 9 Jul 2024 14:27:11 +0100 Subject: [PATCH 37/44] Some extra reminder on these tests as I believe 'can record and replay style mutations' covers more ground. Could probably emphasize that these 2 are related to `insertRule` i.e. programmatic mutations as opposed to text mutations --- packages/rrweb/test/integration.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 73d9ab4049..bc244620e0 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -1378,12 +1378,8 @@ describe('record integration tests', function (this: ISuite) { }); /** - * https://github.com/rrweb-io/rrweb/pull/1417 - * This test is to make sure that this problem doesn't regress - * Test case description: - * 1. Record two style elements. One is recorded as a full snapshot and the other is recorded as an incremental snapshot. - * 2. Change the color of both style elements to yellow as incremental style mutation. - * 3. Replay the recorded events and check if the style mutation is applied correctly. + * the regression part of the following is now handled by replayer.test.ts::'can deal with duplicate/conflicting values on style elements' + * so this test could be dropped if we add more robust mixing of `insertRule` into 'can record and replay style mutations' */ it('should record style mutations and replay them correctly', async () => { const page: puppeteer.Page = await browser.newPage(); @@ -1478,6 +1474,8 @@ describe('record integration tests', function (this: ISuite) { }); it('should record style mutations with multiple child nodes and replay them correctly', async () => { + // ensure that presence of multiple text nodes doesn't interfere with programmatic insertRule operations + const page: puppeteer.Page = await browser.newPage(); const Color = 'rgb(255, 0, 0)'; // red color From 34def579c2de1546f2882671fa6578aaef98127b Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Fri, 26 Jul 2024 17:30:47 +0100 Subject: [PATCH 38/44] Fix regression on test [html file]: with-style-sheet-with-import.html The prior 'dynamic stylesheet' route is now the main route for serializing a stylesheet; dynamic stylesheet were missed out in #1533 but are caught in this PR by the tests added in that PR as the stylesheet handling is simplified/centralised --- packages/rrweb-snapshot/src/utils.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index b02f5d72f6..1d582954dc 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -114,8 +114,13 @@ export function stringifyStylesheet(s: CSSStyleSheet): string | null { if (!rules) { return null; } + let sheetHref = s.href; + if (!sheetHref && s.ownerNode && s.ownerNode.ownerDocument) { + // an inline `).querySelector('style'); + const window = new Window({ url: 'https://localhost:8080' }); + const document = window.document; + document.head.innerHTML = ''; + const style = document.querySelector('style'); if (style) { // as authored, e.g. no spaces - style.appendChild(JSDOM.fragment('.a{background-color:red;}')); - style.appendChild(JSDOM.fragment('.a{background-color:black;}')); + style.append('.a{background-color:black;}'); // how it is currently stringified (spaces present) const expected = [ @@ -100,8 +103,7 @@ describe('css splitter', () => { '.a { background-color: black; }', ]; const browserSheet = expected.join(''); - // can't do this as JSDOM doesn't have style.sheet - //expect(stringifyStylesheet(style.sheet!)).toEqual(browserSheet); + expect(stringifyStylesheet(style.sheet!)).toEqual(browserSheet); expect(splitCssText(browserSheet, style)).toEqual(expected); } @@ -133,6 +135,7 @@ describe('css splitter', () => { const browserSheet = expected.join(''); // can't do this as JSDOM doesn't have style.sheet + // also happy-dom doesn't strip out vendor-prefixed rules like a real browser does //expect(stringifyStylesheet(style.sheet!)).toEqual(browserSheet); expect(splitCssText(browserSheet, style)).toEqual(expected); From 58bfecc5d8bee0cc0537f2d9c27b6a7f23ca3200 Mon Sep 17 00:00:00 2001 From: Eoghan Murray Date: Fri, 2 Aug 2024 17:56:06 +0100 Subject: [PATCH 44/44] Add stripping of css comments and also semicolons to the `normalizeCssString` function (thanks Justin). Add test to show when this matters --- packages/rrweb-snapshot/src/utils.ts | 10 ++++++---- packages/rrweb-snapshot/test/css.test.ts | 20 ++++++++++++++++++++ packages/rrweb-snapshot/test/rebuild.test.ts | 8 ++------ 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index c86b9bc07f..862a3e5bf4 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -443,10 +443,12 @@ export function absolutifyURLs(cssText: string | null, href: string): string { ); } -function normalizeCssString(cssText: string): string { - // remove spaces - // TODO: normalize other differences between css as authored vs. stringifyStylesheet - return cssText.replace(/[\s]/g, ''); +/** + * Intention is to normalize by remove spaces, semicolons and CSS comments + * so that we can compare css as authored vs. output of stringifyStylesheet + */ +export function normalizeCssString(cssText: string): string { + return cssText.replace(/(\/\*[^*]*\*\/)|[\s;]/g, ''); } /** diff --git a/packages/rrweb-snapshot/test/css.test.ts b/packages/rrweb-snapshot/test/css.test.ts index b784da1405..d51fad363f 100644 --- a/packages/rrweb-snapshot/test/css.test.ts +++ b/packages/rrweb-snapshot/test/css.test.ts @@ -109,6 +109,26 @@ describe('css splitter', () => { } }); + it('finds css textElement splits correctly when comments are present', () => { + const window = new Window({ url: 'https://localhost:8080' }); + const document = window.document; + // as authored, with comment, missing semicolons + document.head.innerHTML = + ''; + const style = document.querySelector('style'); + if (style) { + style.append('/* author comment */.a{color:red}.b{color:green}'); + + // how it is currently stringified (spaces present) + const expected = [ + '.a { color: red; } .b { color: blue; }', + '.a { color: red; } .b { color: green; }', + ]; + const browserSheet = expected.join(''); + expect(splitCssText(browserSheet, style)).toEqual(expected); + } + }); + it('finds css textElement splits correctly when vendor prefixed rules have been removed', () => { const style = JSDOM.fragment(``).querySelector('style'); if (style) { diff --git a/packages/rrweb-snapshot/test/rebuild.test.ts b/packages/rrweb-snapshot/test/rebuild.test.ts index 490b515f5b..14a255bf6d 100644 --- a/packages/rrweb-snapshot/test/rebuild.test.ts +++ b/packages/rrweb-snapshot/test/rebuild.test.ts @@ -10,7 +10,7 @@ import { createCache, } from '../src/rebuild'; import { NodeType } from '../src/types'; -import { createMirror, Mirror } from '../src/utils'; +import { createMirror, Mirror, normalizeCssString } from '../src/utils'; const expect = _expect as unknown as { (actual: T): { @@ -20,7 +20,7 @@ const expect = _expect as unknown as { expect.extend({ toMatchCss: function (received: string, expected: string) { - const pass = normCss(received) === normCss(expected); + const pass = normalizeCssString(received) === normalizeCssString(expected); const message: () => string = () => pass ? '' @@ -32,10 +32,6 @@ expect.extend({ }, }); -function normCss(cssText: string): string { - return cssText.replace(/[\s;]/g, ''); -} - function getDuration(hrtime: [number, number]) { const [seconds, nanoseconds] = hrtime; return seconds * 1000 + nanoseconds / 1000000;