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>
This commit is contained in:
Yun Feng
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 65ff0efefd
commit 4e241acc6d
35 changed files with 3278 additions and 444 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,314 @@
import { EventType, eventWithTime, IncrementalSource } from '../../src/types';
const now = Date.now();
const events: eventWithTime[] = [
{
type: EventType.Meta,
data: {
href: 'about:blank',
width: 1920,
height: 1080,
},
timestamp: now,
},
{
type: EventType.FullSnapshot,
data: {
node: {
type: 0,
childNodes: [
{
type: 1,
name: 'html',
publicId: '',
systemId: '',
id: 2,
},
{
type: 2,
tagName: 'html',
attributes: {},
childNodes: [
{
type: 2,
tagName: 'head',
attributes: {},
childNodes: [],
id: 4,
},
{
type: 2,
tagName: 'body',
attributes: {},
childNodes: [
{
type: 3,
textContent: '\n ',
id: 6,
},
{
type: 2,
tagName: 'div',
attributes: {},
childNodes: [
{
type: 3,
textContent: 'div in outermost document',
id: 8,
},
],
id: 7,
},
{
type: 3,
textContent: ' \n ',
id: 9,
},
{
type: 2,
tagName: 'iframe',
attributes: {},
childNodes: [],
id: 10,
},
{
type: 3,
textContent: '\n ',
id: 11,
},
],
id: 5,
},
],
id: 3,
},
],
id: 1,
},
initialOffset: {
left: 0,
top: 0,
},
},
timestamp: now + 100,
},
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.AdoptedStyleSheet,
id: 1,
styleIds: [1],
styles: [
{
styleId: 1,
rules: [],
},
],
},
timestamp: now + 150,
},
{
type: EventType.IncrementalSnapshot,
data: {
source: 0,
adds: [
{
parentId: 10,
nextId: null,
node: {
type: 0,
childNodes: [
{
type: 2,
tagName: 'html',
attributes: {},
childNodes: [
{
type: 2,
tagName: 'head',
attributes: {},
childNodes: [],
rootId: 12,
id: 14,
},
{
type: 2,
tagName: 'body',
attributes: {},
childNodes: [
{
type: 2,
tagName: 'h1',
attributes: {},
childNodes: [
{
type: 3,
textContent: 'h1 in iframe',
rootId: 12,
id: 17,
},
],
rootId: 12,
id: 16,
},
],
rootId: 12,
id: 15,
},
],
rootId: 12,
id: 13,
},
],
compatMode: 'BackCompat',
id: 12,
},
},
],
removes: [],
texts: [],
attributes: [],
isAttachIframe: true,
},
timestamp: now + 200,
},
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.AdoptedStyleSheet,
id: 12,
styleIds: [2],
styles: [
{
rules: [],
styleId: 2,
},
],
},
timestamp: now + 250,
},
// use CSSStyleSheet.replace api
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.StyleSheetRule,
styleId: 1,
replace: 'div { color: yellow; }',
},
timestamp: now + 300,
},
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.StyleSheetRule,
styleId: 2,
replace: 'h1 { color: blue; }',
},
timestamp: now + 300,
},
// use CSSStyleSheet.replaceSync api
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.StyleSheetRule,
styleId: 1,
replaceSync: 'div { display: inline ; }',
},
timestamp: now + 400,
},
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.StyleSheetRule,
styleId: 2,
replaceSync: 'h1 { font-size: large; }',
},
timestamp: now + 400,
},
// use StyleDeclaration.setProperty api
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.StyleDeclaration,
styleId: 1,
set: {
property: 'color',
value: 'green',
priority: undefined,
},
index: [0],
},
timestamp: now + 500,
},
// use StyleDeclaration.removeProperty api
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.StyleDeclaration,
styleId: 1,
remove: {
property: 'display',
},
index: [0],
},
timestamp: now + 500,
},
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.StyleDeclaration,
styleId: 2,
set: {
property: 'font-size',
value: 'medium',
priority: 'important',
},
index: [0],
},
timestamp: now + 500,
},
// use CSSStyleSheet.insertRule api
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.StyleSheetRule,
styleId: 2,
adds: [
{
rule: 'h2 { color: red; }',
},
],
},
timestamp: now + 500,
},
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.StyleSheetRule,
styleId: 1,
adds: [
{
rule: 'body { border: 2px solid blue; }',
index: 1,
},
],
},
timestamp: now + 600,
},
// use CSSStyleSheet.deleteRule api
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.StyleSheetRule,
styleId: 2,
removes: [
{
index: 0,
},
],
},
timestamp: now + 600,
},
];
export default events;

