diff --git a/.changeset/single-style-capture.md b/.changeset/single-style-capture.md deleted file mode 100644 index 96f81ed6..00000000 --- a/.changeset/single-style-capture.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"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 f3b43c77..d2ca69bb 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, normalizeCssString } from '../src/utils'; +import { createMirror, Mirror } from '../src/utils'; import postcss from 'postcss'; const expect = _expect as unknown as { @@ -21,7 +21,7 @@ const expect = _expect as unknown as { expect.extend({ toMatchCss: function (received: string, expected: string) { - const pass = normalizeCssString(received) === normalizeCssString(expected); + const pass = normCss(received) === normCss(expected); const message: () => string = () => pass ? '' @@ -33,6 +33,10 @@ 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 794a9bee..a8f75c76 100644 --- a/packages/rrweb-snapshot/test/snapshot.test.ts +++ b/packages/rrweb-snapshot/test/snapshot.test.ts @@ -164,27 +164,22 @@ 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)).toMatchObject({ + expect(serializeNode(styleEl.childNodes[0])).toMatchObject({ + isStyle: true, rootId: undefined, - attributes: { - _cssText: 'section {color: blue;}body {color: red;}', - }, - type: 2, + textContent: 'section {color: blue;}body {color: red;}', + type: 3, }); }); - it('should serialize all rules on stylesheets with mix of insertion type', () => { + it('should serialize individual text nodes on stylesheets with multiple child nodes', () => { const styleEl = render(``); - styleEl.sheet?.insertRule('section.lost { color: unseeable; }'); // browser throws this away after append styleEl.append(document.createTextNode('section { color: blue; }')); - styleEl.sheet?.insertRule('section.working { color: pink; }'); - expect(serializeNode(styleEl)).toMatchObject({ + expect(serializeNode(styleEl.childNodes[1])).toMatchObject({ + isStyle: true, rootId: undefined, - attributes: { - _cssText: - 'section.working {color: pink;}body {color: red;}/* rr_split */section {color: blue;}', - }, - type: 2, + textContent: 'section { color: blue; }', + type: 3, }); }); }); diff --git a/packages/rrweb/package.json b/packages/rrweb/package.json index 81011ef9..24e0258e 100644 --- a/packages/rrweb/package.json +++ b/packages/rrweb/package.json @@ -5,8 +5,7 @@ "scripts": { "prepare": "npm run prepack", "prepack": "npm run build", - "retest": "cross-env PUPPETEER_HEADLESS=true yarn retest:headful", - "retest:headful": "vitest run --exclude test/benchmark", + "retest": "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 3b281662..45945a03 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -292,26 +292,12 @@ export default class MutationBuffer { }; const pushAdd = (n: Node) => { const parent = dom.parentNode(n); - if (!parent || !inDom(n)) { + if (!parent || !inDom(n) || (parent as Element).tagName === 'TEXTAREA') { 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 128d2874..8b56988e 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -105,7 +105,7 @@ describe('record integration tests', function (this: ISuite) { await assertSnapshot(snapshots); }); - it('can record and replay textarea mutations correctly', async () => { + it('can record textarea mutations correctly', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); await page.setContent(getHtml.call(this, 'empty.html')); @@ -116,29 +116,20 @@ 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 waitForRAF(page); + await page.waitForTimeout(5); 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 waitForRAF(page); + await page.waitForTimeout(5); 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 waitForRAF(page); + await page.waitForTimeout(5); await page.type('textarea', '1'); // types (inserts) at index 0, in front of existing text - await waitForRAF(page); + await page.waitForTimeout(5); await page.evaluate(() => { const t = document.querySelector('textarea') as HTMLTextAreaElement; // user has typed so childNode content should now be ignored @@ -149,7 +140,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 waitForRAF(page); + await page.waitForTimeout(5); await page.type('textarea', '2'); // cursor is at index 1 const snapshots = (await page.evaluate( @@ -166,18 +157,12 @@ 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 @@ -185,131 +170,6 @@ 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'); @@ -1382,8 +1242,12 @@ describe('record integration tests', function (this: ISuite) { }); /** - * 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' + * 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. */ it('should record style mutations and replay them correctly', async () => { const page: puppeteer.Page = await browser.newPage(); @@ -1476,77 +1340,4 @@ 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 64d4ae11..697d3c8e 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: { - [key: string]: any; + src?: string; }; }) { - 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/'); - } + 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/'); } }