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:
6
.changeset/single-style-capture.md
Normal file
6
.changeset/single-style-capture.md
Normal 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
|
||||
@@ -34,6 +34,7 @@
|
||||
"eslint-plugin-compat": "^5.0.0",
|
||||
"eslint-plugin-jest": "^27.6.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.17",
|
||||
"happy-dom": "^14.12.0",
|
||||
"markdownlint": "^0.25.1",
|
||||
"markdownlint-cli": "^0.31.1",
|
||||
"prettier": "2.8.4",
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"@typescript-eslint/eslint-plugin": "^5.23.0",
|
||||
"@typescript-eslint/parser": "^5.23.0",
|
||||
"eslint": "^8.15.0",
|
||||
"happy-dom": "^14.12.0",
|
||||
"puppeteer": "^17.1.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.3.1",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { mediaSelectorPlugin, pseudoClassPlugin } from './css';
|
||||
import {
|
||||
type serializedNodeWithId,
|
||||
type serializedElementNodeWithId,
|
||||
type serializedTextNodeWithId,
|
||||
NodeType,
|
||||
type tagMap,
|
||||
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(
|
||||
n: serializedNodeWithId,
|
||||
options: {
|
||||
@@ -154,14 +227,13 @@ function buildNode(
|
||||
continue;
|
||||
}
|
||||
|
||||
const isTextarea = tagName === 'textarea' && name === 'value';
|
||||
const isRemoteOrDynamicCss = tagName === 'style' && name === '_cssText';
|
||||
if (isRemoteOrDynamicCss && hackCss && typeof value === 'string') {
|
||||
value = adaptCssForReplay(value, cache);
|
||||
}
|
||||
if ((isTextarea || isRemoteOrDynamicCss) && typeof value === 'string') {
|
||||
// https://github.com/rrweb-io/rrweb/issues/112
|
||||
// https://github.com/rrweb-io/rrweb/pull/1351
|
||||
if (typeof value !== 'string') {
|
||||
// pass
|
||||
} else if (tagName === 'style' && name === '_cssText') {
|
||||
buildStyleNode(n, node as HTMLStyleElement, value, options);
|
||||
continue; // no need to set _cssText as attribute
|
||||
} else if (tagName === 'textarea' && name === 'value') {
|
||||
// create without an ID or presence in mirror
|
||||
node.appendChild(doc.createTextNode(value));
|
||||
n.childNodes = []; // value overrides childNodes
|
||||
continue;
|
||||
@@ -317,11 +389,11 @@ function buildNode(
|
||||
return node;
|
||||
}
|
||||
case NodeType.Text:
|
||||
return doc.createTextNode(
|
||||
n.isStyle && hackCss
|
||||
? adaptCssForReplay(n.textContent, cache)
|
||||
: n.textContent,
|
||||
);
|
||||
if (n.isStyle && hackCss) {
|
||||
// support legacy style
|
||||
return doc.createTextNode(adaptCssForReplay(n.textContent, cache));
|
||||
}
|
||||
return doc.createTextNode(n.textContent);
|
||||
case NodeType.CDATA:
|
||||
return doc.createCDATASection(n.textContent);
|
||||
case NodeType.Comment:
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
toLowerCase,
|
||||
extractFileExtension,
|
||||
absolutifyURLs,
|
||||
markCssSplits,
|
||||
} from './utils';
|
||||
import dom from '@rrweb/utils';
|
||||
|
||||
@@ -403,6 +404,7 @@ function serializeNode(
|
||||
* `newlyAddedElement: true` skips scrollTop and scrollLeft check
|
||||
*/
|
||||
newlyAddedElement?: boolean;
|
||||
cssCaptured?: boolean;
|
||||
},
|
||||
): serializedNode | false {
|
||||
const {
|
||||
@@ -420,6 +422,7 @@ function serializeNode(
|
||||
recordCanvas,
|
||||
keepIframeSrcFn,
|
||||
newlyAddedElement = false,
|
||||
cssCaptured = false,
|
||||
} = options;
|
||||
// Only record root id when document object is not the base document
|
||||
const rootId = getRootId(doc, mirror);
|
||||
@@ -466,6 +469,7 @@ function serializeNode(
|
||||
needsMask,
|
||||
maskTextFn,
|
||||
rootId,
|
||||
cssCaptured,
|
||||
});
|
||||
case n.CDATA_SECTION_NODE:
|
||||
return {
|
||||
@@ -497,48 +501,38 @@ function serializeTextNode(
|
||||
needsMask: boolean;
|
||||
maskTextFn: MaskTextFn | undefined;
|
||||
rootId: number | undefined;
|
||||
cssCaptured?: boolean;
|
||||
},
|
||||
): 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.
|
||||
// So just let it be undefined which is ok in this use case.
|
||||
const parent = dom.parentNode(n);
|
||||
const parentTagName = parent && (parent as HTMLElement).tagName;
|
||||
let text = dom.textContent(n);
|
||||
let textContent: string | null = '';
|
||||
const isStyle = parentTagName === 'STYLE' ? 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) {
|
||||
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) {
|
||||
text = maskTextFn
|
||||
? maskTextFn(text, dom.parentElement(n))
|
||||
: text.replace(/[\S]/g, '*');
|
||||
if (!isStyle && !isScript && textContent && needsMask) {
|
||||
textContent = maskTextFn
|
||||
? maskTextFn(textContent, dom.parentElement(n))
|
||||
: textContent.replace(/[\S]/g, '*');
|
||||
}
|
||||
|
||||
return {
|
||||
type: NodeType.Text,
|
||||
textContent: text || '',
|
||||
isStyle,
|
||||
textContent: textContent || '',
|
||||
rootId,
|
||||
};
|
||||
}
|
||||
@@ -608,17 +602,14 @@ function serializeElementNode(
|
||||
attributes._cssText = cssText;
|
||||
}
|
||||
}
|
||||
// dynamic stylesheet
|
||||
if (
|
||||
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(
|
||||
if (tagName === 'style' && (n as HTMLStyleElement).sheet) {
|
||||
let cssText = stringifyStylesheet(
|
||||
(n as HTMLStyleElement).sheet as CSSStyleSheet,
|
||||
);
|
||||
if (cssText) {
|
||||
if (n.childNodes.length > 1) {
|
||||
cssText = markCssSplits(cssText, n as HTMLStyleElement);
|
||||
}
|
||||
attributes._cssText = cssText;
|
||||
}
|
||||
}
|
||||
@@ -937,6 +928,7 @@ export function serializeNodeWithId(
|
||||
node: serializedElementNodeWithId,
|
||||
) => unknown;
|
||||
stylesheetLoadTimeout?: number;
|
||||
cssCaptured?: boolean;
|
||||
},
|
||||
): serializedNodeWithId | null {
|
||||
const {
|
||||
@@ -962,6 +954,7 @@ export function serializeNodeWithId(
|
||||
stylesheetLoadTimeout = 5000,
|
||||
keepIframeSrcFn = () => false,
|
||||
newlyAddedElement = false,
|
||||
cssCaptured = false,
|
||||
} = options;
|
||||
let { needsMask } = options;
|
||||
let { preserveWhiteSpace = true } = options;
|
||||
@@ -992,6 +985,7 @@ export function serializeNodeWithId(
|
||||
recordCanvas,
|
||||
keepIframeSrcFn,
|
||||
newlyAddedElement,
|
||||
cssCaptured,
|
||||
});
|
||||
if (!_serializedNode) {
|
||||
// TODO: dev only
|
||||
@@ -1007,7 +1001,6 @@ export function serializeNodeWithId(
|
||||
slimDOMExcluded(_serializedNode, slimDOMOptions) ||
|
||||
(!preserveWhiteSpace &&
|
||||
_serializedNode.type === NodeType.Text &&
|
||||
!_serializedNode.isStyle &&
|
||||
!_serializedNode.textContent.replace(/^\s+|\s+$/gm, '').length)
|
||||
) {
|
||||
id = IGNORED_NODE;
|
||||
@@ -1072,6 +1065,7 @@ export function serializeNodeWithId(
|
||||
onStylesheetLoad,
|
||||
stylesheetLoadTimeout,
|
||||
keepIframeSrcFn,
|
||||
cssCaptured: false,
|
||||
};
|
||||
|
||||
if (
|
||||
@@ -1081,6 +1075,13 @@ export function serializeNodeWithId(
|
||||
) {
|
||||
// value parameter in DOM reflects the correct value, so ignore childNode
|
||||
} 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))) {
|
||||
const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
|
||||
if (serializedChildNode) {
|
||||
|
||||
@@ -20,9 +20,18 @@ export type documentTypeNode = {
|
||||
systemId: string;
|
||||
};
|
||||
|
||||
export type attributes = {
|
||||
[key: string]: string | number | true | null;
|
||||
type cssTextKeyAttr = {
|
||||
_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 = {
|
||||
/**
|
||||
* @deprecated old bug in rrweb was causing these to always be set
|
||||
@@ -45,6 +54,10 @@ export type elementNode = {
|
||||
export type textNode = {
|
||||
type: NodeType.Text;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -78,6 +91,11 @@ export type serializedElementNodeWithId = Extract<
|
||||
Record<'type', NodeType.Element>
|
||||
>;
|
||||
|
||||
export type serializedTextNodeWithId = Extract<
|
||||
serializedNodeWithId,
|
||||
Record<'type', NodeType.Text>
|
||||
>;
|
||||
|
||||
export type tagMap = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
@@ -99,14 +99,28 @@ export function escapeImportStatement(rule: CSSImportRule): string {
|
||||
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 {
|
||||
try {
|
||||
const rules = s.rules || s.cssRules;
|
||||
if (!rules) {
|
||||
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) =>
|
||||
stringifyRule(rule, s.href),
|
||||
stringifyRule(rule, sheetHref),
|
||||
).join('');
|
||||
return fixBrowserCompatibilityIssuesInCSS(stringifiedRules);
|
||||
} 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 */');
|
||||
}
|
||||
|
||||
@@ -1094,12 +1094,13 @@ exports[`shadow DOM integration tests > snapshot shadow DOM 1`] = `
|
||||
{
|
||||
\\"type\\": 2,
|
||||
\\"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\\": [
|
||||
{
|
||||
\\"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; }\\",
|
||||
\\"isStyle\\": true,
|
||||
\\"textContent\\": \\"\\",
|
||||
\\"id\\": 38
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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 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', () => {
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
createCache,
|
||||
} from '../src/rebuild';
|
||||
import { NodeType } from '../src/types';
|
||||
import { createMirror, Mirror } from '../src/utils';
|
||||
import { createMirror, Mirror, normalizeCssString } from '../src/utils';
|
||||
|
||||
const expect = _expect as unknown as {
|
||||
<T = unknown>(actual: T): {
|
||||
@@ -20,7 +20,7 @@ const expect = _expect as unknown as {
|
||||
|
||||
expect.extend({
|
||||
toMatchCss: function (received: string, expected: string) {
|
||||
const pass = normCss(received) === normCss(expected);
|
||||
const pass = normalizeCssString(received) === normalizeCssString(expected);
|
||||
const message: () => string = () =>
|
||||
pass
|
||||
? ''
|
||||
@@ -32,10 +32,6 @@ expect.extend({
|
||||
},
|
||||
});
|
||||
|
||||
function normCss(cssText: string): string {
|
||||
return cssText.replace(/[\s;]/g, '');
|
||||
}
|
||||
|
||||
function getDuration(hrtime: [number, number]) {
|
||||
const [seconds, nanoseconds] = hrtime;
|
||||
return seconds * 1000 + nanoseconds / 1000000;
|
||||
|
||||
@@ -162,22 +162,27 @@ describe('style elements', () => {
|
||||
it('should serialize all rules of stylesheet when the sheet has a single child node', () => {
|
||||
const styleEl = render(`<style>body { color: red; }</style>`);
|
||||
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(`<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; }'));
|
||||
expect(serializeNode(styleEl.childNodes[1])).toMatchObject({
|
||||
isStyle: true,
|
||||
styleEl.sheet?.insertRule('section.working { color: pink; }');
|
||||
expect(serializeNode(styleEl)).toMatchObject({
|
||||
rootId: undefined,
|
||||
textContent: 'section { color: blue; }',
|
||||
type: 3,
|
||||
attributes: {
|
||||
_cssText:
|
||||
'section.working {color: pink;}body {color: red;}/* rr_split */section {color: blue;}',
|
||||
},
|
||||
type: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"scripts": {
|
||||
"prepare": "npm run prepack",
|
||||
"prepack": "npm run build",
|
||||
"retest": "vitest run --exclude test/benchmark",
|
||||
"retest": "cross-env PUPPETEER_HEADLESS=true yarn retest:headful",
|
||||
"retest:headful": "vitest run --exclude test/benchmark",
|
||||
"build-and-test": "yarn build && yarn retest",
|
||||
"test:headless": "cross-env PUPPETEER_HEADLESS=true yarn build-and-test",
|
||||
"test:headful": "cross-env PUPPETEER_HEADLESS=false yarn build-and-test",
|
||||
|
||||
@@ -287,12 +287,26 @@ export default class MutationBuffer {
|
||||
};
|
||||
const pushAdd = (n: Node) => {
|
||||
const parent = dom.parentNode(n);
|
||||
if (!parent || !inDom(n) || (parent as Element).tagName === 'TEXTAREA') {
|
||||
if (!parent || !inDom(n)) {
|
||||
return;
|
||||
}
|
||||
let cssCaptured = false;
|
||||
if (n.nodeType === Node.TEXT_NODE) {
|
||||
const parentTag = (parent as Element).tagName;
|
||||
if (parentTag === 'TEXTAREA') {
|
||||
// genTextAreaValueMutation already called via parent
|
||||
return;
|
||||
} else if (parentTag === 'STYLE' && this.addedSet.has(parent)) {
|
||||
// css content will be recorded via parent's _cssText attribute when
|
||||
// mutation adds entire <style> element
|
||||
cssCaptured = true;
|
||||
}
|
||||
}
|
||||
|
||||
const parentId = isShadowRoot(parent)
|
||||
? this.mirror.getId(getShadowHost(n))
|
||||
: this.mirror.getId(parent);
|
||||
|
||||
const nextId = getNextId(n);
|
||||
if (parentId === -1 || nextId === -1) {
|
||||
return addList.addNode(n);
|
||||
@@ -335,6 +349,7 @@ export default class MutationBuffer {
|
||||
onStylesheetLoad: (link, childSn) => {
|
||||
this.stylesheetManager.attachLinkElement(link, childSn);
|
||||
},
|
||||
cssCaptured,
|
||||
});
|
||||
if (sn) {
|
||||
adds.push({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
rebuild,
|
||||
adaptCssForReplay,
|
||||
buildNodeWithSN,
|
||||
NodeType,
|
||||
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; }',
|
||||
);
|
||||
}
|
||||
if (!injectStylesRules.length) {
|
||||
return;
|
||||
}
|
||||
if (this.usingVirtualDom) {
|
||||
const styleEl = this.virtualDom.createElement('style');
|
||||
this.virtualDom.mirror.add(
|
||||
@@ -1743,7 +1747,14 @@ export class Replayer {
|
||||
}
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1386,18 +1386,18 @@ exports[`record > captures inserted style text nodes correctly 1`] = `
|
||||
{
|
||||
\\"type\\": 2,
|
||||
\\"tagName\\": \\"style\\",
|
||||
\\"attributes\\": {},
|
||||
\\"attributes\\": {
|
||||
\\"_cssText\\": \\"div { color: red; }/* rr_split */section { color: blue; }\\"
|
||||
},
|
||||
\\"childNodes\\": [
|
||||
{
|
||||
\\"type\\": 3,
|
||||
\\"textContent\\": \\"div { color: red; }\\",
|
||||
\\"isStyle\\": true,
|
||||
\\"textContent\\": \\"\\",
|
||||
\\"id\\": 6
|
||||
},
|
||||
{
|
||||
\\"type\\": 3,
|
||||
\\"textContent\\": \\"section { color: blue; }\\",
|
||||
\\"isStyle\\": true,
|
||||
\\"textContent\\": \\"\\",
|
||||
\\"id\\": 7
|
||||
}
|
||||
],
|
||||
@@ -1460,7 +1460,6 @@ exports[`record > captures inserted style text nodes correctly 1`] = `
|
||||
\\"node\\": {
|
||||
\\"type\\": 3,
|
||||
\\"textContent\\": \\"h1 { color: pink; }\\",
|
||||
\\"isStyle\\": true,
|
||||
\\"id\\": 12
|
||||
}
|
||||
},
|
||||
@@ -1470,7 +1469,6 @@ exports[`record > captures inserted style text nodes correctly 1`] = `
|
||||
\\"node\\": {
|
||||
\\"type\\": 3,
|
||||
\\"textContent\\": \\"span { color: orange; }\\",
|
||||
\\"isStyle\\": true,
|
||||
\\"id\\": 13
|
||||
}
|
||||
}
|
||||
|
||||
31
packages/rrweb/test/html/style.html
Normal file
31
packages/rrweb/test/html/style.html
Normal 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>
|
||||
@@ -101,7 +101,7 @@ describe('record integration tests', function (this: ISuite) {
|
||||
await assertSnapshot(snapshots);
|
||||
});
|
||||
|
||||
it('can record textarea mutations correctly', async () => {
|
||||
it('can record and replay textarea mutations correctly', async () => {
|
||||
const page: puppeteer.Page = await browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(getHtml.call(this, 'empty.html'));
|
||||
@@ -112,20 +112,29 @@ describe('record integration tests', function (this: ISuite) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.innerText = 'pre value';
|
||||
document.body.append(ta);
|
||||
|
||||
const ta2 = document.createElement('textarea');
|
||||
ta2.id = 'ta2';
|
||||
document.body.append(ta2);
|
||||
});
|
||||
await page.waitForTimeout(5);
|
||||
await waitForRAF(page);
|
||||
await page.evaluate(() => {
|
||||
const t = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||
t.innerText = 'ok'; // this mutation should be recorded
|
||||
|
||||
const ta2t = document.createTextNode('added');
|
||||
document.getElementById('ta2').append(ta2t);
|
||||
});
|
||||
await page.waitForTimeout(5);
|
||||
await waitForRAF(page);
|
||||
await page.evaluate(() => {
|
||||
const t = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||
(t.childNodes[0] as Text).appendData('3'); // this mutation is also valid
|
||||
|
||||
document.getElementById('ta2').remove(); // done with this
|
||||
});
|
||||
await page.waitForTimeout(5);
|
||||
await waitForRAF(page);
|
||||
await page.type('textarea', '1'); // types (inserts) at index 0, in front of existing text
|
||||
await page.waitForTimeout(5);
|
||||
await waitForRAF(page);
|
||||
await page.evaluate(() => {
|
||||
const t = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||
// user has typed so childNode content should now be ignored
|
||||
@@ -136,7 +145,7 @@ describe('record integration tests', function (this: ISuite) {
|
||||
// there is nothing explicit in rrweb which enforces this, but this test may protect against
|
||||
// a future change where a mutation on a textarea incorrectly updates the .value
|
||||
});
|
||||
await page.waitForTimeout(5);
|
||||
await waitForRAF(page);
|
||||
await page.type('textarea', '2'); // cursor is at index 1
|
||||
|
||||
const snapshots = (await page.evaluate(
|
||||
@@ -153,12 +162,18 @@ describe('record integration tests', function (this: ISuite) {
|
||||
replayer.pause((e.timestamp - window.snapshots[0].timestamp)+1);
|
||||
let ts = replayer.iframe.contentDocument.querySelector('textarea');
|
||||
vals.push((e.data.source === 0 ? 'Mutation' : 'User') + ':' + ts.value);
|
||||
let ts2 = replayer.iframe.contentDocument.getElementById('ta2');
|
||||
if (ts2) {
|
||||
vals.push('ta2:' + ts2.value);
|
||||
}
|
||||
});
|
||||
vals;
|
||||
`);
|
||||
expect(replayTextareaValues).toEqual([
|
||||
'Mutation:pre value',
|
||||
'ta2:',
|
||||
'Mutation:ok',
|
||||
'ta2:added',
|
||||
'Mutation:ok3',
|
||||
'User:1ok3',
|
||||
'Mutation:1ok3', // if this gets set to 'ignore', it's an error, as the 'user' has modified the textarea
|
||||
@@ -166,6 +181,131 @@ describe('record integration tests', function (this: ISuite) {
|
||||
]);
|
||||
});
|
||||
|
||||
it('can record and replay style mutations', async () => {
|
||||
// This test shows that the `isStyle` attribute on textContent is not needed in a mutation
|
||||
// TODO: we could get a lot more elaborate here with mixed textContent and insertRule mutations
|
||||
const page: puppeteer.Page = await browser.newPage();
|
||||
await page.goto(`${serverURL}/html`);
|
||||
await page.setContent(getHtml.call(this, 'style.html'));
|
||||
|
||||
await waitForRAF(page); // ensure mutations aren't included in fullsnapshot
|
||||
|
||||
await page.evaluate(() => {
|
||||
let styleEl = document.querySelector('style#dual-textContent');
|
||||
if (styleEl) {
|
||||
styleEl.append(
|
||||
document.createTextNode('body { background-color: darkgreen; }'),
|
||||
);
|
||||
styleEl.append(
|
||||
document.createTextNode(
|
||||
'.absolutify { background-image: url("./rel"); }',
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
await waitForRAF(page);
|
||||
await page.evaluate(() => {
|
||||
let styleEl = document.querySelector('style#dual-textContent');
|
||||
if (styleEl) {
|
||||
styleEl.childNodes.forEach((cn) => {
|
||||
if (cn.textContent) {
|
||||
cn.textContent = cn.textContent.replace('darkgreen', 'purple');
|
||||
cn.textContent = cn.textContent.replace(
|
||||
'orange !important',
|
||||
'yellow',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
await waitForRAF(page);
|
||||
await page.evaluate(() => {
|
||||
let styleEl = document.querySelector('style#dual-textContent');
|
||||
if (styleEl) {
|
||||
styleEl.childNodes.forEach((cn) => {
|
||||
if (cn.textContent) {
|
||||
cn.textContent = cn.textContent.replace(
|
||||
'black',
|
||||
'black !important',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
let hoverMutationStyleEl = document.querySelector('style#hover-mutation');
|
||||
if (hoverMutationStyleEl) {
|
||||
hoverMutationStyleEl.childNodes.forEach((cn) => {
|
||||
if (cn.textContent) {
|
||||
cn.textContent = 'a:hover { outline: cyan solid 1px; }';
|
||||
}
|
||||
});
|
||||
}
|
||||
let st = document.createElement('style');
|
||||
st.id = 'goldilocks';
|
||||
st.innerText = 'body { color: brown }';
|
||||
document.body.append(st);
|
||||
});
|
||||
|
||||
await waitForRAF(page);
|
||||
await page.evaluate(() => {
|
||||
let styleEl = document.querySelector('style#goldilocks');
|
||||
if (styleEl) {
|
||||
styleEl.childNodes.forEach((cn) => {
|
||||
if (cn.textContent) {
|
||||
cn.textContent = cn.textContent.replace('brown', 'gold');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const snapshots = (await page.evaluate(
|
||||
'window.snapshots',
|
||||
)) as eventWithTime[];
|
||||
|
||||
// following ensures that the ./rel url has been absolutized (in a mutation)
|
||||
await assertSnapshot(snapshots);
|
||||
|
||||
// check after each mutation and text input
|
||||
const replayStyleValues = await page.evaluate(`
|
||||
const { Replayer } = rrweb;
|
||||
const replayer = new Replayer(window.snapshots);
|
||||
const vals = [];
|
||||
window.snapshots.filter((e)=>e.data.attributes || e.data.source === 5).forEach((e)=>{
|
||||
replayer.pause((e.timestamp - window.snapshots[0].timestamp)+1);
|
||||
let bodyStyle = getComputedStyle(replayer.iframe.contentDocument.querySelector('body'))
|
||||
vals.push({
|
||||
'background-color': bodyStyle['background-color'],
|
||||
'color': bodyStyle['color'],
|
||||
});
|
||||
});
|
||||
vals.push(replayer.iframe.contentDocument.getElementById('single-textContent').innerText);
|
||||
vals.push(replayer.iframe.contentDocument.getElementById('empty').innerText);
|
||||
vals.push(replayer.iframe.contentDocument.getElementById('hover-mutation').innerText);
|
||||
vals;
|
||||
`);
|
||||
|
||||
expect(replayStyleValues).toEqual([
|
||||
{
|
||||
'background-color': 'rgb(0, 100, 0)', // darkgreen
|
||||
color: 'rgb(255, 165, 0)', // orange (from style.html)
|
||||
},
|
||||
{
|
||||
'background-color': 'rgb(128, 0, 128)', // purple
|
||||
color: 'rgb(255, 255, 0)', // yellow
|
||||
},
|
||||
{
|
||||
'background-color': 'rgb(0, 0, 0)', // black !important
|
||||
color: 'rgb(165, 42, 42)', // brown
|
||||
},
|
||||
{
|
||||
'background-color': 'rgb(0, 0, 0)',
|
||||
color: 'rgb(255, 215, 0)', // gold
|
||||
},
|
||||
'a:hover,\na.\\:hover { outline: red solid 1px; }', // has run adaptCssForReplay
|
||||
'a:hover,\na.\\:hover { outline: blue solid 1px; }', // has run adaptCssForReplay
|
||||
'a:hover,\na.\\:hover { outline: cyan solid 1px; }', // has run adaptCssForReplay after text mutation
|
||||
]);
|
||||
});
|
||||
|
||||
it('can record childList mutations', async () => {
|
||||
const page: puppeteer.Page = await browser.newPage();
|
||||
await page.goto('about:blank');
|
||||
@@ -1238,12 +1378,8 @@ describe('record integration tests', function (this: ISuite) {
|
||||
});
|
||||
|
||||
/**
|
||||
* https://github.com/rrweb-io/rrweb/pull/1417
|
||||
* This test is to make sure that this problem doesn't regress
|
||||
* Test case description:
|
||||
* 1. Record two style elements. One is recorded as a full snapshot and the other is recorded as an incremental snapshot.
|
||||
* 2. Change the color of both style elements to yellow as incremental style mutation.
|
||||
* 3. Replay the recorded events and check if the style mutation is applied correctly.
|
||||
* the regression part of the following is now handled by replayer.test.ts::'can deal with duplicate/conflicting values on style elements'
|
||||
* so this test could be dropped if we add more robust mixing of `insertRule` into 'can record and replay style mutations'
|
||||
*/
|
||||
it('should record style mutations and replay them correctly', async () => {
|
||||
const page: puppeteer.Page = await browser.newPage();
|
||||
@@ -1336,4 +1472,77 @@ describe('record integration tests', function (this: ISuite) {
|
||||
expect(changedColors).toEqual([NewColor, NewColor]);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
it('should record style mutations with multiple child nodes and replay them correctly', async () => {
|
||||
// ensure that presence of multiple text nodes doesn't interfere with programmatic insertRule operations
|
||||
|
||||
const page: puppeteer.Page = await browser.newPage();
|
||||
const Color = 'rgb(255, 0, 0)'; // red color
|
||||
|
||||
await page.setContent(
|
||||
`
|
||||
<!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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,18 +250,18 @@ export function stringifySnapshots(snapshots: eventWithTime[]): string {
|
||||
|
||||
function stripBlobURLsFromAttributes(node: {
|
||||
attributes: {
|
||||
src?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}) {
|
||||
if (
|
||||
'src' in node.attributes &&
|
||||
node.attributes.src &&
|
||||
typeof node.attributes.src === 'string' &&
|
||||
node.attributes.src.startsWith('blob:')
|
||||
) {
|
||||
node.attributes.src = node.attributes.src
|
||||
.replace(/[\w-]+$/, '...')
|
||||
.replace(/:[0-9]+\//, ':xxxx/');
|
||||
for (const attr in node.attributes) {
|
||||
if (
|
||||
typeof node.attributes[attr] === 'string' &&
|
||||
node.attributes[attr].startsWith('blob:')
|
||||
) {
|
||||
node.attributes[attr] = node.attributes[attr]
|
||||
.replace(/[\w-]+$/, '...')
|
||||
.replace(/:[0-9]+\//, ':xxxx/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user