Files
rrweb/packages/rrweb-snapshot/test/rebuild.test.ts
Justin Halsall 5217a09c60 Support top-layer <dialog> recording & replay (#1503)
* chore: its important to run `yarn build:all` before running `yarn dev`

* feat: trigger showModal from rrdom and rrweb

* feat: Add support for replaying modal and non modal dialog elements

* chore: Update dev script to remove CLEAR_DIST_DIR flag

* Get modal recording and replay working

* DRY up dialog test and dedupe snapshot images

* feat: Refactor dialog test to use updated attribute name

* feat: Update dialog test to include rr_open attribute

* chore: Add npm dependency happy-dom@14.12.0

* Add more test cases for dialog

* Clean up naming

* Refactor dialog open code

* Revert changed code that doesn't do anything

* Add documentation for unimplemented type

* chore: Remove unnecessary comments in dialog.test.ts

* rename rr_open to rr_openMode

* Replace todo with a skipped test

* Add better logging for CI

* Rename rr_openMode to rr_open_mode

rrdom downcases all attribute names which made `rr_openMode` tricky to deal with

* Remove unused images

* Move after iframe append based on @YunFeng0817's comment
https://github.com/rrweb-io/rrweb/pull/1503#discussion_r1666363931

* Remove redundant dialog handling from rrdom.

rrdom already handles dialog element creation it's self

* Rename variables for dialog handling in rrweb replay module

* Update packages/rrdom/src/document.ts

---------

Co-authored-by: Eoghan Murray <eoghan@getthere.ie>
2026-04-01 12:00:00 +08:00

252 lines
7.7 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import * as fs from 'fs';
import * as path from 'path';
import { beforeEach, describe, expect as _expect, it } from 'vitest';
import {
adaptCssForReplay,
buildNodeWithSN,
createCache,
} from '../src/rebuild';
import { NodeType } from '../src/types';
import { createMirror, Mirror } from '../src/utils';
const expect = _expect as unknown as {
<T = unknown>(actual: T): {
toMatchCss(expected: string): void;
} & ReturnType<typeof _expect>;
} & typeof _expect;
expect.extend({
toMatchCss: function (received: string, expected: string) {
const pass = normCss(received) === normCss(expected);
const message: () => string = () =>
pass
? ''
: `Received (${received}) is not the same as expected (${expected})`;
return {
message,
pass,
};
},
});
function normCss(cssText: string): string {
return cssText.replace(/[\s;]/g, '');
}
function getDuration(hrtime: [number, number]) {
const [seconds, nanoseconds] = hrtime;
return seconds * 1000 + nanoseconds / 1000000;
}
describe('rebuild', function () {
let cache: ReturnType<typeof createCache>;
let mirror: Mirror;
beforeEach(() => {
mirror = createMirror();
cache = createCache();
});
describe('rr_dataURL', function () {
it('should rebuild dataURL', function () {
const dataURI =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const node = buildNodeWithSN(
{
id: 1,
tagName: 'img',
type: NodeType.Element,
attributes: {
rr_dataURL: dataURI,
src: 'http://example.com/image.png',
},
childNodes: [],
},
{
doc: document,
mirror,
hackCss: false,
cache,
},
) as HTMLImageElement;
expect(node?.src).toBe(dataURI);
});
});
describe('shadowDom', function () {
it('rebuild shadowRoot without siblings', function () {
const node = buildNodeWithSN(
{
id: 1,
tagName: 'div',
type: NodeType.Element,
attributes: {},
childNodes: [
{
id: 2,
tagName: 'div',
type: NodeType.Element,
attributes: {},
childNodes: [],
isShadow: true,
},
],
isShadowHost: true,
},
{
doc: document,
mirror,
hackCss: false,
cache,
},
) as HTMLDivElement;
expect(node.shadowRoot?.childNodes.length).toBe(1);
});
});
describe('add hover class to hover selector related rules', function () {
it('will do nothing to css text without :hover', () => {
const cssText = 'body { color: white }';
expect(adaptCssForReplay(cssText, cache)).toMatchCss(cssText);
});
it('can add hover class to css text', () => {
const cssText = '.a:hover { color: white }';
expect(adaptCssForReplay(cssText, cache)).toMatchCss(
'.a:hover, .a.\\:hover { color: white }',
);
});
it('can correctly add hover when in middle of selector', () => {
const cssText = 'ul li a:hover img { color: white }';
expect(adaptCssForReplay(cssText, cache)).toMatchCss(
'ul li a:hover img, ul li a.\\:hover img { color: white }',
);
});
it('can correctly add hover on multiline selector', () => {
const cssText = `ul li.specified a:hover img,
ul li.multiline
b:hover
img,
ul li.specified c:hover img {
color: white
}`;
expect(adaptCssForReplay(cssText, cache)).toMatchCss(
`ul li.specified a:hover img,
ul li.multiline
b:hover
img,
ul li.specified c:hover img,
ul li.specified a.\\:hover img,
ul li.multiline b.\\:hover img,
ul li.specified c.\\:hover img {
color: white
}`,
);
});
it('can add hover class within media query', () => {
const cssText = '@media screen { .m:hover { color: white } }';
expect(adaptCssForReplay(cssText, cache)).toMatchCss(
'@media screen { .m:hover, .m.\\:hover { color: white } }',
);
});
it('can add hover class when there is multi selector', () => {
const cssText = '.a, .b:hover, .c { color: white }';
expect(adaptCssForReplay(cssText, cache)).toMatchCss(
'.a, .b:hover, .c, .b.\\:hover { color: white }',
);
});
it('can add hover class when there is a multi selector with the same prefix', () => {
const cssText = '.a:hover, .a:hover::after { color: white }';
expect(adaptCssForReplay(cssText, cache)).toMatchCss(
'.a:hover, .a:hover::after, .a.\\:hover, .a.\\:hover::after { color: white }',
);
});
it('can add hover class when :hover is not the end of selector', () => {
const cssText = 'div:hover::after { color: white }';
expect(adaptCssForReplay(cssText, cache)).toMatchCss(
'div:hover::after, div.\\:hover::after { color: white }',
);
});
it('can add hover class when the selector has multi :hover', () => {
const cssText = 'a:hover b:hover { color: white }';
expect(adaptCssForReplay(cssText, cache)).toMatchCss(
'a:hover b:hover, a.\\:hover b.\\:hover { color: white }',
);
});
it('will ignore :hover in css value', () => {
const cssText = '.a::after { content: ":hover" }';
expect(adaptCssForReplay(cssText, cache)).toMatchCss(cssText);
});
it('can adapt media rules to replay context', () => {
const cssText =
'@media only screen and (min-device-width : 1200px) { .a { width: 10px; }}';
expect(adaptCssForReplay(cssText, cache)).toMatchCss(
'@media only screen and (min-width : 1200px) { .a { width: 10px; }}',
);
});
// this benchmark is unreliable when run in parallel with other tests
it.skip('benchmark', () => {
const cssText = fs.readFileSync(
path.resolve(__dirname, './css/benchmark.css'),
'utf8',
);
const start = process.hrtime();
adaptCssForReplay(cssText, cache);
const end = process.hrtime(start);
const duration = getDuration(end);
expect(duration).toBeLessThan(100);
});
it('should be a lot faster to add a hover class to a previously processed css string', () => {
const factor = 100;
let cssText = fs.readFileSync(
path.resolve(__dirname, './css/benchmark.css'),
'utf8',
);
const start = process.hrtime();
adaptCssForReplay(cssText, cache);
const end = process.hrtime(start);
const cachedStart = process.hrtime();
adaptCssForReplay(cssText, cache);
const cachedEnd = process.hrtime(cachedStart);
expect(getDuration(cachedEnd) * factor).toBeLessThan(getDuration(end));
});
});
it('should not incorrectly interpret escaped quotes', () => {
// the ':hover' in the below is a decoy which is not part of the selector,
// previously that part was being incorrectly consumed by the selector regex
const should_not_modify =
".tailwind :is(.before\\:content-\\[\\'\\'\\])::before { --tw-content: \":hover\"; content: var(--tw-content); }.tailwind :is(.\\[\\&\\>li\\]\\:before\\:content-\\[\\'-\\'\\] > li)::before { color: pink; }";
expect(adaptCssForReplay(should_not_modify, cache)).toMatchCss(
should_not_modify,
);
});
it('should not incorrectly interpret at rules', () => {
// the ':hover' in the below is a decoy which is not part of the selector,
const should_not_modify =
'@import url("https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,400;0,500;0,700;1,400&display=:hover");';
expect(adaptCssForReplay(should_not_modify, cache)).toMatchCss(
should_not_modify,
);
});
});