Files
rrweb/packages/rrweb-snapshot/test/css.test.ts
Eoghan Murray 67657a8710 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>
2026-04-01 12:00:00 +08:00

243 lines
8.7 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { describe, it, beforeEach, expect } from 'vitest';
import { mediaSelectorPlugin, pseudoClassPlugin } from '../src/css';
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 {
const ast = postcss([plugin]).process(input, {});
return ast.css;
}
describe('mediaSelectorPlugin', () => {
it('selectors without device remain unchanged', () => {
const cssText =
'@media only screen and (min-width: 1200px) { .a { width: 10px; }}';
expect(parse(mediaSelectorPlugin, cssText)).toEqual(cssText);
});
it('can adapt media rules to replay context', () => {
[
['min', 'width'],
['min', 'height'],
['max', 'width'],
['max', 'height'],
].forEach(([first, second]) => {
expect(
parse(
mediaSelectorPlugin,
`@media only screen and (${first}-device-${second}: 1200px) { .a { width: 10px; }}`,
),
).toEqual(
`@media only screen and (${first}-${second}: 1200px) { .a { width: 10px; }}`,
);
});
});
});
describe('pseudoClassPlugin', () => {
it('parses nested commas in selectors correctly', () => {
const cssText =
'body > ul :is(li:not(:first-of-type) a:hover, li:not(:first-of-type).active a) {background: red;}';
expect(parse(pseudoClassPlugin, cssText)).toEqual(cssText);
});
it('should parse selector with comma nested inside ()', () => {
const cssText =
'[_nghost-ng-c4172599085]:not(.fit-content).aim-select:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active) { border-color: rgb(84, 84, 84); }';
expect(parse(pseudoClassPlugin, cssText))
.toEqual(`[_nghost-ng-c4172599085]:not(.fit-content).aim-select:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active),
[_nghost-ng-c4172599085]:not(.fit-content).aim-select.\\:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active) { border-color: rgb(84, 84, 84); }`);
});
it('ignores ( in strings', () => {
const cssText =
'li[attr="weirdly("] a:hover, li[attr="weirdly)"] a {background-color: red;}';
expect(parse(pseudoClassPlugin, cssText))
.toEqual(`li[attr="weirdly("] a:hover, li[attr="weirdly)"] a,
li[attr="weirdly("] a.\\:hover {background-color: red;}`);
});
it('ignores escaping in strings', () => {
const cssText = `li[attr="weirder\\"("] a:hover, li[attr="weirder\\")"] a {background-color: red;}`;
expect(parse(pseudoClassPlugin, cssText))
.toEqual(`li[attr="weirder\\"("] a:hover, li[attr="weirder\\")"] a,
li[attr="weirder\\"("] a.\\:hover {background-color: red;}`);
});
it('ignores comma in string', () => {
const cssText = 'li[attr="has,comma"] a:hover {background: red;}';
expect(parse(pseudoClassPlugin, cssText)).toEqual(
`li[attr="has,comma"] a:hover,
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,
);
});
});