Files
rrweb/packages/rrweb/test/replayer.test.ts
Alailson 360ed54d2d fix: ensure empty string replace/replaceSync clears stylesheets (#1774)
* fix: ensure empty string replace/replaceSync clears stylesheets

---------

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-01 12:00:00 +08:00

1387 lines
48 KiB
TypeScript

import * as fs from 'fs';
import * as path from 'path';
import type * as puppeteer from 'puppeteer';
import { vi } from 'vitest';
import 'construct-style-sheets-polyfill';
import {
assertDomSnapshot,
launchPuppeteer,
sampleEvents as events,
sampleStyleSheetRemoveEvents as stylesheetRemoveEvents,
sampleRemoteStyleSheetEvents as remoteStyleSheetEvents,
waitForRAF,
} from './utils';
import styleSheetRuleEvents from './events/style-sheet-rule-events';
import orderingEvents from './events/ordering';
import scrollEvents from './events/scroll';
import scrollWithParentStylesEvents from './events/scroll-with-parent-styles';
import inputEvents from './events/input';
import iframeEvents from './events/iframe';
import selectionEvents from './events/selection';
import shadowDomEvents from './events/shadow-dom';
import badTextareaEvents from './events/bad-textarea';
import badStyleEvents from './events/bad-style';
import StyleSheetTextMutation from './events/style-sheet-text-mutation';
import canvasInIframe from './events/canvas-in-iframe';
import adoptedStyleSheet from './events/adopted-style-sheet';
import adoptedStyleSheetModification from './events/adopted-style-sheet-modification';
import {
emptyReplaceSyncEvents,
emptyReplaceEvents,
} from './events/adopted-style-sheet-empty-replace';
import nestedStyleDeclarationEvents from './events/nested-style-declaration';
import styleDeclarationMissingRuleEvents from './events/style-declaration-missing-rule';
import documentReplacementEvents from './events/document-replacement';
import hoverInIframeShadowDom from './events/iframe-shadowdom-hover';
import customElementDefineClass from './events/custom-element-define-class';
import { ReplayerEvents } from '@rrweb/types';
interface ISuite {
code: string;
browser: puppeteer.Browser;
page: puppeteer.Page;
}
type IWindow = Window &
typeof globalThis & { rrweb: typeof import('../src'); events: typeof events };
describe('replayer', function () {
vi.setConfig({ testTimeout: 10_000 });
let code: ISuite['code'];
let browser: ISuite['browser'];
let page: ISuite['page'];
beforeAll(async () => {
browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../dist/rrweb.umd.cjs');
code = fs.readFileSync(bundlePath, 'utf8');
});
beforeEach(async () => {
page = await browser.newPage();
await page.goto('about:blank');
await page.evaluate(code);
await page.evaluate(`var events = ${JSON.stringify(events)}`);
page.on('console', (msg) => console.log('PAGE LOG:', msg.text()));
});
afterEach(async () => {
await page.close();
});
afterAll(async () => {
await browser.close();
});
it('can get meta data', async () => {
const meta = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.getMetaData();
`);
expect(meta).toEqual({
startTime: events[0].timestamp,
endTime: events[events.length - 1].timestamp,
totalTime: events[events.length - 1].timestamp - events[0].timestamp,
});
});
it('will start actions when play', async () => {
const actionLength = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.play();
replayer['timer']['actions'].length;
`);
expect(actionLength).toEqual(events.length);
});
it('will clean actions when pause', async () => {
const actionLength = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.play();
replayer.pause();
replayer['timer']['actions'].length;
`);
expect(actionLength).toEqual(0);
});
it('can play at any time offset', async () => {
const actionLength = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.play(1500);
replayer['timer']['actions'].length;
`);
expect(actionLength).toEqual(
events.filter((e) => e.timestamp - events[0].timestamp >= 1500).length,
);
});
it('can play a second time in the future', async () => {
const actionLength = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.play(500);
replayer.play(1500);
replayer['timer']['actions'].length;
`);
expect(actionLength).toEqual(
events.filter((e) => e.timestamp - events[0].timestamp >= 1500).length,
);
});
it('can play a second time to the past', async () => {
const actionLength = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.play(1500);
replayer.play(500);
replayer['timer']['actions'].length;
`);
expect(actionLength).toEqual(
events.filter((e) => e.timestamp - events[0].timestamp >= 500).length,
);
});
it('can pause at any time offset', async () => {
const actionLength = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(2500);
replayer['timer']['actions'].length;
`);
const currentTime = await page.evaluate(`
replayer.getCurrentTime();
`);
const currentState = await page.evaluate(`
replayer['service']['state']['value'];
`);
expect(actionLength).toEqual(0);
expect(currentTime).toEqual(2500);
expect(currentState).toEqual('paused');
});
it('can fast forward past StyleSheetRule changes on virtual elements', async () => {
await page.evaluate(`events = ${JSON.stringify(styleSheetRuleEvents)}`);
const actionLength = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.play(1500);
replayer['timer']['actions'].length;
`);
expect(actionLength).toEqual(
styleSheetRuleEvents.filter(
(e) => e.timestamp - styleSheetRuleEvents[0].timestamp >= 1500,
).length,
);
await assertDomSnapshot(page);
});
it('should apply fast forwarded StyleSheetRules that where added', async () => {
await page.evaluate(`events = ${JSON.stringify(styleSheetRuleEvents)}`);
const result = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(1500);
const rules = [...replayer.iframe.contentDocument.styleSheets].map(
(sheet) => [...sheet.rules],
).flat();
rules.some((x) => x.selectorText === '.css-added-at-1000-deleted-at-2500');
`);
expect(result).toEqual(true);
});
it('can handle removing style elements', async () => {
await page.evaluate(`events = ${JSON.stringify(stylesheetRemoveEvents)}`);
const actionLength = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.play(2500);
replayer['timer']['actions'].length;
`);
expect(actionLength).toEqual(
stylesheetRemoveEvents.filter(
(e) => e.timestamp - stylesheetRemoveEvents[0].timestamp >= 2500,
).length,
);
await assertDomSnapshot(page);
});
it('can handle remote stylesheets', async () => {
await page.evaluate(`events = ${JSON.stringify(remoteStyleSheetEvents)}`);
const actionLength = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.play(2500);
replayer['timer']['actions'].length;
`);
expect(actionLength).toEqual(
remoteStyleSheetEvents.filter(
(e) => e.timestamp - remoteStyleSheetEvents[0].timestamp >= 2500,
).length,
);
await assertDomSnapshot(page);
});
it('can fast forward selection events', async () => {
await page.evaluate(`events = ${JSON.stringify(selectionEvents)}`);
/** check the first selection event */
let [startOffset, endOffset] = (await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(360);
var range = replayer.iframe.contentDocument.getSelection().getRangeAt(0);
[range.startOffset, range.endOffset];
`)) as [startOffset: number, endOffset: number];
expect(startOffset).toEqual(5);
expect(endOffset).toEqual(15);
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
/** check the second selection event */
[startOffset, endOffset] = (await page.evaluate(`
replayer.pause(410);
var range = replayer.iframe.contentDocument.getSelection().getRangeAt(0);
[range.startOffset, range.endOffset];
`)) as [startOffset: number, endOffset: number];
expect(startOffset).toEqual(11);
expect(endOffset).toEqual(6);
});
it('can fast forward past StyleSheetRule deletion on virtual elements', async () => {
await page.evaluate(`events = ${JSON.stringify(styleSheetRuleEvents)}`);
const actionLength = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(2600);
replayer['timer']['actions'].length;
`);
await assertDomSnapshot(page);
});
it('should delete fast forwarded StyleSheetRules that where removed', async () => {
await page.evaluate(`events = ${JSON.stringify(styleSheetRuleEvents)}`);
const result = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(3000);
const rules = [...replayer.iframe.contentDocument.styleSheets].map(
(sheet) => [...sheet.rules],
).flat();
rules.some((x) => x.selectorText === '.css-added-at-1000-deleted-at-2500');
`);
expect(result).toEqual(false);
});
it("should overwrite all StyleSheetRules by replacing style element's textContent while fast-forwarding", async () => {
await page.evaluate(`events = ${JSON.stringify(styleSheetRuleEvents)}`);
const result = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(3500);
const rules = [...replayer.iframe.contentDocument.styleSheets].map(
(sheet) => [...sheet.rules],
).flat();
rules.some((x) => x.selectorText === '.css-added-at-200-overwritten-at-3000');
`);
expect(result).toEqual(false);
});
it('should apply fast-forwarded StyleSheetRules that came after stylesheet textContent overwrite', async () => {
await page.evaluate(`events = ${JSON.stringify(styleSheetRuleEvents)}`);
const result = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(3500);
const rules = [...replayer.iframe.contentDocument.styleSheets].map(
(sheet) => [...sheet.rules],
).flat();
rules.some((x) => x.selectorText === '.css-added-at-3100') &&
!rules.some(
(x) => x.selectorText === '.css-added-at-500-overwritten-at-3000',
);
`);
expect(result).toEqual(true);
});
it('should overwrite all StyleSheetRules by appending a text node to stylesheet element while fast-forwarding', async () => {
await page.evaluate(`events = ${JSON.stringify(StyleSheetTextMutation)}`);
const result = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(1600);
const rules = [...replayer.iframe.contentDocument.styleSheets].map(
(sheet) => [...sheet.rules],
).flat();
rules.some((x) => x.selectorText === '.css-added-at-1000-overwritten-at-1500');
`);
expect(result).toEqual(false);
});
it('should apply fast-forwarded StyleSheetRules that came after appending text node to stylesheet element', async () => {
await page.evaluate(`events = ${JSON.stringify(StyleSheetTextMutation)}`);
const result = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(2100);
const rules = [...replayer.iframe.contentDocument.styleSheets].map(
(sheet) => [...sheet.rules],
).flat();
rules.some((x) => x.selectorText === '.css-added-at-2000-overwritten-at-2500');
`);
expect(result).toEqual(true);
});
it('should overwrite all StyleSheetRules by removing text node from stylesheet element while fast-forwarding', async () => {
await page.evaluate(`events = ${JSON.stringify(StyleSheetTextMutation)}`);
const result = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(2600);
const rules = [...replayer.iframe.contentDocument.styleSheets].map(
(sheet) => [...sheet.rules],
).flat();
rules.some((x) => x.selectorText === '.css-added-at-2000-overwritten-at-2500');
`);
expect(result).toEqual(false);
});
it('should apply fast-forwarded StyleSheetRules that came after removing text node from stylesheet element', async () => {
await page.evaluate(`events = ${JSON.stringify(StyleSheetTextMutation)}`);
const result = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(3100);
const rules = [...replayer.iframe.contentDocument.styleSheets].map(
(sheet) => [...sheet.rules],
).flat();
rules.some((x) => x.selectorText === '.css-added-at-3000');
`);
expect(result).toEqual(true);
});
it('can fast forward scroll events', async () => {
await page.evaluate(`
events = ${JSON.stringify(scrollEvents)};
const { Replayer } = rrweb;
var replayer = new Replayer(events,{showDebug:true});
replayer.pause(550);
`);
// add the "#container" element at 500
const iframe = await page.$('iframe');
const contentDocument = await iframe!.contentFrame()!;
expect(await contentDocument!.$('#container')).not.toBeNull();
expect(await contentDocument!.$('#block')).not.toBeNull();
expect(
await contentDocument!.$eval(
'#container',
(element: Element) => element.scrollTop,
),
).toEqual(0);
// restart the replayer
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(1050);');
// scroll the "#container" div' at 1000
expect(
await contentDocument!.$eval(
'#container',
(element: Element) => element.scrollTop,
),
).toEqual(2500);
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(1550);');
// scroll the document at 1500
expect(
await page.$eval(
'iframe',
(element: Element) =>
(element as HTMLIFrameElement)!.contentWindow!.scrollY,
),
).toEqual(250);
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(2050);');
// remove the "#container" element at 2000
expect(await contentDocument!.$('#container')).toBeNull();
expect(await contentDocument!.$('#block')).toBeNull();
expect(
await page.$eval(
'iframe',
(element: Element) =>
(element as HTMLIFrameElement)!.contentWindow!.scrollY,
),
).toEqual(0);
});
it('can fast forward scroll events w/ a parent node that affects a child nodes height', async () => {
await page.evaluate(`
events = ${JSON.stringify(scrollWithParentStylesEvents)};
const { Replayer } = rrweb;
var replayer = new Replayer(events,{showDebug:true});
replayer.pause(550);
`);
// add the ".container" element at 500
const iframe = await page.$('iframe');
const contentDocument = await iframe!.contentFrame()!;
expect(await contentDocument!.$('.container')).not.toBeNull();
expect(
await contentDocument!.$eval(
'.container',
(element: Element) => element.scrollTop,
),
).toEqual(0);
// restart the replayer
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(1050);');
// scroll the ".container" div' at 1000
expect(
await contentDocument!.$eval(
'.container',
(element: Element) => element.scrollTop,
),
).toEqual(800);
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(2050);');
// remove the ".container" element at 2000
expect(await contentDocument!.$('.container')).toBeNull();
expect(
await page.$eval(
'iframe',
(element: Element) =>
(element as HTMLIFrameElement)!.contentWindow!.scrollY,
),
).toEqual(0);
});
it('can fast forward input events', async () => {
await page.evaluate(`
events = ${JSON.stringify(inputEvents)};
const { Replayer } = rrweb;
var replayer = new Replayer(events,{showDebug:true});
replayer.pause(1050);
`);
const iframe = await page.$('iframe');
const contentDocument = await iframe!.contentFrame()!;
expect(await contentDocument!.$('select')).not.toBeNull();
expect(
await contentDocument!.$eval(
'select',
(element: Element) => (element as HTMLSelectElement).value,
),
).toEqual('valueB'); // the default value
// restart the replayer
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(1550);');
// the value get changed to 'valueA' at 1500
expect(
await contentDocument!.$eval(
'select',
(element: Element) => (element as HTMLSelectElement).value,
),
).toEqual('valueA');
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(2050);');
// the value get changed to 'valueC' at 2000
expect(
await contentDocument!.$eval(
'select',
(element: Element) => (element as HTMLSelectElement).value,
),
).toEqual('valueC');
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(2550);');
// add a new input element at 2500
expect(
await contentDocument!.$eval(
'input',
(element: Element) => (element as HTMLSelectElement).value,
),
).toEqual('');
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(3050);');
// set the value 'test input' for the input element at 3000
expect(
await contentDocument!.$eval(
'input',
(element: Element) => (element as HTMLSelectElement).value,
),
).toEqual('test input');
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(3550);');
// remove the select element at 3500
expect(await contentDocument!.$('select')).toBeNull();
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(4050);');
// remove the input element at 4000
expect(await contentDocument!.$('input')).toBeNull();
});
it('can fast-forward mutation events containing nested iframe elements', async () => {
await page.evaluate(`
events = ${JSON.stringify(iframeEvents)};
const { Replayer } = rrweb;
var replayer = new Replayer(events,{showDebug:true});
replayer.pause(250);
`);
const iframe = await page.$('iframe');
const contentDocument = await iframe!.contentFrame()!;
expect(await contentDocument!.$('iframe')).toBeNull();
// restart the replayer
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(550);'); // add 'iframe one' at 500
expect(await contentDocument!.$('iframe')).not.toBeNull();
let iframeOneDocument = await (await contentDocument!.$(
'iframe',
))!.contentFrame();
expect(iframeOneDocument).not.toBeNull();
expect(await iframeOneDocument!.$('noscript')).not.toBeNull();
// make sure custom style rules are inserted rules
expect((await iframeOneDocument!.$$('style')).length).toBe(1);
expect(
await iframeOneDocument!.$eval(
'noscript',
(element) => window.getComputedStyle(element).display,
),
).toEqual('none');
// add 'iframe two' and 'iframe three' at 1000
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(1050);');
// check the inserted style of iframe 'one' again
iframeOneDocument = await (await contentDocument!.$(
'iframe',
))!.contentFrame();
expect((await iframeOneDocument!.$$('style')).length).toBe(1);
expect((await contentDocument!.$$('iframe')).length).toEqual(2);
let iframeTwoDocument = await (
await contentDocument!.$$('iframe')
)[1]!.contentFrame();
expect(iframeTwoDocument).not.toBeNull();
expect((await iframeTwoDocument!.$$('iframe')).length).toEqual(2);
expect((await iframeTwoDocument!.$$('style')).length).toBe(1);
const iframeThreeDocument = await (
await iframeTwoDocument!.$$('iframe')
)[0]!.contentFrame();
let iframeFourDocument = await (
await iframeTwoDocument!.$$('iframe')
)[1]!.contentFrame();
expect(iframeThreeDocument).not.toBeNull();
expect((await iframeThreeDocument!.$$('style')).length).toBe(1);
expect(iframeFourDocument).not.toBeNull();
// add 'iframe four' at 1500
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(1550);');
iframeTwoDocument = await (
await contentDocument!.$$('iframe')
)[1]!.contentFrame();
expect((await iframeTwoDocument!.$$('style')).length).toBe(1);
iframeFourDocument = await (
await iframeTwoDocument!.$$('iframe')
)[1]!.contentFrame();
expect(await iframeFourDocument!.$('iframe')).toBeNull();
expect((await iframeFourDocument!.$$('style')).length).toBe(1);
expect(await iframeFourDocument!.title()).toEqual('iframe 4');
// add 'iframe five' at 2000
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(2050);');
iframeTwoDocument = await (
await contentDocument!.$$('iframe')
)[1]!.contentFrame();
iframeFourDocument = await (
await iframeTwoDocument!.$$('iframe')
)[1]!.contentFrame();
expect((await iframeFourDocument!.$$('style')).length).toBe(1);
expect(await iframeFourDocument!.$('iframe')).not.toBeNull();
const iframeFiveDocument = await (await iframeFourDocument!.$(
'iframe',
))!.contentFrame();
expect(iframeFiveDocument).not.toBeNull();
expect((await iframeFiveDocument!.$$('style')).length).toBe(1);
expect(await iframeFiveDocument!.$('noscript')).not.toBeNull();
expect(
await iframeFiveDocument!.$eval(
'noscript',
(element) => window.getComputedStyle(element).display,
),
).toEqual('none');
// remove the html element of 'iframe four' at 2500
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(2550);');
iframeTwoDocument = await (
await contentDocument!.$$('iframe')
)[1]!.contentFrame();
iframeFourDocument = await (
await iframeTwoDocument!.$$('iframe')
)[1]!.contentFrame();
// the html element should be removed
expect(await iframeFourDocument!.$('html')).toBeNull();
// the doctype should still exist
expect(
await iframeTwoDocument!.evaluate(
(iframe) => (iframe as HTMLIFrameElement)!.contentDocument!.doctype,
(
await iframeTwoDocument!.$$('iframe')
)[1],
),
).not.toBeNull();
});
it('can fast-forward mutation events containing nested shadow doms', async () => {
await page.evaluate(`
events = ${JSON.stringify(shadowDomEvents)};
const { Replayer } = rrweb;
var replayer = new Replayer(events,{showDebug:true});
replayer.pause(550);
`);
// add shadow dom 'one' at 500
const iframe = await page.$('iframe');
const contentDocument = await iframe!.contentFrame()!;
expect(
await contentDocument!.$eval('div', (element) => element.shadowRoot),
).not.toBeNull();
expect(
await contentDocument!.evaluate(
() =>
document
.querySelector('body > div')!
.shadowRoot!.querySelector('span')!.textContent,
),
).toEqual('shadow dom one');
// add shadow dom 'two' at 1000
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(1050);');
expect(
await contentDocument!.evaluate(
() =>
document
.querySelector('body > div')!
.shadowRoot!.querySelector('div')!.shadowRoot,
),
).not.toBeNull();
expect(
await contentDocument!.evaluate(
() =>
document
.querySelector('body > div')!
.shadowRoot!.querySelector('div')!
.shadowRoot!.querySelector('span')!.textContent,
),
).toEqual('shadow dom two');
});
it('can fast-forward mutation events containing painted canvas in iframe', async () => {
await page.evaluate(`
events = ${JSON.stringify(canvasInIframe)};
const { Replayer } = rrweb;
var replayer = new Replayer(events,{showDebug:true});
replayer.pause(550);
`);
const replayerIframe = await page.$('iframe');
const contentDocument = await replayerIframe!.contentFrame()!;
const iframe = await contentDocument!.$('iframe');
expect(iframe).not.toBeNull();
const docInIFrame = await iframe?.contentFrame();
expect(docInIFrame).not.toBeNull();
const canvasElements = await docInIFrame!.$$('canvas');
// The first canvas is a blank one and the second is a painted one.
expect(canvasElements.length).toEqual(2);
const dataUrls = await docInIFrame?.$$eval('canvas', (elements) =>
elements.map((element) => (element as HTMLCanvasElement).toDataURL()),
);
expect(dataUrls?.length).toEqual(2);
// The painted canvas's data should not be empty.
expect(dataUrls![1]).not.toEqual(dataUrls![0]);
});
it('can stream events in live mode', async () => {
const status = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events, {
liveMode: true
});
replayer.startLive();
replayer.service.state.value;
`);
expect(status).toEqual('live');
});
it("shouldn't trigger ReplayerEvents.Finish in live mode", async () => {
const status = await page.evaluate((FinishState) => {
return new Promise((resolve) => {
const win = window as IWindow;
let triggeredFinish = false;
const { Replayer } = win.rrweb;
const replayer = new Replayer([], {
liveMode: true,
});
replayer.on(FinishState, () => {
triggeredFinish = true;
});
replayer.startLive();
replayer.addEvent(win.events[0]);
requestAnimationFrame(() => {
resolve(triggeredFinish);
});
});
}, ReplayerEvents.Finish);
expect(status).toEqual(false);
});
it('replays same timestamp events in correct order', async () => {
await page.evaluate(`events = ${JSON.stringify(orderingEvents)}`);
await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.play();
`);
await page.waitForTimeout(50);
await assertDomSnapshot(page);
});
it('replays same timestamp events in correct order (with addAction)', async () => {
await page.evaluate(`events = ${JSON.stringify(orderingEvents)}`);
await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events.slice(0, events.length-2));
replayer.play();
replayer.addEvent(events[events.length-2]);
replayer.addEvent(events[events.length-1]);
`);
await page.waitForTimeout(50);
await assertDomSnapshot(page);
});
it('should destroy the replayer after calling destroy()', async () => {
await page.evaluate(`events = ${JSON.stringify(events)}`);
await page.evaluate(`
const { Replayer } = rrweb;
let replayer = new Replayer(events);
replayer.play();
`);
const replayerWrapperClassName = 'replayer-wrapper';
let wrapper = await page.$(`.${replayerWrapperClassName}`);
expect(wrapper).not.toBeNull();
await page.evaluate(`replayer.destroy(); replayer = null;`);
wrapper = await page.$(`.${replayerWrapperClassName}`);
expect(wrapper).toBeNull();
});
it('can replay adopted stylesheet events', async () => {
await page.evaluate(`
events = ${JSON.stringify(adoptedStyleSheet)};
const { Replayer } = rrweb;
var replayer = new Replayer(events,{showDebug:true});
replayer.play();
`);
await page.waitForTimeout(600);
const iframe = await page.$('iframe');
const contentDocument = await iframe!.contentFrame()!;
const colorRGBMap = {
yellow: 'rgb(255, 255, 0)',
red: 'rgb(255, 0, 0)',
blue: 'rgb(0, 0, 255)',
green: 'rgb(0, 128, 0)',
};
const checkCorrectness = async () => {
// check the adopted stylesheet is applied on the outermost document
expect(
await contentDocument!.$eval(
'div',
(element) => window.getComputedStyle(element).color,
),
).toEqual(colorRGBMap.yellow);
// check the adopted stylesheet is applied on the shadow dom #1's root
expect(
await contentDocument!.evaluate(
() =>
window.getComputedStyle(
document
.querySelector('#shadow-host1')!
.shadowRoot!.querySelector('span')!,
).color,
),
).toEqual(colorRGBMap.red);
// check the adopted stylesheet is applied on document of the IFrame element
expect(
await contentDocument!.$eval(
'iframe',
(element) =>
window.getComputedStyle(
(element as HTMLIFrameElement).contentDocument!.querySelector(
'h1',
)!,
).color,
),
).toEqual(colorRGBMap.blue);
// check the adopted stylesheet is applied on the shadow dom #2's root
expect(
await contentDocument!.evaluate(
() =>
window.getComputedStyle(
document
.querySelector('#shadow-host2')!
.shadowRoot!.querySelector('span')!,
).color,
),
).toEqual(colorRGBMap.green);
};
await checkCorrectness();
// To test the correctness of replaying adopted stylesheet events in the fast-forward mode.
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(600);');
await checkCorrectness();
});
it('can replay modification events for adoptedStyleSheet', async () => {
await page.evaluate(`
events = ${JSON.stringify(adoptedStyleSheetModification)};
const { Replayer } = rrweb;
var replayer = new Replayer(events,{showDebug:true});
replayer.pause(0);
async function playTill(offsetTime) {
replayer.play();
return new Promise((resolve) => {
const checkTime = () => {
if (replayer.getCurrentTime() >= offsetTime) {
replayer.pause();
resolve(undefined);
} else {
requestAnimationFrame(checkTime);
}
};
checkTime();
});
}`);
const iframe = await page.$('iframe');
const contentDocument = await iframe!.contentFrame()!;
// At 250ms, the adopted stylesheet is still empty.
const check250ms = async () => {
expect(
await contentDocument!.evaluate(
() =>
document.adoptedStyleSheets.length === 1 &&
document.adoptedStyleSheets[0].cssRules.length === 0,
),
).toBeTruthy();
expect(
await contentDocument!.evaluate(
() =>
document.querySelector('iframe')!.contentDocument!
.adoptedStyleSheets.length === 1 &&
document.querySelector('iframe')!.contentDocument!
.adoptedStyleSheets[0].cssRules.length === 0,
),
).toBeTruthy();
};
// At 300ms, the adopted stylesheet is replaced with new content.
const check300ms = async () => {
expect(
await contentDocument!.evaluate(
() =>
document.adoptedStyleSheets[0].cssRules.length === 1 &&
document.adoptedStyleSheets[0].cssRules[0].cssText ===
'div { color: yellow; }',
),
).toBeTruthy();
expect(
await contentDocument!.evaluate(
() =>
document.querySelector('iframe')!.contentDocument!
.adoptedStyleSheets[0].cssRules.length === 1 &&
document.querySelector('iframe')!.contentDocument!
.adoptedStyleSheets[0].cssRules[0].cssText ===
'h1 { color: blue; }',
),
).toBeTruthy();
};
// At 400ms, check replaceSync API.
const check400ms = async () => {
expect(
await contentDocument!.evaluate(
() =>
document.adoptedStyleSheets[0].cssRules.length === 1 &&
document.adoptedStyleSheets[0].cssRules[0].cssText ===
'div { display: inline; }',
),
).toBeTruthy();
expect(
await contentDocument!.evaluate(
() =>
document.querySelector('iframe')!.contentDocument!
.adoptedStyleSheets[0].cssRules.length === 1 &&
document.querySelector('iframe')!.contentDocument!
.adoptedStyleSheets[0].cssRules[0].cssText ===
'h1 { font-size: large; }',
),
).toBeTruthy();
};
// At 500ms, check CSSStyleDeclaration API.
const check500ms = async () => {
expect(
await contentDocument!.evaluate(
() =>
document.adoptedStyleSheets[0].cssRules.length === 1 &&
document.adoptedStyleSheets[0].cssRules[0].cssText ===
'div { color: green; }',
),
).toBeTruthy();
expect(
await contentDocument!.evaluate(
() =>
document.querySelector('iframe')!.contentDocument!
.adoptedStyleSheets[0].cssRules.length === 2 &&
document.querySelector('iframe')!.contentDocument!
.adoptedStyleSheets[0].cssRules[0].cssText ===
'h2 { color: red; }' &&
document.querySelector('iframe')!.contentDocument!
.adoptedStyleSheets[0].cssRules[1].cssText ===
'h1 { font-size: medium !important; }',
),
).toBeTruthy();
};
// At 600ms, check insertRule and deleteRule API.
const check600ms = async () => {
expect(
await contentDocument!.evaluate(
() =>
document.adoptedStyleSheets[0].cssRules.length === 2 &&
document.adoptedStyleSheets[0].cssRules[0].cssText ===
'div { color: green; }' &&
document.adoptedStyleSheets[0].cssRules[1].cssText ===
'body { border: 2px solid blue; }',
),
).toBeTruthy();
expect(
await contentDocument!.evaluate(
() =>
document.querySelector('iframe')!.contentDocument!
.adoptedStyleSheets[0].cssRules.length === 1 &&
document.querySelector('iframe')!.contentDocument!
.adoptedStyleSheets[0].cssRules[0].cssText ===
'h1 { font-size: medium !important; }',
),
).toBeTruthy();
};
await page.evaluate(`playTill(250)`);
await check250ms();
await page.evaluate(`playTill(300)`);
await check300ms();
await page.evaluate(`playTill(400)`);
await check400ms();
await page.evaluate(`playTill(500)`);
await check500ms();
await page.evaluate(`playTill(600)`);
await check600ms();
// To test the correctness of replaying adopted stylesheet mutation events in the fast-forward mode.
await page.evaluate('replayer.play(0);');
await waitForRAF(page);
await page.evaluate('replayer.pause(280);');
await check250ms();
await page.evaluate('replayer.pause(330);');
await check300ms();
await page.evaluate('replayer.pause(430);');
await check400ms();
await page.evaluate('replayer.pause(530);');
await check500ms();
await page.evaluate('replayer.pause(630);');
await check600ms();
});
it('can clear adopted stylesheets with empty replaceSync', async () => {
await page.evaluate(`
events = ${JSON.stringify(emptyReplaceSyncEvents)};
const { Replayer } = rrweb;
var replayer = new Replayer(events, { showDebug: true });
replayer.pause(0);
`);
const iframe = await page.$('iframe');
const contentDocument = await iframe!.contentFrame()!;
// At 250ms, stylesheet should have rules
await page.evaluate('replayer.pause(250);');
expect(
await contentDocument!.evaluate(
() =>
document.adoptedStyleSheets.length === 1 &&
document.adoptedStyleSheets[0].cssRules.length === 1,
),
).toBeTruthy();
// At 350ms, stylesheet should be empty after replaceSync('')
await page.evaluate('replayer.pause(350);');
expect(
await contentDocument!.evaluate(
() =>
document.adoptedStyleSheets.length === 1 &&
document.adoptedStyleSheets[0].cssRules.length === 0,
),
).toBeTruthy();
});
it('can clear adopted stylesheets with empty replace', async () => {
await page.evaluate(`
events = ${JSON.stringify(emptyReplaceEvents)};
const { Replayer } = rrweb;
var replayer = new Replayer(events, { showDebug: true });
replayer.pause(0);
`);
const iframe = await page.$('iframe');
const contentDocument = await iframe!.contentFrame()!;
// At 250ms, stylesheet should have rules
await page.evaluate('replayer.pause(250);');
expect(
await contentDocument!.evaluate(
() =>
document.adoptedStyleSheets.length === 1 &&
document.adoptedStyleSheets[0].cssRules.length === 1,
),
).toBeTruthy();
// At 350ms, stylesheet should be empty after replace('')
await page.evaluate('replayer.pause(350);');
await contentDocument!.waitForFunction(
() =>
document.adoptedStyleSheets.length === 1 &&
document.adoptedStyleSheets[0].cssRules.length === 0,
);
});
it('can replay StyleDeclaration events on nested CSS rules inside @media', async () => {
await page.evaluate(`
events = ${JSON.stringify(nestedStyleDeclarationEvents)};
const { Replayer } = rrweb;
var replayer = new Replayer(events, { showDebug: true });
replayer.pause(0);
`);
// At 250ms, setProperty on [0, 0] should change background-color to red
const bgColorAfterSet = await page.evaluate(`
replayer.pause(250);
const doc1 = replayer.iframe.contentDocument;
const styleElement1 = doc1.querySelector('head style');
const sheet1 = styleElement1?.sheet;
if (!sheet1 || sheet1.cssRules.length === 0) 'no sheet';
const mediaRule1 = sheet1.cssRules[0];
if (!mediaRule1 || !mediaRule1.cssRules) 'no media rule';
const nestedRule1 = mediaRule1.cssRules[0];
nestedRule1?.style?.backgroundColor || 'no bg color';
`);
expect(bgColorAfterSet).toBe('red');
// At 350ms, setProperty on [0, 1] should add font-weight: bold
const fontWeightAfterSet = await page.evaluate(`
replayer.pause(350);
const doc2 = replayer.iframe.contentDocument;
const styleElement2 = doc2.querySelector('head style');
const sheet2 = styleElement2?.sheet;
if (!sheet2) 'no sheet';
const mediaRule2 = sheet2.cssRules[0];
const secondRule2 = mediaRule2?.cssRules?.[1];
secondRule2?.style?.fontWeight || 'no font weight';
`);
expect(fontWeightAfterSet).toBe('bold');
// At 450ms, removeProperty on [0, 0] should remove width
const widthAfterRemove = await page.evaluate(`
replayer.pause(450);
const doc3 = replayer.iframe.contentDocument;
const styleElement3 = doc3.querySelector('head style');
const sheet3 = styleElement3?.sheet;
if (!sheet3) 'has width';
const mediaRule3 = sheet3.cssRules[0];
const nestedRule3 = mediaRule3?.cssRules?.[0];
nestedRule3?.style?.width || '';
`);
expect(widthAfterRemove).toBe('');
// At 550ms, setProperty on deeply nested [1, 0, 0] should change background-color to purple
const deepBgColorAfterSet = await page.evaluate(`
replayer.pause(550);
const doc4 = replayer.iframe.contentDocument;
const styleElement4 = doc4.querySelector('head style');
const sheet4 = styleElement4?.sheet;
if (!sheet4 || sheet4.cssRules.length < 2) 'no sheet';
const supportsRule4 = sheet4.cssRules[1];
const mediaRule4 = supportsRule4?.cssRules?.[0];
const deepRule4 = mediaRule4?.cssRules?.[0];
deepRule4?.style?.backgroundColor || 'no bg color';
`);
expect(deepBgColorAfterSet).toBe('purple');
// At 650ms, removeProperty on [1, 0, 0] should remove background-color
const deepBgColorAfterRemove = await page.evaluate(`
replayer.pause(650);
const doc5 = replayer.iframe.contentDocument;
const styleElement5 = doc5.querySelector('head style');
const sheet5 = styleElement5?.sheet;
if (!sheet5 || sheet5.cssRules.length < 2) 'has bg';
const supportsRule5 = sheet5.cssRules[1];
const mediaRule5 = supportsRule5?.cssRules?.[0];
const deepRule5 = mediaRule5?.cssRules?.[0];
deepRule5?.style?.backgroundColor || '';
`);
expect(deepBgColorAfterRemove).toBe('');
});
it('should not crash when StyleDeclaration references non-existent rules', async () => {
/**
* This test verifies that the replayer gracefully handles StyleDeclaration
* events that reference rules which don't exist in the stylesheet.
*
* This can happen due to:
* - Timing issues where StyleDeclaration arrives before StyleSheetRule
* - Dynamic stylesheets that aren't fully synchronized
* - Event ordering issues during recording
*
* The replayer should silently skip these instead of crashing.
*/
await page.evaluate(
`events = ${JSON.stringify(styleDeclarationMissingRuleEvents)}`,
);
// Should not throw any errors
const result = await page.evaluate(`
try {
const { Replayer } = rrweb;
const replayer = new Replayer(events, { showDebug: true });
replayer.pause(500); // After all StyleDeclaration events
'success';
} catch (e) {
'error: ' + e.message;
}
`);
expect(result).toBe('success');
// Verify the existing rule still works (wasn't corrupted)
const existingRuleColor = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(500);
const doc = replayer.iframe.contentDocument;
const style = doc.querySelector('head style');
const sheet = style?.sheet;
const rule = sheet?.cssRules?.[0];
rule?.style?.color || 'no color';
`);
expect(existingRuleColor).toBe('blue');
});
it('should replay document replacement events without warnings or errors', async () => {
await page.evaluate(
`events = ${JSON.stringify(documentReplacementEvents)}`,
);
const warningThrown = vi.fn();
page.on('console', warningThrown);
const errorThrown = vi.fn();
page.on('pageerror', errorThrown);
await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.play(500);
`);
await waitForRAF(page);
// No warnings should be logged.
expect(warningThrown).not.toHaveBeenCalled();
// No errors should be thrown.
expect(errorThrown).not.toHaveBeenCalled();
});
it('should remove outdated hover styles in iframes and shadow doms', async () => {
await page.evaluate(`events = ${JSON.stringify(hoverInIframeShadowDom)}`);
await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(550);
`);
const replayerIframe = await page.$('iframe');
const contentDocument = await replayerIframe!.contentFrame()!;
const iframe = await contentDocument!.$('iframe');
expect(iframe).not.toBeNull();
const docInIFrame = await iframe?.contentFrame();
expect(docInIFrame).not.toBeNull();
// hover element in iframe at 500ms
expect(
await docInIFrame?.evaluate(
() => document.querySelector('span')?.className,
),
).toBe(':hover');
// At this time, there should be no class name in shadow dom
expect(
await docInIFrame?.evaluate(() => {
const shadowRoot = document.querySelector('div')?.shadowRoot;
return (shadowRoot?.childNodes[0] as HTMLElement).className;
}),
).toBe('');
// hover element in shadow dom at 1000ms
await page.evaluate('replayer.pause(1050);');
// :hover style should be removed from iframe
expect(
await docInIFrame?.evaluate(
() => document.querySelector('span')?.className,
),
).toBe('');
expect(
await docInIFrame?.evaluate(() => {
const shadowRoot = document.querySelector('div')?.shadowRoot;
return (shadowRoot?.childNodes[0] as HTMLElement).className;
}),
).toBe(':hover');
// hover element in iframe at 1500ms again
await page.evaluate('replayer.pause(1550);');
// hover style should be removed from shadow dom
expect(
await docInIFrame?.evaluate(() => {
const shadowRoot = document.querySelector('div')?.shadowRoot;
return (shadowRoot?.childNodes[0] as HTMLElement).className;
}),
).toBe('');
expect(
await docInIFrame?.evaluate(
() => document.querySelector('span')?.className,
),
).toBe(':hover');
});
it('should replay styles with :define pseudo-class', async () => {
await page.evaluate(`events = ${JSON.stringify(customElementDefineClass)}`);
const displayValue = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(200);
const customElement = replayer.iframe.contentDocument.querySelector('custom-element');
window.getComputedStyle(customElement).display;
`);
// If the custom element is not defined, the display value will be 'none'.
// If the custom element is defined, the display value will be 'block'.
expect(displayValue).toEqual('block');
});
it('can deal with legacy duplicate/conflicting values on textareas', async () => {
await page.evaluate(`events = ${JSON.stringify(badTextareaEvents)}`);
const displayValue = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(100);
const textarea = replayer.iframe.contentDocument.querySelector('textarea');
textarea.value;
`);
// If the custom element is not defined, the display value will be 'none'.
// If the custom element is defined, the display value will be 'block'.
expect(displayValue).toEqual('this value is used for replay');
});
it('can deal with duplicate/conflicting values on style elements', async () => {
await page.evaluate(`events = ${JSON.stringify(badStyleEvents)}`);
const changedColors = await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(1000);
// Get the color of the elements after applying the style mutation event
[
replayer.iframe.contentWindow.getComputedStyle(
replayer.iframe.contentDocument.querySelector('#one'),
).color,
replayer.iframe.contentWindow.getComputedStyle(
replayer.iframe.contentDocument.querySelector('#two'),
).color,
];
`);
const newColor = 'rgb(255, 255, 0)'; // yellow
expect(changedColors).toEqual([newColor, newColor]);
});
});