Single style capture (#1437)

Support a contrived/rare case where a <style> element has multiple text node children (this is usually only possible to recreate via javascript append) ... this PR fixes cases where there are subsequent text mutations to these nodes; previously these would have been lost

* In this scenario, a new CSS comment may now be inserted into the captured `_cssText` for a <style> element to show where it should be broken up into text elements upon replay: `/* rr_split */`
* The new 'can record and replay style mutations' test is the principal way to the problematic scenarios, and is a detailed 'catch-all' test with many checks to cover most of the ways things can fail
* There are new tests for splitting/rebuilding the css using the rr_split marker
* The prior 'dynamic stylesheet' route is now the main route for serializing a stylesheet; dynamic stylesheet were missed out in #1533 but that case is now covered with this PR

This PR was originally extracted from #1475 so the  initial motivation was to change the approach on stringifying <style> elements to do so in a single place.  This is also the motivating factor for always serializing <style> elements via the `_cssText` attribute rather than in it's childNodes; in #1475 we will be delaying populating `_cssText` for performance and instead recorrding them as assets.

Thanks for the detailed review to  Justin Halsall <Juice10@users.noreply.github.com> & Yun Feng <https://github.com/YunFeng0817>
This commit is contained in:
Eoghan Murray
2026-04-01 12:00:00 +08:00
committed by GitHub
parent f8b3f4f8a2
commit 67657a8710
19 changed files with 1595 additions and 387 deletions

View File

@@ -0,0 +1,6 @@
---
"rrweb-snapshot": patch
"rrweb": patch
---
Edge case: Provide support for mutations on a <style> element which (unusually) has multiple text nodes

View File

@@ -34,6 +34,7 @@
"eslint-plugin-compat": "^5.0.0", "eslint-plugin-compat": "^5.0.0",
"eslint-plugin-jest": "^27.6.0", "eslint-plugin-jest": "^27.6.0",
"eslint-plugin-tsdoc": "^0.2.17", "eslint-plugin-tsdoc": "^0.2.17",
"happy-dom": "^14.12.0",
"markdownlint": "^0.25.1", "markdownlint": "^0.25.1",
"markdownlint-cli": "^0.31.1", "markdownlint-cli": "^0.31.1",
"prettier": "2.8.4", "prettier": "2.8.4",

View File

@@ -46,7 +46,6 @@
"@typescript-eslint/eslint-plugin": "^5.23.0", "@typescript-eslint/eslint-plugin": "^5.23.0",
"@typescript-eslint/parser": "^5.23.0", "@typescript-eslint/parser": "^5.23.0",
"eslint": "^8.15.0", "eslint": "^8.15.0",
"happy-dom": "^14.12.0",
"puppeteer": "^17.1.3", "puppeteer": "^17.1.3",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vite": "^5.3.1", "vite": "^5.3.1",

View File

@@ -1,6 +1,8 @@
import { mediaSelectorPlugin, pseudoClassPlugin } from './css'; import { mediaSelectorPlugin, pseudoClassPlugin } from './css';
import { import {
type serializedNodeWithId, type serializedNodeWithId,
type serializedElementNodeWithId,
type serializedTextNodeWithId,
NodeType, NodeType,
type tagMap, type tagMap,
type elementNode, type elementNode,
@@ -78,6 +80,77 @@ export function createCache(): BuildCache {
}; };
} }
/**
* undo splitCssText/markCssSplits
* (would move to utils.ts but uses `adaptCssForReplay`)
*/
export function applyCssSplits(
n: serializedElementNodeWithId,
cssText: string,
hackCss: boolean,
cache: BuildCache,
): void {
const childTextNodes: serializedTextNodeWithId[] = [];
for (const scn of n.childNodes) {
if (scn.type === NodeType.Text) {
childTextNodes.push(scn);
}
}
const cssTextSplits = cssText.split('/* rr_split */');
while (
cssTextSplits.length > 1 &&
cssTextSplits.length > childTextNodes.length
) {
// unexpected: remerge the last two so that we don't discard any css
cssTextSplits.splice(-2, 2, cssTextSplits.slice(-2).join(''));
}
for (let i = 0; i < childTextNodes.length; i++) {
const childTextNode = childTextNodes[i];
const cssTextSection = cssTextSplits[i];
if (childTextNode && cssTextSection) {
// id will be assigned when these child nodes are
// iterated over in buildNodeWithSN
childTextNode.textContent = hackCss
? adaptCssForReplay(cssTextSection, cache)
: cssTextSection;
}
}
}
/**
* Normally a <style> element has a single textNode containing the rules.
* During serialization, we bypass this (`styleEl.sheet`) to get the rules the
* browser sees and serialize this to a special _cssText attribute, blanking
* out any text nodes. This function reverses that and also handles cases where
* there were no textNode children present (dynamic css/or a <link> element) as
* well as multiple textNodes, which need to be repopulated (based on presence of
* a special `rr_split` marker in case they are modified by subsequent mutations.
*/
export function buildStyleNode(
n: serializedElementNodeWithId,
styleEl: HTMLStyleElement, // when inlined, a <link type="stylesheet"> also gets rebuilt as a <style>
cssText: string,
options: {
doc: Document;
hackCss: boolean;
cache: BuildCache;
},
) {
const { doc, hackCss, cache } = options;
if (n.childNodes.length) {
applyCssSplits(n, cssText, hackCss, cache);
} else {
if (hackCss) {
cssText = adaptCssForReplay(cssText, cache);
}
/**
<link> element or dynamic <style> are serialized without any child nodes
we create the text node without an ID or presence in mirror as it can't
*/
styleEl.appendChild(doc.createTextNode(cssText));
}
}
function buildNode( function buildNode(
n: serializedNodeWithId, n: serializedNodeWithId,
options: { options: {
@@ -154,14 +227,13 @@ function buildNode(
continue; continue;
} }
const isTextarea = tagName === 'textarea' && name === 'value'; if (typeof value !== 'string') {
const isRemoteOrDynamicCss = tagName === 'style' && name === '_cssText'; // pass
if (isRemoteOrDynamicCss && hackCss && typeof value === 'string') { } else if (tagName === 'style' && name === '_cssText') {
value = adaptCssForReplay(value, cache); buildStyleNode(n, node as HTMLStyleElement, value, options);
} continue; // no need to set _cssText as attribute
if ((isTextarea || isRemoteOrDynamicCss) && typeof value === 'string') { } else if (tagName === 'textarea' && name === 'value') {
// https://github.com/rrweb-io/rrweb/issues/112 // create without an ID or presence in mirror
// https://github.com/rrweb-io/rrweb/pull/1351
node.appendChild(doc.createTextNode(value)); node.appendChild(doc.createTextNode(value));
n.childNodes = []; // value overrides childNodes n.childNodes = []; // value overrides childNodes
continue; continue;
@@ -317,11 +389,11 @@ function buildNode(
return node; return node;
} }
case NodeType.Text: case NodeType.Text:
return doc.createTextNode( if (n.isStyle && hackCss) {
n.isStyle && hackCss // support legacy style
? adaptCssForReplay(n.textContent, cache) return doc.createTextNode(adaptCssForReplay(n.textContent, cache));
: n.textContent, }
); return doc.createTextNode(n.textContent);
case NodeType.CDATA: case NodeType.CDATA:
return doc.createCDATASection(n.textContent); return doc.createCDATASection(n.textContent);
case NodeType.Comment: case NodeType.Comment:

View File

@@ -27,6 +27,7 @@ import {
toLowerCase, toLowerCase,
extractFileExtension, extractFileExtension,
absolutifyURLs, absolutifyURLs,
markCssSplits,
} from './utils'; } from './utils';
import dom from '@rrweb/utils'; import dom from '@rrweb/utils';
@@ -403,6 +404,7 @@ function serializeNode(
* `newlyAddedElement: true` skips scrollTop and scrollLeft check * `newlyAddedElement: true` skips scrollTop and scrollLeft check
*/ */
newlyAddedElement?: boolean; newlyAddedElement?: boolean;
cssCaptured?: boolean;
}, },
): serializedNode | false { ): serializedNode | false {
const { const {
@@ -420,6 +422,7 @@ function serializeNode(
recordCanvas, recordCanvas,
keepIframeSrcFn, keepIframeSrcFn,
newlyAddedElement = false, newlyAddedElement = false,
cssCaptured = false,
} = options; } = options;
// Only record root id when document object is not the base document // Only record root id when document object is not the base document
const rootId = getRootId(doc, mirror); const rootId = getRootId(doc, mirror);
@@ -466,6 +469,7 @@ function serializeNode(
needsMask, needsMask,
maskTextFn, maskTextFn,
rootId, rootId,
cssCaptured,
}); });
case n.CDATA_SECTION_NODE: case n.CDATA_SECTION_NODE:
return { return {
@@ -497,48 +501,38 @@ function serializeTextNode(
needsMask: boolean; needsMask: boolean;
maskTextFn: MaskTextFn | undefined; maskTextFn: MaskTextFn | undefined;
rootId: number | undefined; rootId: number | undefined;
cssCaptured?: boolean;
}, },
): serializedNode { ): serializedNode {
const { needsMask, maskTextFn, rootId } = options; const { needsMask, maskTextFn, rootId, cssCaptured } = options;
// The parent node may not be a html element which has a tagName attribute. // The parent node may not be a html element which has a tagName attribute.
// So just let it be undefined which is ok in this use case. // So just let it be undefined which is ok in this use case.
const parent = dom.parentNode(n); const parent = dom.parentNode(n);
const parentTagName = parent && (parent as HTMLElement).tagName; const parentTagName = parent && (parent as HTMLElement).tagName;
let text = dom.textContent(n); let textContent: string | null = '';
const isStyle = parentTagName === 'STYLE' ? true : undefined; const isStyle = parentTagName === 'STYLE' ? true : undefined;
const isScript = parentTagName === 'SCRIPT' ? true : undefined; const isScript = parentTagName === 'SCRIPT' ? true : undefined;
if (isStyle && text) {
try {
// try to read style sheet
if (n.nextSibling || n.previousSibling) {
// This is not the only child of the stylesheet.
// We can't read all of the sheet's .cssRules and expect them
// to _only_ include the current rule(s) added by the text node.
// So we'll be conservative and keep textContent as-is.
} else if ((parent as HTMLStyleElement).sheet?.cssRules) {
text = stringifyStylesheet((parent as HTMLStyleElement).sheet!);
}
} catch (err) {
console.warn(
`Cannot get CSS styles from text's parentNode. Error: ${err as string}`,
n,
);
}
text = absolutifyURLs(text, getHref(options.doc));
}
if (isScript) { if (isScript) {
text = 'SCRIPT_PLACEHOLDER'; textContent = 'SCRIPT_PLACEHOLDER';
} else if (!cssCaptured) {
textContent = dom.textContent(n);
if (isStyle && textContent) {
// mutation only: we don't need to use stringifyStylesheet
// as a <style> text node mutation obliterates any previous
// programmatic rule manipulation (.insertRule etc.)
// so the current textContent represents the most up to date state
textContent = absolutifyURLs(textContent, getHref(options.doc));
}
} }
if (!isStyle && !isScript && text && needsMask) { if (!isStyle && !isScript && textContent && needsMask) {
text = maskTextFn textContent = maskTextFn
? maskTextFn(text, dom.parentElement(n)) ? maskTextFn(textContent, dom.parentElement(n))
: text.replace(/[\S]/g, '*'); : textContent.replace(/[\S]/g, '*');
} }
return { return {
type: NodeType.Text, type: NodeType.Text,
textContent: text || '', textContent: textContent || '',
isStyle,
rootId, rootId,
}; };
} }
@@ -608,17 +602,14 @@ function serializeElementNode(
attributes._cssText = cssText; attributes._cssText = cssText;
} }
} }
// dynamic stylesheet if (tagName === 'style' && (n as HTMLStyleElement).sheet) {
if ( let cssText = stringifyStylesheet(
tagName === 'style' &&
(n as HTMLStyleElement).sheet &&
// TODO: Currently we only try to get dynamic stylesheet when it is an empty style element
!(n.innerText || dom.textContent(n) || '').trim().length
) {
const cssText = stringifyStylesheet(
(n as HTMLStyleElement).sheet as CSSStyleSheet, (n as HTMLStyleElement).sheet as CSSStyleSheet,
); );
if (cssText) { if (cssText) {
if (n.childNodes.length > 1) {
cssText = markCssSplits(cssText, n as HTMLStyleElement);
}
attributes._cssText = cssText; attributes._cssText = cssText;
} }
} }
@@ -937,6 +928,7 @@ export function serializeNodeWithId(
node: serializedElementNodeWithId, node: serializedElementNodeWithId,
) => unknown; ) => unknown;
stylesheetLoadTimeout?: number; stylesheetLoadTimeout?: number;
cssCaptured?: boolean;
}, },
): serializedNodeWithId | null { ): serializedNodeWithId | null {
const { const {
@@ -962,6 +954,7 @@ export function serializeNodeWithId(
stylesheetLoadTimeout = 5000, stylesheetLoadTimeout = 5000,
keepIframeSrcFn = () => false, keepIframeSrcFn = () => false,
newlyAddedElement = false, newlyAddedElement = false,
cssCaptured = false,
} = options; } = options;
let { needsMask } = options; let { needsMask } = options;
let { preserveWhiteSpace = true } = options; let { preserveWhiteSpace = true } = options;
@@ -992,6 +985,7 @@ export function serializeNodeWithId(
recordCanvas, recordCanvas,
keepIframeSrcFn, keepIframeSrcFn,
newlyAddedElement, newlyAddedElement,
cssCaptured,
}); });
if (!_serializedNode) { if (!_serializedNode) {
// TODO: dev only // TODO: dev only
@@ -1007,7 +1001,6 @@ export function serializeNodeWithId(
slimDOMExcluded(_serializedNode, slimDOMOptions) || slimDOMExcluded(_serializedNode, slimDOMOptions) ||
(!preserveWhiteSpace && (!preserveWhiteSpace &&
_serializedNode.type === NodeType.Text && _serializedNode.type === NodeType.Text &&
!_serializedNode.isStyle &&
!_serializedNode.textContent.replace(/^\s+|\s+$/gm, '').length) !_serializedNode.textContent.replace(/^\s+|\s+$/gm, '').length)
) { ) {
id = IGNORED_NODE; id = IGNORED_NODE;
@@ -1072,6 +1065,7 @@ export function serializeNodeWithId(
onStylesheetLoad, onStylesheetLoad,
stylesheetLoadTimeout, stylesheetLoadTimeout,
keepIframeSrcFn, keepIframeSrcFn,
cssCaptured: false,
}; };
if ( if (
@@ -1081,6 +1075,13 @@ export function serializeNodeWithId(
) { ) {
// value parameter in DOM reflects the correct value, so ignore childNode // value parameter in DOM reflects the correct value, so ignore childNode
} else { } else {
if (
serializedNode.type === NodeType.Element &&
(serializedNode as elementNode).attributes._cssText !== undefined &&
typeof serializedNode.attributes._cssText === 'string'
) {
bypassOptions.cssCaptured = true;
}
for (const childN of Array.from(dom.childNodes(n))) { for (const childN of Array.from(dom.childNodes(n))) {
const serializedChildNode = serializeNodeWithId(childN, bypassOptions); const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
if (serializedChildNode) { if (serializedChildNode) {

View File

@@ -20,9 +20,18 @@ export type documentTypeNode = {
systemId: string; systemId: string;
}; };
export type attributes = { type cssTextKeyAttr = {
[key: string]: string | number | true | null; _cssText?: string;
}; };
export type attributes = cssTextKeyAttr & {
[key: string]:
| string
| number // properties e.g. rr_scrollLeft or rr_mediaCurrentTime
| true // e.g. checked on <input type="radio">
| null; // an indication that an attribute was removed (during a mutation)
};
export type legacyAttributes = { export type legacyAttributes = {
/** /**
* @deprecated old bug in rrweb was causing these to always be set * @deprecated old bug in rrweb was causing these to always be set
@@ -45,6 +54,10 @@ export type elementNode = {
export type textNode = { export type textNode = {
type: NodeType.Text; type: NodeType.Text;
textContent: string; textContent: string;
/**
* @deprecated styles are now always snapshotted against parent <style> element
* style mutations can still happen via an added textNode, but they don't need this attribute for correct replay
*/
isStyle?: true; isStyle?: true;
}; };
@@ -78,6 +91,11 @@ export type serializedElementNodeWithId = Extract<
Record<'type', NodeType.Element> Record<'type', NodeType.Element>
>; >;
export type serializedTextNodeWithId = Extract<
serializedNodeWithId,
Record<'type', NodeType.Text>
>;
export type tagMap = { export type tagMap = {
[key: string]: string; [key: string]: string;
}; };

View File

@@ -99,14 +99,28 @@ export function escapeImportStatement(rule: CSSImportRule): string {
return statement.join(' ') + ';'; return statement.join(' ') + ';';
} }
/*
* serialize the css rules from the .sheet property
* for <link rel="stylesheet"> elements, this is the only way of getting the rules without a FETCH
* for <style> elements, this is less preferable to looking at childNodes[0].textContent
* (which will include vendor prefixed rules which may not be used or visible to the recorded browser,
* but which might be needed by the replayer browser)
* however, at snapshot time, we don't know whether the style element has suffered
* any programmatic manipulation prior to the snapshot, in which case the .sheet would be more up to date
*/
export function stringifyStylesheet(s: CSSStyleSheet): string | null { export function stringifyStylesheet(s: CSSStyleSheet): string | null {
try { try {
const rules = s.rules || s.cssRules; const rules = s.rules || s.cssRules;
if (!rules) { if (!rules) {
return null; return null;
} }
let sheetHref = s.href;
if (!sheetHref && s.ownerNode && s.ownerNode.ownerDocument) {
// an inline <style> element
sheetHref = s.ownerNode.ownerDocument.location.href;
}
const stringifiedRules = Array.from(rules, (rule: CSSRule) => const stringifiedRules = Array.from(rules, (rule: CSSRule) =>
stringifyRule(rule, s.href), stringifyRule(rule, sheetHref),
).join(''); ).join('');
return fixBrowserCompatibilityIssuesInCSS(stringifiedRules); return fixBrowserCompatibilityIssuesInCSS(stringifiedRules);
} catch (error) { } catch (error) {
@@ -428,3 +442,62 @@ export function absolutifyURLs(cssText: string | null, href: string): string {
}, },
); );
} }
/**
* 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, '');
}
/**
* Maps the output of stringifyStylesheet to individual text nodes of a <style> element
* performance is not considered as this is anticipated to be very much an edge case
* (javascript is needed to add extra text nodes to a <style>)
*/
export function splitCssText(
cssText: string,
style: HTMLStyleElement,
): string[] {
const childNodes = Array.from(style.childNodes);
const splits: string[] = [];
if (childNodes.length > 1 && cssText && typeof cssText === 'string') {
const cssTextNorm = normalizeCssString(cssText);
for (let i = 1; i < childNodes.length; i++) {
if (
childNodes[i].textContent &&
typeof childNodes[i].textContent === 'string'
) {
const textContentNorm = normalizeCssString(childNodes[i].textContent!);
for (let j = 3; j < textContentNorm.length; j++) {
// find a substring that appears only once
const bit = textContentNorm.substring(0, j);
if (cssTextNorm.split(bit).length === 2) {
const splitNorm = cssTextNorm.indexOf(bit);
// find the split point in the original text
for (let k = splitNorm; k < cssText.length; k++) {
if (
normalizeCssString(cssText.substring(0, k)).length === splitNorm
) {
splits.push(cssText.substring(0, k));
cssText = cssText.substring(k);
break;
}
}
break;
}
}
}
}
}
splits.push(cssText); // either the full thing if no splits were found, or the last split
return splits;
}
export function markCssSplits(
cssText: string,
style: HTMLStyleElement,
): string {
return splitCssText(cssText, style).join('/* rr_split */');
}

View File

@@ -1094,12 +1094,13 @@ exports[`shadow DOM integration tests > snapshot shadow DOM 1`] = `
{ {
\\"type\\": 2, \\"type\\": 2,
\\"tagName\\": \\"style\\", \\"tagName\\": \\"style\\",
\\"attributes\\": {}, \\"attributes\\": {
\\"_cssText\\": \\":host { display: inline-block; width: 650px; font-family: \\\\\\"Roboto Slab\\\\\\"; contain: content; }:host([background]) { background: var(--background-color, #9E9E9E); border-radius: 10px; padding: 10px; }#panels { box-shadow: rgba(0, 0, 0, 0.3) 0px 2px 2px; background: white; border-radius: 3px; padding: 16px; height: 250px; overflow: auto; }#tabs { display: inline-flex; user-select: none; }#tabs slot { display: inline-flex; }#tabs ::slotted(*) { font: 400 16px / 22px Roboto; padding: 16px 8px; margin: 0px; text-align: center; width: 100px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; cursor: pointer; border-top-left-radius: 3px; border-top-right-radius: 3px; background: linear-gradient(rgb(250, 250, 250), rgb(238, 238, 238)); border: none; }#tabs ::slotted([aria-selected=\\\\\\"true\\\\\\"]) { font-weight: 600; background: white; box-shadow: none; }#tabs ::slotted(:focus) { z-index: 1; }#panels ::slotted([aria-hidden=\\\\\\"true\\\\\\"]) { display: none; }\\"
},
\\"childNodes\\": [ \\"childNodes\\": [
{ {
\\"type\\": 3, \\"type\\": 3,
\\"textContent\\": \\":host { display: inline-block; width: 650px; font-family: \\\\\\"Roboto Slab\\\\\\"; contain: content; }:host([background]) { background: var(--background-color, #9E9E9E); border-radius: 10px; padding: 10px; }#panels { box-shadow: rgba(0, 0, 0, 0.3) 0px 2px 2px; background: white; border-radius: 3px; padding: 16px; height: 250px; overflow: auto; }#tabs { display: inline-flex; user-select: none; }#tabs slot { display: inline-flex; }#tabs ::slotted(*) { font: 400 16px / 22px Roboto; padding: 16px 8px; margin: 0px; text-align: center; width: 100px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; cursor: pointer; border-top-left-radius: 3px; border-top-right-radius: 3px; background: linear-gradient(rgb(250, 250, 250), rgb(238, 238, 238)); border: none; }#tabs ::slotted([aria-selected=\\\\\\"true\\\\\\"]) { font-weight: 600; background: white; box-shadow: none; }#tabs ::slotted(:focus) { z-index: 1; }#panels ::slotted([aria-hidden=\\\\\\"true\\\\\\"]) { display: none; }\\", \\"textContent\\": \\"\\",
\\"isStyle\\": true,
\\"id\\": 38 \\"id\\": 38
} }
], ],

View File

@@ -1,6 +1,19 @@
import { describe, it, expect } from 'vitest'; /**
* @vitest-environment jsdom
*/
import { describe, it, beforeEach, expect } from 'vitest';
import { mediaSelectorPlugin, pseudoClassPlugin } from '../src/css'; import { mediaSelectorPlugin, pseudoClassPlugin } from '../src/css';
import postcss, { AcceptedPlugin } from 'postcss'; import postcss, { type AcceptedPlugin } from 'postcss';
import { JSDOM } from 'jsdom';
import { splitCssText, stringifyStylesheet } from './../src/utils';
import { applyCssSplits } from './../src/rebuild';
import {
NodeType,
type serializedElementNodeWithId,
type BuildCache,
type textNode,
} from '../src/types';
import { Window } from 'happy-dom';
describe('css parser', () => { describe('css parser', () => {
function parse(plugin: AcceptedPlugin, input: string): string { function parse(plugin: AcceptedPlugin, input: string): string {
@@ -73,3 +86,157 @@ li[attr="has,comma"] a.\\:hover {background: red;}`,
}); });
}); });
}); });
describe('css splitter', () => {
it('finds css textElement splits correctly', () => {
const window = new Window({ url: 'https://localhost:8080' });
const document = window.document;
document.head.innerHTML = '<style>.a{background-color:red;}</style>';
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 =
'<style>.a{color:red}.b{color:blue} /* author comment */</style>';
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(`<style></style>`).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,
);
});
});