View File

@@ -0,0 +1,354 @@
import { EventType, eventWithTime, IncrementalSource } from '../../src/types';
const now = Date.now();
const events: eventWithTime[] = [
{ type: EventType.DomContentLoaded, data: {}, timestamp: now },
{
type: EventType.Meta,
data: {
href: 'about:blank',
width: 1920,
height: 1080,
},
timestamp: now + 100,
},
{
type: EventType.FullSnapshot,
data: {
node: {
type: 0,
childNodes: [
{
type: 1,
name: 'html',
publicId: '',
systemId: '',
id: 2,
},
{
type: 2,
tagName: 'html',
attributes: {},
childNodes: [
{
type: 2,
tagName: 'head',
attributes: {},
childNodes: [],
id: 4,
},
{
type: 2,
tagName: 'body',
attributes: {},
childNodes: [
{
type: 3,
textContent: '\n ',
id: 6,
},
{
type: 2,
tagName: 'div',
attributes: {},
childNodes: [
{
type: 3,
textContent: 'div in outermost document',
id: 8,
},
],
id: 7,
},
{
type: 3,
textContent: '\n ',
id: 9,
},
{
type: 2,
tagName: 'div',
attributes: {
id: 'shadow-host1',
},
childNodes: [
{
type: 2,
tagName: 'div',
attributes: {},
childNodes: [
{
type: 3,
textContent: 'div in shadow dom 1',
id: 12,
},
],
id: 11,
isShadow: true,
},
{
type: 2,
tagName: 'span',
attributes: {},
childNodes: [
{
type: 3,
textContent: 'span in shadow dom 1',
id: 14,
},
],
id: 13,
isShadow: true,
},
],
id: 10,
isShadowHost: true,
},
{
type: 3,
textContent: '\n ',
id: 15,
},
{
type: 2,
tagName: 'div',
attributes: {
id: 'shadow-host2',
},
childNodes: [],
id: 16,
},
{
type: 3,
textContent: '\n ',
id: 17,
},
{
type: 2,
tagName: 'iframe',
attributes: {},
childNodes: [],
id: 18,
},
{
type: 3,
textContent: '\n ',
id: 19,
},
],
id: 5,
},
],
id: 3,
},
],
id: 1,
},
initialOffset: {
left: 0,
top: 0,
},
},
timestamp: now + 100,
},
// Adopt the stylesheet #1 on document at 200ms
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.AdoptedStyleSheet,
id: 1,
styleIds: [1],
styles: [
{
rules: [
{
rule: 'div { color: yellow; }',
},
],
styleId: 1,
},
],
},
timestamp: now + 200,
},
// Add an IFrame element
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
adds: [
{
parentId: 18,
nextId: null,
node: {
type: 0,
childNodes: [
{
type: 2,
tagName: 'html',
attributes: {},
childNodes: [
{
type: 2,
tagName: 'head',
attributes: {},
childNodes: [],
rootId: 20,
id: 22,
},
{
type: 2,
tagName: 'body',
attributes: {},
childNodes: [
{
type: 2,
tagName: 'h1',
attributes: {},
childNodes: [
{
type: 3,
textContent: 'h1 in iframe',
rootId: 20,
id: 25,
},
],
rootId: 20,
id: 24,
},
],
rootId: 20,
id: 23,
},
],
rootId: 20,
id: 21,
},
],
compatMode: 'BackCompat',
id: 20,
},
},
],
removes: [],
texts: [],
attributes: [],
isAttachIframe: true,
},
timestamp: now + 250,
},
// Adopt the stylesheet #2 on a shadow root at 300ms
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.AdoptedStyleSheet,
id: 10,
styleIds: [1, 2],
styles: [
{
rules: [
{
rule: 'span { color: red; }',
},
],
styleId: 2,
},
],
},
timestamp: now + 300,
},
// Adopt the stylesheet #3 on document of the IFrame element
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.AdoptedStyleSheet,
id: 20,
styleIds: [3],
styles: [
{
rules: [
{
rule: 'h1 { color: blue; }',
},
],
styleId: 3,
},
],
},
timestamp: now + 300,
},
// create a new shadow dom
{
type: EventType.IncrementalSnapshot,
data: {
source: 0,
texts: [],
attributes: [],
removes: [],
adds: [
{
parentId: 16,
nextId: null,
node: {
type: 2,
tagName: 'span',
attributes: {},
childNodes: [],
id: 26,
isShadow: true,
},
},
{
parentId: 26,
nextId: null,
node: {
type: 3,
textContent: 'span in shadow dom 2',
id: 27,
},
},
{
parentId: 16,
nextId: 26,
node: {
type: 2,
tagName: 'div',
attributes: {},
childNodes: [],
id: 28,
isShadow: true,
},
},
{
parentId: 28,
nextId: null,
node: {
type: 3,
textContent: 'div in shadow dom 2',
id: 29,
},
},
],
},
timestamp: now + 500,
},
// Adopt the stylesheet #4 on the shadow dom
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.AdoptedStyleSheet,
id: 16,
styleIds: [4],
styles: [
{
rules: [{ rule: 'span { color: green; }' }],
styleId: 4,
},
],
},
timestamp: now + 550,
},
];
export default events;

