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, 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, + browserSheet.length, + ]); + } + }); + + 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: findCssTextSplits 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 + 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, + ]); + } + }); +}); + +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 fullCssText = halfCssText + otherHalfCssText; + 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, + fullCssText, + [halfCssText.length, fullCssText.length], + 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, + fullCssText, + [halfCssText.length, fullCssText.length], + 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, + fullCssText, + [halfCssText.length, fullCssText.length], + false, + mockLastUnusedArg, + ); + expect((sn1.childNodes[0] as textNode).textContent).toEqual(fullCssText); + }); + + it('ignores css splits correctly when there is a mismatch in length check', () => { + applyCssSplits(sn, fullCssText, [2, 3], false, mockLastUnusedArg); + expect((sn.childNodes[0] as textNode).textContent).toEqual(fullCssText); + expect((sn.childNodes[1] as textNode).textContent).toEqual(''); + }); + + it('ignores css splits correctly when we indicate a split is invalid with the zero marker', () => { + applyCssSplits( + sn, + fullCssText, + [0, fullCssText.length], + false, + mockLastUnusedArg, + ); + expect((sn.childNodes[0] as textNode).textContent).toEqual(fullCssText); + expect((sn.childNodes[1] as textNode).textContent).toEqual(''); + }); + + it('ignores css splits correctly with negative splits', () => { + applyCssSplits(sn, fullCssText, [-2, -4], false, mockLastUnusedArg); + expect((sn.childNodes[0] as textNode).textContent).toEqual(fullCssText); + expect((sn.childNodes[1] as textNode).textContent).toEqual(''); + }); + + it('ignores css splits correctly with out of order splits', () => { + applyCssSplits( + sn, + fullCssText, + [fullCssText.length * 2, fullCssText.length], + false, + mockLastUnusedArg, + ); + expect((sn.childNodes[0] as textNode).textContent).toEqual(fullCssText); + expect((sn.childNodes[1] as textNode).textContent).toEqual(''); + }); +}); diff --git a/packages/rrweb-snapshot/test/snapshot.test.ts b/packages/rrweb-snapshot/test/snapshot.test.ts index aa4bb428ee..54d058a6d9 100644 --- a/packages/rrweb-snapshot/test/snapshot.test.ts +++ b/packages/rrweb-snapshot/test/snapshot.test.ts @@ -161,22 +161,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;}section {color: blue;}', + }, + type: 2, }); }); }); diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 484a8a7ecb..387199c01d 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -282,6 +282,7 @@ function record( }); const stylesheetManager = new StylesheetManager({ + mirror, mutationCb: wrappedMutationEmit, adoptedStyleSheetCb: wrappedAdoptedStyleSheetEmit, }); @@ -385,7 +386,7 @@ function record( if (isSerializedIframe(n, mirror)) { iframeManager.addIframe(n as HTMLIFrameElement); } - if (isSerializedStylesheet(n, mirror)) { + if (inlineStylesheet && isSerializedStylesheet(n, mirror)) { stylesheetManager.trackLinkElement(n as HTMLLinkElement); } if (hasShadowRoot(n)) { @@ -396,9 +397,6 @@ function record( iframeManager.attachIframe(iframe, childSn); shadowDomManager.observeAttachShadow(iframe); }, - onStylesheetLoad: (linkEl, childSn) => { - stylesheetManager.attachLinkElement(linkEl, childSn); - }, keepIframeSrcFn, }); diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index f4267af340..2d3a634862 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -320,7 +320,10 @@ export default class MutationBuffer { if (isSerializedIframe(currentN, this.mirror)) { this.iframeManager.addIframe(currentN as HTMLIFrameElement); } - if (isSerializedStylesheet(currentN, this.mirror)) { + if ( + this.inlineStylesheet && + isSerializedStylesheet(currentN, this.mirror) + ) { this.stylesheetManager.trackLinkElement( currentN as HTMLLinkElement, ); @@ -333,9 +336,6 @@ export default class MutationBuffer { this.iframeManager.attachIframe(iframe, childSn); this.shadowDomManager.observeAttachShadow(iframe); }, - onStylesheetLoad: (link, childSn) => { - this.stylesheetManager.attachLinkElement(link, childSn); - }, }); if (sn) { adds.push({ @@ -663,6 +663,20 @@ export default class MutationBuffer { item.styleDiff[pname] = false; // delete } } + } else if ( + this.inlineStylesheet && + attributeName === 'rel' && + value.toLowerCase() === 'stylesheet' && + m.target.tagName === 'LINK' + ) { + if (m.target.sheet) { + console.warn( + 'have we missed the onload event due to delayed mutation?', + ); + } + this.stylesheetManager.trackLinkElement( + m.target as HTMLLinkElement, + ); } } break; diff --git a/packages/rrweb/src/record/stylesheet-manager.ts b/packages/rrweb/src/record/stylesheet-manager.ts index 6e0a8077b4..7a55ba342f 100644 --- a/packages/rrweb/src/record/stylesheet-manager.ts +++ b/packages/rrweb/src/record/stylesheet-manager.ts @@ -1,5 +1,10 @@ import type { elementNode, serializedNodeWithId } from 'rrweb-snapshot'; -import { stringifyRule } from 'rrweb-snapshot'; +import { + stringifyRule, + stringifyStylesheet, + absoluteToStylesheet, + getHref, +} from 'rrweb-snapshot'; import type { adoptedStyleSheetCallback, adoptedStyleSheetParam, @@ -13,39 +18,20 @@ export class StylesheetManager { private mutationCb: mutationCallBack; private adoptedStyleSheetCb: adoptedStyleSheetCallback; public styleMirror = new StyleSheetMirror(); + private mirror: Mirror; constructor(options: { + mirror: Mirror; mutationCb: mutationCallBack; adoptedStyleSheetCb: adoptedStyleSheetCallback; }) { this.mutationCb = options.mutationCb; this.adoptedStyleSheetCb = options.adoptedStyleSheetCb; - } - - public attachLinkElement( - linkEl: HTMLLinkElement, - childSn: serializedNodeWithId, - ) { - if ('_cssText' in (childSn as elementNode).attributes) - this.mutationCb({ - adds: [], - removes: [], - texts: [], - attributes: [ - { - id: childSn.id, - attributes: (childSn as elementNode) - .attributes as attributeMutation['attributes'], - }, - ], - }); - - this.trackLinkElement(linkEl); + this.mirror = options.mirror; } public trackLinkElement(linkEl: HTMLLinkElement) { if (this.trackedLinkElements.has(linkEl)) return; - this.trackedLinkElements.add(linkEl); this.trackStylesheetInLinkElement(linkEl); } @@ -80,10 +66,41 @@ export class StylesheetManager { this.trackedLinkElements = new WeakSet(); } - // TODO: take snapshot on stylesheet reload by applying event listener private trackStylesheetInLinkElement(linkEl: HTMLLinkElement) { - // linkEl.addEventListener('load', () => { - // // re-loaded, maybe take another snapshot? - // }); + // if is already loaded, it will have .sheet available and that + // will get serialized in the snapshot. The following is for when that doesn't happen + linkEl.addEventListener('load', () => { + console.log('load'); + if (!linkEl.sheet) { + return; + } + const id = this.mirror.getId(linkEl); + if (!id) { + // disappeared? should warn? + return; + } + let _cssText = stringifyStylesheet(linkEl.sheet); + if (_cssText) { + console.log('load emit'); + _cssText = absoluteToStylesheet( + _cssText, + getHref(linkEl.ownerDocument), + ); + // TODO: compare _cssText with previous emission + this.mutationCb({ + adds: [], + removes: [], + texts: [], + attributes: [ + { + id, + attributes: { + _cssText, + }, + }, + ], + }); + } + }); } } diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 89aa66d933..03696f385b 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -869,6 +869,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( diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index f349bd2669..411378d8eb 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -1034,6 +1034,585 @@ exports[`record integration tests can mask character data mutations with regexp ]" `; +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\\": { + \\"id\\": \\"dual-textContent\\", + \\"_cssText\\": \\"body { background-color: black; }body { color: orange !important; }\\", + \\"_cssTextSplits\\": \\"33 67\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\", + \\"id\\": 14 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\", + \\"id\\": 15 + } + ], + \\"id\\": 13 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 16 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 18 + } + ], + \\"id\\": 17 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 19 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"style\\", + \\"attributes\\": { + \\"id\\": \\"single-textContent\\", + \\"_cssText\\": \\"a:hover { outline: red solid 1px; }\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\", + \\"id\\": 21 + } + ], + \\"id\\": 20 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 22 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"style\\", + \\"attributes\\": { + \\"id\\": \\"empty\\", + \\"_cssText\\": \\"a:hover { outline: blue solid 1px; }\\" + }, + \\"childNodes\\": [], + \\"id\\": 23 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 24 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 26 + } + ], + \\"id\\": 25 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 27 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 28 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\", + \\"id\\": 30 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 32 + } + ], + \\"id\\": 31 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", + \\"id\\": 33 + } + ], + \\"id\\": 29 + } + ], + \\"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\\": \\".absolutify { background-image: url(\\\\\\"http://localhost:3030/rel\\\\\\"); }\\", + \\"id\\": 34 + } + }, + { + \\"parentId\\": 13, + \\"nextId\\": 34, + \\"node\\": { + \\"type\\": 3, + \\"textContent\\": \\"body { background-color: darkgreen; }\\", + \\"id\\": 35 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [ + { + \\"id\\": 15, + \\"value\\": \\"body { color: yellow; }\\" + }, + { + \\"id\\": 15, + \\"value\\": \\"body { color: yellow; }\\" + }, + { + \\"id\\": 35, + \\"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 and replay textarea mutations correctly 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\\": \\"Empty\\", + \\"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\\": \\"div\\", + \\"attributes\\": { + \\"id\\": \\"one\\" + }, + \\"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, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 14, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"textarea\\", + \\"attributes\\": { + \\"value\\": \\"pre value\\" + }, + \\"childNodes\\": [], + \\"id\\": 21 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 21, + \\"attributes\\": { + \\"value\\": \\"ok\\" + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 21, + \\"attributes\\": { + \\"value\\": \\"ok3\\" + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 5, + \\"id\\": 21 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 5, + \\"text\\": \\"1ok3\\", + \\"isChecked\\": false, + \\"id\\": 21 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 21, + \\"attributes\\": { + \\"value\\": \\"ignore\\" + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 5, + \\"text\\": \\"12ok3\\", + \\"isChecked\\": false, + \\"id\\": 21 + } + } +]" +`; + exports[`record integration tests can record attribute mutation 1`] = ` "[ { @@ -3556,255 +4135,27 @@ exports[`record integration tests can record node mutations 1`] = ` { \\"type\\": 3, \\"data\\": { - \\"source\\": 5, - \\"text\\": \\"\\", - \\"isChecked\\": false, - \\"id\\": 42 - } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 5, - \\"text\\": \\"\\", - \\"isChecked\\": false, - \\"id\\": 35 - } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 2, - \\"type\\": 0, - \\"id\\": 70 - } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 0, - \\"texts\\": [], - \\"attributes\\": [ - { - \\"id\\": 36, - \\"attributes\\": { - \\"style\\": { - \\"color\\": [ - \\"black\\", - \\"important\\" - ] - } - } - } - ], - \\"removes\\": [], - \\"adds\\": [] - } - } -]" -`; - -exports[`record integration tests can record style changes compactly and preserve css var() functions 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\\": 2, - \\"tagName\\": \\"html\\", - \\"attributes\\": {}, - \\"childNodes\\": [ - { - \\"type\\": 2, - \\"tagName\\": \\"head\\", - \\"attributes\\": {}, - \\"childNodes\\": [], - \\"id\\": 3 - }, - { - \\"type\\": 2, - \\"tagName\\": \\"body\\", - \\"attributes\\": {}, - \\"childNodes\\": [ - { - \\"type\\": 3, - \\"textContent\\": \\"\\\\n \\", - \\"id\\": 5 - }, - { - \\"type\\": 2, - \\"tagName\\": \\"script\\", - \\"attributes\\": {}, - \\"childNodes\\": [ - { - \\"type\\": 3, - \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", - \\"id\\": 7 - } - ], - \\"id\\": 6 - }, - { - \\"type\\": 3, - \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", - \\"id\\": 8 - } - ], - \\"id\\": 4 - } - ], - \\"id\\": 2 - } - ], - \\"compatMode\\": \\"BackCompat\\", - \\"id\\": 1 - }, - \\"initialOffset\\": { - \\"left\\": 0, - \\"top\\": 0 - } - } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 0, - \\"texts\\": [], - \\"attributes\\": [ - { - \\"id\\": 4, - \\"attributes\\": { - \\"style\\": \\"background: var(--mystery)\\" - } - } - ], - \\"removes\\": [], - \\"adds\\": [] - } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 0, - \\"texts\\": [], - \\"attributes\\": [ - { - \\"id\\": 4, - \\"attributes\\": { - \\"style\\": \\"background: var(--mystery); background-color: black\\" - } - } - ], - \\"removes\\": [], - \\"adds\\": [] - } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 0, - \\"texts\\": [], - \\"attributes\\": [ - { - \\"id\\": 4, - \\"attributes\\": { - \\"style\\": \\"\\" - } - } - ], - \\"removes\\": [], - \\"adds\\": [] - } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 0, - \\"texts\\": [], - \\"attributes\\": [ - { - \\"id\\": 4, - \\"attributes\\": { - \\"style\\": \\"display:block\\" - } - } - ], - \\"removes\\": [], - \\"adds\\": [] - } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 0, - \\"texts\\": [], - \\"attributes\\": [ - { - \\"id\\": 4, - \\"attributes\\": { - \\"style\\": { - \\"color\\": \\"var(--mystery-color)\\" - } - } - } - ], - \\"removes\\": [], - \\"adds\\": [] - } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 0, - \\"texts\\": [], - \\"attributes\\": [ - { - \\"id\\": 4, - \\"attributes\\": { - \\"style\\": \\"color:var(--mystery-color);display:block;margin:10px\\" - } - } - ], - \\"removes\\": [], - \\"adds\\": [] + \\"source\\": 5, + \\"text\\": \\"\\", + \\"isChecked\\": false, + \\"id\\": 42 } }, { \\"type\\": 3, \\"data\\": { - \\"source\\": 0, - \\"texts\\": [], - \\"attributes\\": [ - { - \\"id\\": 4, - \\"attributes\\": { - \\"style\\": { - \\"margin-left\\": \\"Npx\\" - } - } - } - ], - \\"removes\\": [], - \\"adds\\": [] + \\"source\\": 5, + \\"text\\": \\"\\", + \\"isChecked\\": false, + \\"id\\": 35 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 0, + \\"id\\": 70 } }, { @@ -3814,11 +4165,13 @@ exports[`record integration tests can record style changes compactly and preserv \\"texts\\": [], \\"attributes\\": [ { - \\"id\\": 4, + \\"id\\": 36, \\"attributes\\": { \\"style\\": { - \\"margin-top\\": \\"Npx\\", - \\"color\\": false + \\"color\\": [ + \\"black\\", + \\"important\\" + ] } } } @@ -3830,7 +4183,7 @@ exports[`record integration tests can record style changes compactly and preserv ]" `; -exports[`record integration tests can record textarea mutations correctly 1`] = ` +exports[`record integration tests can record style changes compactly and preserve css var() functions 1`] = ` "[ { \\"type\\": 0, @@ -3854,84 +4207,17 @@ exports[`record integration tests can record textarea mutations correctly 1`] = \\"node\\": { \\"type\\": 0, \\"childNodes\\": [ - { - \\"type\\": 1, - \\"name\\": \\"html\\", - \\"publicId\\": \\"\\", - \\"systemId\\": \\"\\", - \\"id\\": 2 - }, { \\"type\\": 2, \\"tagName\\": \\"html\\", - \\"attributes\\": { - \\"lang\\": \\"en\\" - }, + \\"attributes\\": {}, \\"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\\": \\"Empty\\", - \\"id\\": 11 - } - ], - \\"id\\": 10 - }, - { - \\"type\\": 3, - \\"textContent\\": \\"\\\\n \\", - \\"id\\": 12 - } - ], - \\"id\\": 4 - }, - { - \\"type\\": 3, - \\"textContent\\": \\"\\\\n \\", - \\"id\\": 13 + \\"childNodes\\": [], + \\"id\\": 3 }, { \\"type\\": 2, @@ -3941,21 +4227,7 @@ exports[`record integration tests can record textarea mutations correctly 1`] = { \\"type\\": 3, \\"textContent\\": \\"\\\\n \\", - \\"id\\": 15 - }, - { - \\"type\\": 2, - \\"tagName\\": \\"div\\", - \\"attributes\\": { - \\"id\\": \\"one\\" - }, - \\"childNodes\\": [], - \\"id\\": 16 - }, - { - \\"type\\": 3, - \\"textContent\\": \\"\\\\n \\\\n \\", - \\"id\\": 17 + \\"id\\": 5 }, { \\"type\\": 2, @@ -3965,23 +4237,24 @@ exports[`record integration tests can record textarea mutations correctly 1`] = { \\"type\\": 3, \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", - \\"id\\": 19 + \\"id\\": 7 } ], - \\"id\\": 18 + \\"id\\": 6 }, { \\"type\\": 3, \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", - \\"id\\": 20 + \\"id\\": 8 } ], - \\"id\\": 14 + \\"id\\": 4 } ], - \\"id\\": 3 + \\"id\\": 2 } ], + \\"compatMode\\": \\"BackCompat\\", \\"id\\": 1 }, \\"initialOffset\\": { @@ -3995,23 +4268,33 @@ exports[`record integration tests can record textarea mutations correctly 1`] = \\"data\\": { \\"source\\": 0, \\"texts\\": [], - \\"attributes\\": [], + \\"attributes\\": [ + { + \\"id\\": 4, + \\"attributes\\": { + \\"style\\": \\"background: var(--mystery)\\" + } + } + ], \\"removes\\": [], - \\"adds\\": [ + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ { - \\"parentId\\": 14, - \\"nextId\\": null, - \\"node\\": { - \\"type\\": 2, - \\"tagName\\": \\"textarea\\", - \\"attributes\\": { - \\"value\\": \\"pre value\\" - }, - \\"childNodes\\": [], - \\"id\\": 21 + \\"id\\": 4, + \\"attributes\\": { + \\"style\\": \\"background: var(--mystery); background-color: black\\" } } - ] + ], + \\"removes\\": [], + \\"adds\\": [] } }, { @@ -4021,9 +4304,9 @@ exports[`record integration tests can record textarea mutations correctly 1`] = \\"texts\\": [], \\"attributes\\": [ { - \\"id\\": 21, + \\"id\\": 4, \\"attributes\\": { - \\"value\\": \\"ok\\" + \\"style\\": \\"\\" } } ], @@ -4038,9 +4321,9 @@ exports[`record integration tests can record textarea mutations correctly 1`] = \\"texts\\": [], \\"attributes\\": [ { - \\"id\\": 21, + \\"id\\": 4, \\"attributes\\": { - \\"value\\": \\"ok3\\" + \\"style\\": \\"display:block\\" } } ], @@ -4051,18 +4334,37 @@ exports[`record integration tests can record textarea mutations correctly 1`] = { \\"type\\": 3, \\"data\\": { - \\"source\\": 2, - \\"type\\": 5, - \\"id\\": 21 + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 4, + \\"attributes\\": { + \\"style\\": { + \\"color\\": \\"var(--mystery-color)\\" + } + } + } + ], + \\"removes\\": [], + \\"adds\\": [] } }, { \\"type\\": 3, \\"data\\": { - \\"source\\": 5, - \\"text\\": \\"1ok3\\", - \\"isChecked\\": false, - \\"id\\": 21 + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 4, + \\"attributes\\": { + \\"style\\": \\"color:var(--mystery-color);display:block;margin:10px\\" + } + } + ], + \\"removes\\": [], + \\"adds\\": [] } }, { @@ -4072,9 +4374,11 @@ exports[`record integration tests can record textarea mutations correctly 1`] = \\"texts\\": [], \\"attributes\\": [ { - \\"id\\": 21, + \\"id\\": 4, \\"attributes\\": { - \\"value\\": \\"ignore\\" + \\"style\\": { + \\"margin-left\\": \\"Npx\\" + } } } ], @@ -4085,10 +4389,21 @@ exports[`record integration tests can record textarea mutations correctly 1`] = { \\"type\\": 3, \\"data\\": { - \\"source\\": 5, - \\"text\\": \\"12ok3\\", - \\"isChecked\\": false, - \\"id\\": 21 + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 4, + \\"attributes\\": { + \\"style\\": { + \\"margin-top\\": \\"Npx\\", + \\"color\\": false + } + } + } + ], + \\"removes\\": [], + \\"adds\\": [] } } ]" @@ -5216,12 +5531,13 @@ exports[`record integration tests mutations should work when blocked class is un { \\"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 } ], @@ -8112,12 +8428,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 } ], @@ -12160,7 +12477,6 @@ exports[`record integration tests should record dynamic CSS changes 1`] = ` { \\"type\\": 3, \\"textContent\\": \\"\\", - \\"isStyle\\": true, \\"id\\": 18 } ], @@ -15530,12 +15846,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 } ], @@ -15613,12 +15930,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 } ], diff --git a/packages/rrweb/test/__snapshots__/record.test.ts.snap b/packages/rrweb/test/__snapshots__/record.test.ts.snap index cb40f3328d..d7a7b9319b 100644 --- a/packages/rrweb/test/__snapshots__/record.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/record.test.ts.snap @@ -1386,18 +1386,19 @@ exports[`record captures inserted style text nodes correctly 1`] = ` { \\"type\\": 2, \\"tagName\\": \\"style\\", - \\"attributes\\": {}, + \\"attributes\\": { + \\"_cssText\\": \\"div { color: red; }section { color: blue; }\\", + \\"_cssTextSplits\\": \\"19 43\\" + }, \\"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 +1461,6 @@ exports[`record captures inserted style text nodes correctly 1`] = ` \\"node\\": { \\"type\\": 3, \\"textContent\\": \\"h1 { color: pink; }\\", - \\"isStyle\\": true, \\"id\\": 12 } }, @@ -1470,7 +1470,6 @@ exports[`record captures inserted style text nodes correctly 1`] = ` \\"node\\": { \\"type\\": 3, \\"textContent\\": \\"span { color: orange; }\\", - \\"isStyle\\": true, \\"id\\": 13 } } @@ -3255,6 +3254,204 @@ exports[`record loading stylesheets captures stylesheets in iframes that are sti ]" `; +exports[`record loading stylesheets captures stylesheets that are preloaded 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\\": { + \\"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\\": \\"Hello World!\\", + \\"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 Hello world!\\\\n \\\\n\\\\n\\", + \\"id\\": 17 + } + ], + \\"id\\": 16 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 4, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"link\\", + \\"attributes\\": { + \\"rel\\": \\"preload\\", + \\"as\\": \\"style\\", + \\"href\\": \\"http://localhost:3030/html/assets/style.css\\" + }, + \\"childNodes\\": [], + \\"id\\": 18 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 18, + \\"attributes\\": { + \\"rel\\": \\"stylesheet\\" + } + } + ], + \\"removes\\": [], + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [ + { + \\"id\\": 18, + \\"attributes\\": { + \\"_cssText\\": \\"body { color: pink; }\\" + } + } + ] + } + } +]" +`; + exports[`record loading stylesheets captures stylesheets that are still loading 1`] = ` "[ { @@ -3435,6 +3632,169 @@ exports[`record loading stylesheets captures stylesheets that are still loading ]" `; +exports[`record loading stylesheets respects inlineStylesheet=false for late loading stylesheets 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\\": { + \\"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\\": \\"Hello World!\\", + \\"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 Hello world!\\\\n \\\\n\\\\n\\", + \\"id\\": 17 + } + ], + \\"id\\": 16 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 4, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"link\\", + \\"attributes\\": { + \\"rel\\": \\"stylesheet\\", + \\"href\\": \\"http://localhost:3030/html/assets/style.css\\" + }, + \\"childNodes\\": [], + \\"id\\": 18 + } + } + ] + } + } +]" +`; + exports[`record no need for attribute mutations on adds 1`] = ` "[ { diff --git a/packages/rrweb/test/html/style.html b/packages/rrweb/test/html/style.html new file mode 100644 index 0000000000..b9c6975672 --- /dev/null +++ b/packages/rrweb/test/html/style.html @@ -0,0 +1,28 @@ + + + + + + style + + + + + + + + + diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 5ce9b76469..bc2c26bbc3 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) { 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')); @@ -170,6 +170,101 @@ 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'); + if (styleEl) { + styleEl.append( + document.createTextNode('body { background-color: darkgreen; }'), + ); + styleEl.append( + document.createTextNode( + '.absolutify { background-image: url("./rel"); }', + ), + ); + } + }); + await page.waitForTimeout(5); + await page.evaluate(() => { + let styleEl = document.querySelector('style'); + 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 page.waitForTimeout(5); + 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) + 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; +`); + + 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(255, 255, 0)', // yellow + }, + 'a:hover, a.\\:hover { outline: red solid 1px; }', // has run adaptCssForReplay + 'a:hover, a.\\:hover { outline: blue solid 1px; }', // has run adaptCssForReplay + ]); + }); + it('can record childList mutations', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 51e7ad2342..1250b7451d 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -769,6 +769,31 @@ describe('record', function (this: ISuite) { await server.close(); }); + it('captures stylesheets that are preloaded', async () => { + ctx.page.evaluate((serverURL) => { + 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', 'preload'); + link1.setAttribute('as', 'style'); + link1.setAttribute('href', `${serverURL}/html/assets/style.css`); + link1.addEventListener('load', () => { + link1.setAttribute('rel', 'stylesheet'); + }); + document.head.appendChild(link1); + }, serverURL); + + await ctx.page.waitForResponse(`${serverURL}/html/assets/style.css`); + await waitForRAF(ctx.page); + + assertSnapshot(ctx.events); + }); + it('captures stylesheets that are still loading', async () => { ctx.page.evaluate((serverURL) => { const { record } = (window as unknown as IWindow).rrweb; @@ -790,6 +815,27 @@ describe('record', function (this: ISuite) { assertSnapshot(ctx.events); }); + it('respects inlineStylesheet=false for late loading stylesheets', async () => { + ctx.page.evaluate((serverURL) => { + const { record } = (window as unknown as IWindow).rrweb; + + record({ + inlineStylesheet: false, + emit: (window as unknown as IWindow).emit, + }); + + const link1 = document.createElement('link'); + link1.setAttribute('rel', 'stylesheet'); + link1.setAttribute('href', `${serverURL}/html/assets/style.css`); + document.head.appendChild(link1); + }, serverURL); + + await ctx.page.waitForResponse(`${serverURL}/html/assets/style.css`); + await waitForRAF(ctx.page); + + assertSnapshot(ctx.events); + }); + it('captures stylesheets in iframes that are still loading', async () => { ctx.page.evaluate(() => { const iframe = document.createElement('iframe'); diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index 30a36abfcb..88e1b25792 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -242,18 +242,18 @@ 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/'); + } } }