View File

@@ -10,7 +10,7 @@ import {
createCache, createCache,
} from '../src/rebuild'; } from '../src/rebuild';
import { NodeType } from '../src/types'; import { NodeType } from '../src/types';
import { createMirror, Mirror } from '../src/utils'; import { createMirror, Mirror, normalizeCssString } from '../src/utils';
const expect = _expect as unknown as { const expect = _expect as unknown as {
<T = unknown>(actual: T): { <T = unknown>(actual: T): {
@@ -20,7 +20,7 @@ const expect = _expect as unknown as {
expect.extend({ expect.extend({
toMatchCss: function (received: string, expected: string) { toMatchCss: function (received: string, expected: string) {
const pass = normCss(received) === normCss(expected); const pass = normalizeCssString(received) === normalizeCssString(expected);
const message: () => string = () => const message: () => string = () =>
pass pass
? '' ? ''
@@ -32,10 +32,6 @@ expect.extend({
}, },
}); });
function normCss(cssText: string): string {
return cssText.replace(/[\s;]/g, '');
}
function getDuration(hrtime: [number, number]) { function getDuration(hrtime: [number, number]) {
const [seconds, nanoseconds] = hrtime; const [seconds, nanoseconds] = hrtime;
return seconds * 1000 + nanoseconds / 1000000; return seconds * 1000 + nanoseconds / 1000000;

View File

@@ -162,22 +162,27 @@ describe('style elements', () => {
it('should serialize all rules of stylesheet when the sheet has a single child node', () => { it('should serialize all rules of stylesheet when the sheet has a single child node', () => {
const styleEl = render(`<style>body { color: red; }</style>`); const styleEl = render(`<style>body { color: red; }</style>`);
styleEl.sheet?.insertRule('section { color: blue; }'); styleEl.sheet?.insertRule('section { color: blue; }');
expect(serializeNode(styleEl.childNodes[0])).toMatchObject({ expect(serializeNode(styleEl)).toMatchObject({
isStyle: true,
rootId: undefined, rootId: undefined,
textContent: 'section {color: blue;}body {color: red;}', attributes: {
type: 3, _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(`<style>body { color: red; }</style>`); const styleEl = render(`<style>body { color: red; }</style>`);
styleEl.sheet?.insertRule('section.lost { color: unseeable; }'); // browser throws this away after append
styleEl.append(document.createTextNode('section { color: blue; }')); styleEl.append(document.createTextNode('section { color: blue; }'));
expect(serializeNode(styleEl.childNodes[1])).toMatchObject({ styleEl.sheet?.insertRule('section.working { color: pink; }');
isStyle: true, expect(serializeNode(styleEl)).toMatchObject({
rootId: undefined, rootId: undefined,
textContent: 'section { color: blue; }', attributes: {
type: 3, _cssText:
'section.working {color: pink;}body {color: red;}/* rr_split */section {color: blue;}',
},
type: 2,
}); });
}); });
}); });

View File

@@ -5,7 +5,8 @@
"scripts": { "scripts": {
"prepare": "npm run prepack", "prepare": "npm run prepack",
"prepack": "npm run build", "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", "build-and-test": "yarn build && yarn retest",
"test:headless": "cross-env PUPPETEER_HEADLESS=true yarn build-and-test", "test:headless": "cross-env PUPPETEER_HEADLESS=true yarn build-and-test",
"test:headful": "cross-env PUPPETEER_HEADLESS=false yarn build-and-test", "test:headful": "cross-env PUPPETEER_HEADLESS=false yarn build-and-test",

View File

@@ -287,12 +287,26 @@ export default class MutationBuffer {
}; };
const pushAdd = (n: Node) => { const pushAdd = (n: Node) => {
const parent = dom.parentNode(n); const parent = dom.parentNode(n);
if (!parent || !inDom(n) || (parent as Element).tagName === 'TEXTAREA') { if (!parent || !inDom(n)) {
return; 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 <style> element
cssCaptured = true;
}
}
const parentId = isShadowRoot(parent) const parentId = isShadowRoot(parent)
? this.mirror.getId(getShadowHost(n)) ? this.mirror.getId(getShadowHost(n))
: this.mirror.getId(parent); : this.mirror.getId(parent);
const nextId = getNextId(n); const nextId = getNextId(n);
if (parentId === -1 || nextId === -1) { if (parentId === -1 || nextId === -1) {
return addList.addNode(n); return addList.addNode(n);
@@ -335,6 +349,7 @@ export default class MutationBuffer {
onStylesheetLoad: (link, childSn) => { onStylesheetLoad: (link, childSn) => {
this.stylesheetManager.attachLinkElement(link, childSn); this.stylesheetManager.attachLinkElement(link, childSn);
}, },
cssCaptured,
}); });
if (sn) { if (sn) {
adds.push({ adds.push({

View File

@@ -1,5 +1,6 @@
import { import {
rebuild, rebuild,
adaptCssForReplay,
buildNodeWithSN, buildNodeWithSN,
NodeType, NodeType,
type BuildCache, type BuildCache,
@@ -881,6 +882,9 @@ export class Replayer {
'html.rrweb-paused *, html.rrweb-paused *:before, html.rrweb-paused *:after { animation-play-state: paused !important; }', 'html.rrweb-paused *, html.rrweb-paused *:before, html.rrweb-paused *:after { animation-play-state: paused !important; }',
); );
} }
if (!injectStylesRules.length) {
return;
}
if (this.usingVirtualDom) { if (this.usingVirtualDom) {
const styleEl = this.virtualDom.createElement('style'); const styleEl = this.virtualDom.createElement('style');
this.virtualDom.mirror.add( this.virtualDom.mirror.add(
@@ -1743,7 +1747,14 @@ export class Replayer {
} }
return this.warnNodeNotFound(d, mutation.id); return this.warnNodeNotFound(d, mutation.id);
} }
target.textContent = mutation.value;
const parentEl = target.parentElement as Element | RRElement;
if (mutation.value && parentEl && parentEl.tagName === 'STYLE') {
// assumes hackCss: true (which isn't currently configurable from rrweb)
target.textContent = adaptCssForReplay(mutation.value, this.cache);
} else {
target.textContent = mutation.value;
}
/** /**
* https://github.com/rrweb-io/rrweb/pull/865 * https://github.com/rrweb-io/rrweb/pull/865

File diff suppressed because it is too large Load Diff

View File

@@ -1386,18 +1386,18 @@ exports[`record > captures inserted style text nodes correctly 1`] = `
{ {
\\"type\\": 2, \\"type\\": 2,
\\"tagName\\": \\"style\\", \\"tagName\\": \\"style\\",
\\"attributes\\": {}, \\"attributes\\": {
\\"_cssText\\": \\"div { color: red; }/* rr_split */section { color: blue; }\\"
},
\\"childNodes\\": [ \\"childNodes\\": [
{ {
\\"type\\": 3, \\"type\\": 3,
\\"textContent\\": \\"div { color: red; }\\", \\"textContent\\": \\"\\",
\\"isStyle\\": true,
\\"id\\": 6 \\"id\\": 6
}, },
{ {
\\"type\\": 3, \\"type\\": 3,
\\"textContent\\": \\"section { color: blue; }\\", \\"textContent\\": \\"\\",
\\"isStyle\\": true,
\\"id\\": 7 \\"id\\": 7
} }
], ],
@@ -1460,7 +1460,6 @@ exports[`record > captures inserted style text nodes correctly 1`] = `
\\"node\\": { \\"node\\": {
\\"type\\": 3, \\"type\\": 3,
\\"textContent\\": \\"h1 { color: pink; }\\", \\"textContent\\": \\"h1 { color: pink; }\\",
\\"isStyle\\": true,
\\"id\\": 12 \\"id\\": 12
} }
}, },
@@ -1470,7 +1469,6 @@ exports[`record > captures inserted style text nodes correctly 1`] = `
\\"node\\": { \\"node\\": {
\\"type\\": 3, \\"type\\": 3,
\\"textContent\\": \\"span { color: orange; }\\", \\"textContent\\": \\"span { color: orange; }\\",
\\"isStyle\\": true,
\\"id\\": 13 \\"id\\": 13
} }
} }

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>style</title>
<style id="dual-textContent">
body { background-color: black; }
</style>
<script>
// not the same from the POV of the DOM of just sticking this text in above
document.querySelector('style').append(
document.createTextNode('body { color: orange !important; }')
);
</script>
<style id="single-textContent">
a:hover { outline: 1px solid red; }
</style>
<style id="empty"></style>
<script>
// this simulates how <link> is stringified
let empty = document.getElementById('empty');
empty.sheet.insertRule('a:hover { outline: 1px solid blue; }');
</script>
<style id="hover-mutation">
/* replaceme */
</style>
</head>
<body>
</body>
</html>

View File

@@ -101,7 +101,7 @@ describe('record integration tests', function (this: ISuite) {
await assertSnapshot(snapshots); 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(); const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank'); await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'empty.html')); await page.setContent(getHtml.call(this, 'empty.html'));
@@ -112,20 +112,29 @@ describe('record integration tests', function (this: ISuite) {
const ta = document.createElement('textarea'); const ta = document.createElement('textarea');
ta.innerText = 'pre value'; ta.innerText = 'pre value';
document.body.append(ta); 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(() => { await page.evaluate(() => {
const t = document.querySelector('textarea') as HTMLTextAreaElement; const t = document.querySelector('textarea') as HTMLTextAreaElement;
t.innerText = 'ok'; // this mutation should be recorded 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(() => { await page.evaluate(() => {
const t = document.querySelector('textarea') as HTMLTextAreaElement; const t = document.querySelector('textarea') as HTMLTextAreaElement;
(t.childNodes[0] as Text).appendData('3'); // this mutation is also valid (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.type('textarea', '1'); // types (inserts) at index 0, in front of existing text
await page.waitForTimeout(5); await waitForRAF(page);
await page.evaluate(() => { await page.evaluate(() => {
const t = document.querySelector('textarea') as HTMLTextAreaElement; const t = document.querySelector('textarea') as HTMLTextAreaElement;
// user has typed so childNode content should now be ignored // 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 // 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 // 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 await page.type('textarea', '2'); // cursor is at index 1
const snapshots = (await page.evaluate( 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); replayer.pause((e.timestamp - window.snapshots[0].timestamp)+1);
let ts = replayer.iframe.contentDocument.querySelector('textarea'); let ts = replayer.iframe.contentDocument.querySelector('textarea');
vals.push((e.data.source === 0 ? 'Mutation' : 'User') + ':' + ts.value); 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; vals;
`); `);
expect(replayTextareaValues).toEqual([ expect(replayTextareaValues).toEqual([
'Mutation:pre value', 'Mutation:pre value',
'ta2:',
'Mutation:ok', 'Mutation:ok',
'ta2:added',
'Mutation:ok3', 'Mutation:ok3',
'User:1ok3', 'User:1ok3',
'Mutation:1ok3', // if this gets set to 'ignore', it's an error, as the 'user' has modified the textarea '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 () => { it('can record childList mutations', async () => {
const page: puppeteer.Page = await browser.newPage(); const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank'); await page.goto('about:blank');
@@ -1238,12 +1378,8 @@ describe('record integration tests', function (this: ISuite) {
}); });
/** /**
* https://github.com/rrweb-io/rrweb/pull/1417 * the regression part of the following is now handled by replayer.test.ts::'can deal with duplicate/conflicting values on style elements'
* This test is to make sure that this problem doesn't regress * so this test could be dropped if we add more robust mixing of `insertRule` into 'can record and replay style mutations'
* 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 () => { it('should record style mutations and replay them correctly', async () => {
const page: puppeteer.Page = await browser.newPage(); const page: puppeteer.Page = await browser.newPage();
@@ -1336,4 +1472,77 @@ describe('record integration tests', function (this: ISuite) {
expect(changedColors).toEqual([NewColor, NewColor]); expect(changedColors).toEqual([NewColor, NewColor]);
await page.close(); 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(
`
<!DOCTYPE html><html lang="en">
<head>
<style>
/* hello */
</style>
</head>
<body>
<div id="one"></div>
<div id="two"></div>
<script>
document.querySelector("style").append(document.createTextNode("/* world */"));
document.querySelector("style").sheet.insertRule('#one { color: ${Color}; }', 0);
</script>
</body></html>
`,
);
// 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();
});
}); });

View File

@@ -250,18 +250,18 @@ export function stringifySnapshots(snapshots: eventWithTime[]): string {
function stripBlobURLsFromAttributes(node: { function stripBlobURLsFromAttributes(node: {
attributes: { attributes: {
src?: string; [key: string]: any;
}; };
}) { }) {
if ( for (const attr in node.attributes) {
'src' in node.attributes && if (
node.attributes.src && typeof node.attributes[attr] === 'string' &&
typeof node.attributes.src === 'string' && node.attributes[attr].startsWith('blob:')
node.attributes.src.startsWith('blob:') ) {
) { node.attributes[attr] = node.attributes[attr]
node.attributes.src = node.attributes.src .replace(/[\w-]+$/, '...')
.replace(/[\w-]+$/, '...') .replace(/:[0-9]+\//, ':xxxx/');
.replace(/:[0-9]+\//, ':xxxx/'); }
} }
} }