View File

@@ -1,6 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
import type * as puppeteer from 'puppeteer';
import 'construct-style-sheets-polyfill';
import {
recordOptions,
listenerHandler,
@@ -462,6 +463,113 @@ describe('record', function (this: ISuite) {
assertSnapshot(ctx.events);
});
it('captures mutations on adopted stylesheets', async () => {
await ctx.page.evaluate(() => {
document.body.innerHTML = `
<div>div in outermost document</div>
<iframe></iframe>
`;
const sheet = new CSSStyleSheet();
// Add stylesheet to a document.
document.adoptedStyleSheets = [sheet];
const iframe = document.querySelector('iframe');
const sheet2 = new (iframe!.contentWindow! as Window &
typeof globalThis).CSSStyleSheet();
// Add stylesheet to an IFrame document.
iframe!.contentDocument!.adoptedStyleSheets = [sheet2];
iframe!.contentDocument!.body.innerHTML = '<h1>h1 in iframe</h1>';
const { record } = ((window as unknown) as IWindow).rrweb;
record({
emit: ((window as unknown) as IWindow).emit,
});
setTimeout(() => {
sheet.replace!('div { color: yellow; }');
sheet2.replace!('h1 { color: blue; }');
}, 0);
setTimeout(() => {
sheet.replaceSync!('div { display: inline ; }');
sheet2.replaceSync!('h1 { font-size: large; }');
}, 5);
setTimeout(() => {
(sheet.cssRules[0] as CSSStyleRule).style.setProperty('color', 'green');
(sheet.cssRules[0] as CSSStyleRule).style.removeProperty('display');
(sheet2.cssRules[0] as CSSStyleRule).style.setProperty(
'font-size',
'medium',
'important',
);
sheet2.insertRule('h2 { color: red; }');
}, 10);
setTimeout(() => {
sheet.insertRule('body { border: 2px solid blue; }', 1);
sheet2.deleteRule(0);
}, 15);
});
await waitForRAF(ctx.page);
assertSnapshot(ctx.events);
});
it('captures adopted stylesheets in nested shadow doms and iframes', async () => {
await ctx.page.evaluate(() => {
document.body.innerHTML = `
<div id="shadow-host-1">entry</div>
`;
let shadowHost = document.querySelector('div')!;
shadowHost!.attachShadow({ mode: 'open' });
let iframeDocument: Document;
const NestedDepth = 4;
// construct nested shadow doms and iframe elements
for (let i = 1; i <= NestedDepth; i++) {
const shadowRoot = shadowHost.shadowRoot!;
const iframeElement = document.createElement('iframe');
shadowRoot.appendChild(iframeElement);
iframeElement.id = `iframe-${i}`;
iframeDocument = iframeElement.contentDocument!;
shadowHost = iframeDocument.createElement('div');
shadowHost.id = `shadow-host-${i + 1}`;
iframeDocument.body.append(shadowHost);
shadowHost!.attachShadow({ mode: 'open' });
}
const iframeWin = iframeDocument!.defaultView!;
const sheet1 = new iframeWin.CSSStyleSheet();
sheet1.replaceSync!('h1 {color: blue;}');
iframeDocument!.adoptedStyleSheets = [sheet1];
const sheet2 = new iframeWin.CSSStyleSheet();
sheet2.replaceSync!('div {font-size: large;}');
shadowHost.shadowRoot!.adoptedStyleSheets = [sheet2];
const { record } = ((window as unknown) as IWindow).rrweb;
record({
emit: ((window as unknown) as IWindow).emit,
});
setTimeout(() => {
sheet1.insertRule!('div { display: inline ; }', 1);
sheet2.replaceSync!('h1 { font-size: large; }');
}, 100);
setTimeout(() => {
const sheet3 = new iframeWin.CSSStyleSheet();
sheet3.replaceSync!('span {background-color: red;}');
iframeDocument!.adoptedStyleSheets = [sheet3, sheet2];
shadowHost.shadowRoot!.adoptedStyleSheets = [sheet1, sheet3];
}, 150);
});
await ctx.page.waitForTimeout(200);
assertSnapshot(ctx.events);
});
it('captures stylesheets in iframes with `blob:` url', async () => {
await ctx.page.evaluate(() => {
const iframe = document.createElement('iframe');
@@ -579,6 +687,73 @@ describe('record', function (this: ISuite) {
assertSnapshot(ctx.events);
});
it('captures adopted stylesheets in shadow doms and iframe', async () => {
await ctx.page.evaluate(() => {
document.body.innerHTML = `
<div>div in outermost document</div>
<div id="shadow-host1"></div>
<div id="shadow-host2"></div>
<iframe></iframe>
`;
const sheet = new CSSStyleSheet();
sheet.replaceSync!(
'div { color: yellow; } h2 { color: orange; } h3 { font-size: larger;}',
);
// Add stylesheet to a document.
document.adoptedStyleSheets = [sheet];
// Add stylesheet to a shadow host.
const host = document.querySelector('#shadow-host1');
const shadow = host!.attachShadow({ mode: 'open' });
shadow.innerHTML =
'<div>div in shadow dom 1</div><span>span in shadow dom 1</span>';
const sheet2 = new CSSStyleSheet();
sheet2.replaceSync!('span { color: red; }');
shadow.adoptedStyleSheets = [sheet, sheet2];
// Add stylesheet to an IFrame document.
const iframe = document.querySelector('iframe');
const sheet3 = new (iframe!.contentWindow! as IWindow &
typeof globalThis).CSSStyleSheet();
sheet3.replaceSync!('h1 { color: blue; }');
iframe!.contentDocument!.adoptedStyleSheets = [sheet3];
const ele = iframe!.contentDocument!.createElement('h1');
ele.innerText = 'h1 in iframe';
iframe!.contentDocument!.body.appendChild(ele);
((window as unknown) as IWindow).rrweb.record({
emit: ((window.top as unknown) as IWindow).emit,
});
// Make incremental changes to shadow dom.
setTimeout(() => {
const host = document.querySelector('#shadow-host2');
const shadow = host!.attachShadow({ mode: 'open' });
shadow.innerHTML =
'<div>div in shadow dom 2</div><span>span in shadow dom 2</span>';
const sheet4 = new CSSStyleSheet();
sheet4.replaceSync!('span { color: green; }');
shadow.adoptedStyleSheets = [sheet, sheet4];
document.adoptedStyleSheets = [sheet4, sheet, sheet2];
const sheet5 = new (iframe!.contentWindow! as IWindow &
typeof globalThis).CSSStyleSheet();
sheet5.replaceSync!('h2 { color: purple; }');
iframe!.contentDocument!.adoptedStyleSheets = [sheet5, sheet3];
}, 10);
});
await waitForRAF(ctx.page); // wait till events get sent
assertSnapshot(ctx.events);
});
});
describe('record iframes', function (this: ISuite) {

View File

@@ -1,6 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
import type * as puppeteer from 'puppeteer';
import 'construct-style-sheets-polyfill';
import {
assertDomSnapshot,
launchPuppeteer,
@@ -17,6 +18,8 @@ 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;
@@ -718,4 +721,232 @@ describe('replayer', function () {
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();
});
});

View File

@@ -0,0 +1,78 @@
/**
* @jest-environment jsdom
*/
import { StyleSheetMirror } from '../src/utils';
describe('Utilities for other modules', () => {
describe('StyleSheetMirror', () => {
it('should create a StyleSheetMirror', () => {
const mirror = new StyleSheetMirror();
expect(mirror).toBeDefined();
expect(mirror.add).toBeDefined();
expect(mirror.has).toBeDefined();
expect(mirror.reset).toBeDefined();
expect(mirror.getId).toBeDefined();
});
it('can add CSSStyleSheet into the mirror without ID parameter', () => {
const mirror = new StyleSheetMirror();
const styleSheet = new CSSStyleSheet();
expect(mirror.has(styleSheet)).toBeFalsy();
expect(mirror.add(styleSheet)).toEqual(1);
expect(mirror.has(styleSheet)).toBeTruthy();
// This stylesheet has been added before so just return its assigned id.
expect(mirror.add(styleSheet)).toEqual(1);
for (let i = 0; i < 10; i++) {
const styleSheet = new CSSStyleSheet();
expect(mirror.has(styleSheet)).toBeFalsy();
expect(mirror.add(styleSheet)).toEqual(i + 2);
expect(mirror.has(styleSheet)).toBeTruthy();
}
});
it('can add CSSStyleSheet into the mirror with ID parameter', () => {
const mirror = new StyleSheetMirror();
for (let i = 0; i < 10; i++) {
const styleSheet = new CSSStyleSheet();
expect(mirror.has(styleSheet)).toBeFalsy();
expect(mirror.add(styleSheet, i)).toEqual(i);
expect(mirror.has(styleSheet)).toBeTruthy();
}
});
it('can get the id from the mirror', () => {
const mirror = new StyleSheetMirror();
for (let i = 0; i < 10; i++) {
const styleSheet = new CSSStyleSheet();
mirror.add(styleSheet);
expect(mirror.getId(styleSheet)).toBe(i + 1);
}
expect(mirror.getId(new CSSStyleSheet())).toBe(-1);
});
it('can get CSSStyleSheet objects with id', () => {
const mirror = new StyleSheetMirror();
for (let i = 0; i < 10; i++) {
const styleSheet = new CSSStyleSheet();
mirror.add(styleSheet);
expect(mirror.getStyle(i + 1)).toBe(styleSheet);
}
});
it('can reset the mirror', () => {
const mirror = new StyleSheetMirror();
const styleList: CSSStyleSheet[] = [];
for (let i = 0; i < 10; i++) {
const styleSheet = new CSSStyleSheet();
mirror.add(styleSheet);
expect(mirror.getId(styleSheet)).toBe(i + 1);
styleList.push(styleSheet);
}
expect(mirror.reset()).toBeUndefined();
for (let s of styleList) expect(mirror.has(s)).toBeFalsy();
for (let i = 0; i < 10; i++) expect(mirror.getStyle(i + 1)).toBeNull();
expect(mirror.add(new CSSStyleSheet())).toBe(1);
});
});
});