Files
rrweb/packages/rrweb/test/replayer.test.ts
Yun Feng 3809060684 feat: add support for recording and replaying adoptedStyleSheets API (#989)
* test(recording side): add test case for adopted stylesheets in shadow doms and iframe

* add type definition for adopted StyleSheets

* create a StyleSheet Mirror

* enable to record the outermost document's adoptedStyleSheet

* enable to serialize all stylesheets in documents (iframe) and shadow roots

* enable to record adopted stylesheets while building full snapshot

* test: add test case for mutations on adoptedStyleSheets

* defer to record adoptedStyleSheets to avoid create events before full snapshot

* feat: enable to track the mutation of AdoptedStyleSheets

* Merge branch 'fix-shadowdom-record' into construct-style

* fix: incorrect id conditional judgement

* test: add a test case for replaying side

* tweak the style mirror for replayer

* feat: enable to replay adoptedStyleSheet events

* fix: rule index wasn't recorded when serializing the adoptedStyleSheets

* add test case for mutation of stylesheet objects and add support for replace & replaceSync

* refactor: improve the code quality

* feat: monkey patch adoptedStyleSheet API to track its modification

* feat: add support for checkouting fullsnapshot

* CI: fix failed type checks

* test: add test case for nested shadow doms and iframe elements

* feat: add support for adoptedStyleSheets in VirtualDom mode

* style: format files

* test: improve the robustness of the test case

* CI: fix an eslint error

* test: improve the robustness of the test case

* fix: adoptedStyleSheets not applied in fast-forward mode (virtual dom optimization not used)

* refactor the data structure of adoptedStyleSheet event to make it more efficient and robust

* improve the robustness in the live mode

In the live mode where events are transferred over network without strict order guarantee, some newer events are applied before some old events and adopted stylesheets may haven't been created.
Added a retry mechanism to solve this problem.

* apply Yanzhen's review suggestion

* update action name

* test: make the test case more robust for travis CI

* Update packages/rrweb/src/record/constructableStyleSheets.d.ts

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>

* Update packages/rrweb/src/record/constructableStyleSheets.d.ts

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>

* apply Justin's review suggestions

add more browser compatibility checks

* add eslint-plugin-compat and config

* fix record test  type errors

* make Mirror's replace function act the same with the original one when there's no existed node to replace

* test: increase the robustness of test cases

* remove eslint disable in favor of feature detection

Early returns aren't supported yet unfortunately, otherwise this code would be cleaner https://github.com/amilajack/eslint-plugin-compat/issues/523

* Remove eslint-disable-next-line compat/compat

* Standardize browserslist and remove lint exceptions (#1010)

* test: revert deleting virtual style tests and rewrite them to fit the current code base

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>
2022-09-29 14:40:13 +08:00

953 lines
32 KiB
TypeScript

import * as fs from 'fs';
import * as path from 'path';
import type * as puppeteer from 'puppeteer';
import 'construct-style-sheets-polyfill';
import {
assertDomSnapshot,
launchPuppeteer,
sampleEvents as events,
sampleStyleSheetRemoveEvents as stylesheetRemoveEvents,
waitForRAF,
} from './utils';
import styleSheetRuleEvents from './events/style-sheet-rule-events';
import orderingEvents from './events/ordering';
import scrollEvents from './events/scroll';
import inputEvents from './events/input';
import iframeEvents from './events/iframe';
import selectionEvents from './events/selection';
import shadowDomEvents from './events/shadow-dom';
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';
interface ISuite {
code: string;
browser: puppeteer.Browser;
page: puppeteer.Page;
}
describe('replayer', function () {
jest.setTimeout(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.js');
code = fs.readFileSync(bundlePath, 'utf8');
});
beforeEach(async () => {
page = await browser.newPage();
await page.goto('about:blank');
await page.evaluate(code);
await page.evaluate(`let 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 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 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);
let 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('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.play();
`);
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.waitForTimeout(235);
await check250ms();
await page.waitForTimeout(50);
await check300ms();
await page.waitForTimeout(100);
await check400ms();
await page.waitForTimeout(100);
await check500ms();
await page.waitForTimeout(100);
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();
});
});