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 '; + const style = document.querySelector('style'); + if (style) { + // as authored, e.g. no spaces + style.append('.a{background-color:black;}'); + + // how it is currently stringified (spaces present) + const expected = [ + '.a { background-color: red; }', + '.a { background-color: black; }', + ]; + const browserSheet = expected.join(''); + expect(stringifyStylesheet(style.sheet!)).toEqual(browserSheet); + + expect(splitCssText(browserSheet, style)).toEqual(expected); + } + }); + + 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) { + // as authored, with newlines + style.appendChild( + JSDOM.fragment(`.x { + -webkit-transition: all 4s ease; + content: 'try to keep a newline'; + transition: all 4s ease; +}`), + ); + // TODO: splitCssText can't handle it yet if both start with .x + 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 + const expected = [ + '.x { content: "try to keep a newline"; background: red; transition: 4s; }', + '.y { transition: 5s; }', + ]; + 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); + } + }); +}); + +describe('applyCssSplits css rejoiner', function () { + const mockLastUnusedArg = null as unknown as BuildCache; + const halfCssText = '.a { background-color: red; }'; + const otherHalfCssText = halfCssText.replace('.a', '.x'); + const markedCssText = [halfCssText, otherHalfCssText].join('/* rr_split */'); + let sn: serializedElementNodeWithId; + + beforeEach(() => { + sn = { + type: NodeType.Element, + tagName: 'style', + childNodes: [ + { + type: NodeType.Text, + textContent: '', + }, + { + type: NodeType.Text, + textContent: '', + }, + ], + } as serializedElementNodeWithId; + }); + + it('applies css splits correctly', () => { + // happy path + applyCssSplits(sn, markedCssText, false, mockLastUnusedArg); + expect((sn.childNodes[0] as textNode).textContent).toEqual(halfCssText); + expect((sn.childNodes[1] as textNode).textContent).toEqual( + otherHalfCssText, + ); + }); + + it('applies css splits correctly even when there are too many child nodes', () => { + let sn3 = { + type: NodeType.Element, + tagName: 'style', + childNodes: [ + { + type: NodeType.Text, + textContent: '', + }, + { + type: NodeType.Text, + textContent: '', + }, + { + type: NodeType.Text, + textContent: '', + }, + ], + } as serializedElementNodeWithId; + applyCssSplits(sn3, markedCssText, false, mockLastUnusedArg); + expect((sn3.childNodes[0] as textNode).textContent).toEqual(halfCssText); + expect((sn3.childNodes[1] as textNode).textContent).toEqual( + otherHalfCssText, + ); + expect((sn3.childNodes[2] as textNode).textContent).toEqual(''); + }); + + it('maintains entire css text when there are too few child nodes', () => { + let sn1 = { + type: NodeType.Element, + tagName: 'style', + childNodes: [ + { + type: NodeType.Text, + textContent: '', + }, + ], + } as serializedElementNodeWithId; + applyCssSplits(sn1, markedCssText, false, mockLastUnusedArg); + expect((sn1.childNodes[0] as textNode).textContent).toEqual( + halfCssText + otherHalfCssText, + ); + }); +}); 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; diff --git a/packages/rrweb-snapshot/test/snapshot.test.ts b/packages/rrweb-snapshot/test/snapshot.test.ts index c3ff607fd6..5778eb0aff 100644 --- a/packages/rrweb-snapshot/test/snapshot.test.ts +++ b/packages/rrweb-snapshot/test/snapshot.test.ts @@ -162,22 +162,27 @@ describe('style elements', () => { it('should serialize all rules of stylesheet when the sheet has a single child node', () => { const styleEl = render(``); 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;}/* rr_split */section {color: blue;}', + }, + type: 2, }); }); }); diff --git a/packages/rrweb/package.json b/packages/rrweb/package.json index d8ff22b763..2623a517ff 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": "cross-env 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", diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 3ab7f19170..3feae589f6 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -287,12 +287,26 @@ export default class MutationBuffer { }; const pushAdd = (n: Node) => { const parent = dom.parentNode(n); - if (!parent || !inDom(n) || (parent as Element).tagName === 'TEXTAREA') { + if (!parent || !inDom(n)) { return; } + let cssCaptured = false; + if (n.nodeType === Node.TEXT_NODE) { + const parentTag = (parent as Element).tagName; + if (parentTag === 'TEXTAREA') { + // genTextAreaValueMutation already called via parent + return; + } else if (parentTag === 'STYLE' && this.addedSet.has(parent)) { + // css content will be recorded via parent's _cssText attribute when + // mutation adds entire + + + + + + + + + diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index a569d7e035..bc244620e0 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -101,7 +101,7 @@ describe('record integration tests', function (this: ISuite) { await assertSnapshot(snapshots); }); - it('can record textarea mutations correctly', async () => { + it('can record and replay textarea mutations correctly', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); await page.setContent(getHtml.call(this, 'empty.html')); @@ -112,20 +112,29 @@ describe('record integration tests', function (this: ISuite) { const ta = document.createElement('textarea'); ta.innerText = 'pre value'; document.body.append(ta); + + const ta2 = document.createElement('textarea'); + ta2.id = 'ta2'; + document.body.append(ta2); }); - await page.waitForTimeout(5); + await waitForRAF(page); await page.evaluate(() => { const t = document.querySelector('textarea') as HTMLTextAreaElement; t.innerText = 'ok'; // this mutation should be recorded + + const ta2t = document.createTextNode('added'); + document.getElementById('ta2').append(ta2t); }); - await page.waitForTimeout(5); + await waitForRAF(page); await page.evaluate(() => { const t = document.querySelector('textarea') as HTMLTextAreaElement; (t.childNodes[0] as Text).appendData('3'); // this mutation is also valid + + document.getElementById('ta2').remove(); // done with this }); - await page.waitForTimeout(5); + await waitForRAF(page); await page.type('textarea', '1'); // types (inserts) at index 0, in front of existing text - await page.waitForTimeout(5); + await waitForRAF(page); await page.evaluate(() => { const t = document.querySelector('textarea') as HTMLTextAreaElement; // user has typed so childNode content should now be ignored @@ -136,7 +145,7 @@ describe('record integration tests', function (this: ISuite) { // there is nothing explicit in rrweb which enforces this, but this test may protect against // a future change where a mutation on a textarea incorrectly updates the .value }); - await page.waitForTimeout(5); + await waitForRAF(page); await page.type('textarea', '2'); // cursor is at index 1 const snapshots = (await page.evaluate( @@ -153,12 +162,18 @@ describe('record integration tests', function (this: ISuite) { replayer.pause((e.timestamp - window.snapshots[0].timestamp)+1); let ts = replayer.iframe.contentDocument.querySelector('textarea'); vals.push((e.data.source === 0 ? 'Mutation' : 'User') + ':' + ts.value); + let ts2 = replayer.iframe.contentDocument.getElementById('ta2'); + if (ts2) { + vals.push('ta2:' + ts2.value); + } }); vals; `); expect(replayTextareaValues).toEqual([ 'Mutation:pre value', + 'ta2:', 'Mutation:ok', + 'ta2:added', 'Mutation:ok3', 'User:1ok3', 'Mutation:1ok3', // if this gets set to 'ignore', it's an error, as the 'user' has modified the textarea @@ -166,6 +181,131 @@ 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`); + 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#dual-textContent'); + if (styleEl) { + styleEl.append( + document.createTextNode('body { background-color: darkgreen; }'), + ); + styleEl.append( + document.createTextNode( + '.absolutify { background-image: url("./rel"); }', + ), + ); + } + }); + await waitForRAF(page); + await page.evaluate(() => { + let styleEl = document.querySelector('style#dual-textContent'); + if (styleEl) { + styleEl.childNodes.forEach((cn) => { + if (cn.textContent) { + cn.textContent = cn.textContent.replace('darkgreen', 'purple'); + cn.textContent = cn.textContent.replace( + 'orange !important', + 'yellow', + ); + } + }); + } + }); + await waitForRAF(page); + await page.evaluate(() => { + let styleEl = document.querySelector('style#dual-textContent'); + if (styleEl) { + styleEl.childNodes.forEach((cn) => { + if (cn.textContent) { + cn.textContent = cn.textContent.replace( + 'black', + 'black !important', + ); + } + }); + } + let hoverMutationStyleEl = document.querySelector('style#hover-mutation'); + if (hoverMutationStyleEl) { + hoverMutationStyleEl.childNodes.forEach((cn) => { + if (cn.textContent) { + cn.textContent = 'a:hover { outline: cyan solid 1px; }'; + } + }); + } + let st = document.createElement('style'); + st.id = 'goldilocks'; + st.innerText = 'body { color: brown }'; + document.body.append(st); + }); + + await waitForRAF(page); + await page.evaluate(() => { + let styleEl = document.querySelector('style#goldilocks'); + if (styleEl) { + styleEl.childNodes.forEach((cn) => { + if (cn.textContent) { + cn.textContent = cn.textContent.replace('brown', 'gold'); + } + }); + } + }); + + 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); + let bodyStyle = getComputedStyle(replayer.iframe.contentDocument.querySelector('body')) + vals.push({ + 'background-color': bodyStyle['background-color'], + 'color': bodyStyle['color'], + }); + }); + 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; +`); + + expect(replayStyleValues).toEqual([ + { + 'background-color': 'rgb(0, 100, 0)', // darkgreen + color: 'rgb(255, 165, 0)', // orange (from style.html) + }, + { + 'background-color': 'rgb(128, 0, 128)', // purple + color: 'rgb(255, 255, 0)', // yellow + }, + { + 'background-color': 'rgb(0, 0, 0)', // black !important + color: 'rgb(165, 42, 42)', // brown + }, + { + 'background-color': 'rgb(0, 0, 0)', + color: 'rgb(255, 215, 0)', // gold + }, + '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 + ]); + }); + it('can record childList mutations', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); @@ -1238,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(); @@ -1336,4 +1472,77 @@ describe('record integration tests', function (this: ISuite) { expect(changedColors).toEqual([NewColor, NewColor]); await page.close(); }); + + 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 + + await page.setContent( + ` + + + + + +
+
+ + + `, + ); + // 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(); + }); }); diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index 697d3c8e65..64d4ae1196 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -250,18 +250,18 @@ export function stringifySnapshots(snapshots: eventWithTime[]): string { function stripBlobURLsFromAttributes(node: { attributes: { - src?: string; + [key: string]: any; }; }) { - if ( - 'src' in node.attributes && - node.attributes.src && - typeof node.attributes.src === 'string' && - node.attributes.src.startsWith('blob:') - ) { - node.attributes.src = node.attributes.src - .replace(/[\w-]+$/, '...') - .replace(/:[0-9]+\//, ':xxxx/'); + for (const attr in node.attributes) { + if ( + typeof node.attributes[attr] === 'string' && + node.attributes[attr].startsWith('blob:') + ) { + node.attributes[attr] = node.attributes[attr] + .replace(/[\w-]+$/, '...') + .replace(/:[0-9]+\//, ':xxxx/'); + } } }