* fix: adoptedStyleSheets in shadow doms are missed when a full snapshot is checked out after recording has started * fix: avoid removing monkey patch of all existed shadow doms when take a new full snapshot * Apply formatting changes * Update packages/rrweb/test/record.test.ts Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com> * fix typo * update outdated snapshot Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>
939 lines
29 KiB
TypeScript
939 lines
29 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import type * as puppeteer from 'puppeteer';
|
|
import 'construct-style-sheets-polyfill';
|
|
import type { recordOptions } from '../src/types';
|
|
import {
|
|
listenerHandler,
|
|
eventWithTime,
|
|
EventType,
|
|
IncrementalSource,
|
|
styleSheetRuleData,
|
|
selectionData,
|
|
} from '@rrweb/types';
|
|
import {
|
|
assertSnapshot,
|
|
getServerURL,
|
|
launchPuppeteer,
|
|
startServer,
|
|
waitForRAF,
|
|
} from './utils';
|
|
import type { Server } from 'http';
|
|
|
|
interface ISuite {
|
|
code: string;
|
|
browser: puppeteer.Browser;
|
|
page: puppeteer.Page;
|
|
events: eventWithTime[];
|
|
}
|
|
|
|
interface IWindow extends Window {
|
|
rrweb: {
|
|
record: ((
|
|
options: recordOptions<eventWithTime>,
|
|
) => listenerHandler | undefined) & {
|
|
takeFullSnapshot: (isCheckout?: boolean | undefined) => void;
|
|
};
|
|
|
|
addCustomEvent<T>(tag: string, payload: T): void;
|
|
};
|
|
emit: (e: eventWithTime) => undefined;
|
|
}
|
|
|
|
const setup = function (this: ISuite, content: string): ISuite {
|
|
const ctx = {} as ISuite;
|
|
|
|
beforeAll(async () => {
|
|
ctx.browser = await launchPuppeteer({
|
|
devtools: true,
|
|
});
|
|
|
|
const bundlePath = path.resolve(__dirname, '../dist/rrweb.js');
|
|
ctx.code = fs.readFileSync(bundlePath, 'utf8');
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
ctx.page = await ctx.browser.newPage();
|
|
await ctx.page.goto('about:blank');
|
|
await ctx.page.setContent(content);
|
|
await ctx.page.evaluate(ctx.code);
|
|
ctx.events = [];
|
|
await ctx.page.exposeFunction('emit', (e: eventWithTime) => {
|
|
if (e.type === EventType.DomContentLoaded || e.type === EventType.Load) {
|
|
return;
|
|
}
|
|
ctx.events.push(e);
|
|
});
|
|
|
|
ctx.page.on('console', (msg) => console.log('PAGE LOG:', msg.text()));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await ctx.page.close();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await ctx.browser.close();
|
|
});
|
|
|
|
return ctx;
|
|
};
|
|
|
|
describe('record', function (this: ISuite) {
|
|
jest.setTimeout(10_000);
|
|
|
|
const ctx: ISuite = setup.call(
|
|
this,
|
|
`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<input type="text" size="40" />
|
|
</body>
|
|
</html>
|
|
`,
|
|
);
|
|
|
|
it('will only have one full snapshot without checkout config', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
const { record } = ((window as unknown) as IWindow).rrweb;
|
|
record({
|
|
emit: ((window as unknown) as IWindow).emit,
|
|
});
|
|
});
|
|
let count = 30;
|
|
while (count--) {
|
|
await ctx.page.type('input', 'a');
|
|
}
|
|
await ctx.page.waitForTimeout(10);
|
|
expect(ctx.events.length).toEqual(33);
|
|
expect(
|
|
ctx.events.filter((event: eventWithTime) => event.type === EventType.Meta)
|
|
.length,
|
|
).toEqual(1);
|
|
expect(
|
|
ctx.events.filter(
|
|
(event: eventWithTime) => event.type === EventType.FullSnapshot,
|
|
).length,
|
|
).toEqual(1);
|
|
});
|
|
|
|
it('can checkout full snapshot by count', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
const { record } = ((window as unknown) as IWindow).rrweb;
|
|
record({
|
|
emit: ((window as unknown) as IWindow).emit,
|
|
checkoutEveryNth: 10,
|
|
});
|
|
});
|
|
let count = 30;
|
|
while (count--) {
|
|
await ctx.page.type('input', 'a');
|
|
}
|
|
await ctx.page.waitForTimeout(10);
|
|
expect(ctx.events.length).toEqual(39);
|
|
expect(
|
|
ctx.events.filter((event: eventWithTime) => event.type === EventType.Meta)
|
|
.length,
|
|
).toEqual(4);
|
|
expect(
|
|
ctx.events.filter(
|
|
(event: eventWithTime) => event.type === EventType.FullSnapshot,
|
|
).length,
|
|
).toEqual(4);
|
|
expect(ctx.events[1].type).toEqual(EventType.FullSnapshot);
|
|
expect(ctx.events[13].type).toEqual(EventType.FullSnapshot);
|
|
expect(ctx.events[25].type).toEqual(EventType.FullSnapshot);
|
|
expect(ctx.events[37].type).toEqual(EventType.FullSnapshot);
|
|
});
|
|
|
|
it('can checkout full snapshot by time', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
const { record } = ((window as unknown) as IWindow).rrweb;
|
|
record({
|
|
emit: ((window as unknown) as IWindow).emit,
|
|
checkoutEveryNms: 500,
|
|
});
|
|
});
|
|
await ctx.page.type('input', 'a');
|
|
await ctx.page.waitForTimeout(300);
|
|
expect(
|
|
ctx.events.filter((event: eventWithTime) => event.type === EventType.Meta)
|
|
.length,
|
|
).toEqual(1); // before first automatic snapshot
|
|
expect(
|
|
ctx.events.filter(
|
|
(event: eventWithTime) => event.type === EventType.FullSnapshot,
|
|
).length,
|
|
).toEqual(1); // before first automatic snapshot
|
|
await ctx.page.waitForTimeout(200);
|
|
await ctx.page.type('input', 'a');
|
|
await ctx.page.waitForTimeout(10);
|
|
expect(
|
|
ctx.events.filter((event: eventWithTime) => event.type === EventType.Meta)
|
|
.length,
|
|
).toEqual(2);
|
|
expect(
|
|
ctx.events.filter(
|
|
(event: eventWithTime) => event.type === EventType.FullSnapshot,
|
|
).length,
|
|
).toEqual(2);
|
|
});
|
|
|
|
it('is safe to checkout during async callbacks', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
const { record } = ((window as unknown) as IWindow).rrweb;
|
|
record({
|
|
emit: ((window as unknown) as IWindow).emit,
|
|
checkoutEveryNth: 2,
|
|
});
|
|
const p = document.createElement('p');
|
|
const span = document.createElement('span');
|
|
setTimeout(() => {
|
|
document.body.appendChild(p);
|
|
p.appendChild(span);
|
|
document.body.removeChild(document.querySelector('input')!);
|
|
}, 0);
|
|
setTimeout(() => {
|
|
span.innerText = 'test';
|
|
}, 10);
|
|
setTimeout(() => {
|
|
p.removeChild(span);
|
|
document.body.appendChild(span);
|
|
}, 10);
|
|
});
|
|
await ctx.page.waitForTimeout(100);
|
|
assertSnapshot(ctx.events);
|
|
});
|
|
|
|
it('should record scroll position', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
const { record } = ((window as unknown) as IWindow).rrweb;
|
|
record({
|
|
emit: ((window as unknown) as IWindow).emit,
|
|
});
|
|
const p = document.createElement('p');
|
|
p.innerText = 'testtesttesttesttesttesttesttesttesttest';
|
|
p.setAttribute('style', 'overflow: auto; height: 1px; width: 1px;');
|
|
document.body.appendChild(p);
|
|
p.scrollTop = 10;
|
|
p.scrollLeft = 10;
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
assertSnapshot(ctx.events);
|
|
});
|
|
|
|
it('should record selection event', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
const { record } = ((window as unknown) as IWindow).rrweb;
|
|
record({
|
|
emit: ((window as unknown) as IWindow).emit,
|
|
});
|
|
const startNode = document.createElement('p');
|
|
|
|
startNode.innerText =
|
|
'Lorem ipsum dolor sit amet consectetur adipisicing elit.';
|
|
|
|
const endNode = document.createElement('span');
|
|
endNode.innerText =
|
|
'nihil ipsum officiis pariatur laboriosam quas,corrupti vero vitae minus.';
|
|
|
|
document.body.appendChild(startNode);
|
|
document.body.appendChild(endNode);
|
|
|
|
const selection = window.getSelection();
|
|
const range = new Range();
|
|
|
|
range.setStart(startNode!.firstChild!, 10);
|
|
range.setEnd(endNode!.firstChild!, 2);
|
|
|
|
selection?.addRange(range);
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
const selectionData = ctx.events
|
|
.filter(({ type, data }) => {
|
|
return (
|
|
type === EventType.IncrementalSnapshot &&
|
|
data.source === IncrementalSource.Selection
|
|
);
|
|
})
|
|
.map((ev) => ev.data as selectionData);
|
|
|
|
expect(selectionData.length).toEqual(1);
|
|
expect(selectionData[0].ranges[0].startOffset).toEqual(10);
|
|
expect(selectionData[0].ranges[0].endOffset).toEqual(2);
|
|
});
|
|
|
|
it('can add custom event', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
const { record, addCustomEvent } = ((window as unknown) as IWindow).rrweb;
|
|
record({
|
|
emit: ((window as unknown) as IWindow).emit,
|
|
});
|
|
addCustomEvent<number>('tag1', 1);
|
|
addCustomEvent<{ a: string }>('tag2', {
|
|
a: 'b',
|
|
});
|
|
});
|
|
await ctx.page.waitForTimeout(50);
|
|
assertSnapshot(ctx.events);
|
|
});
|
|
|
|
it('captures stylesheet rules', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
const { record } = ((window as unknown) as IWindow).rrweb;
|
|
|
|
record({
|
|
emit: ((window as unknown) as IWindow).emit,
|
|
});
|
|
|
|
const styleElement = document.createElement('style');
|
|
document.head.appendChild(styleElement);
|
|
|
|
const styleSheet = <CSSStyleSheet>styleElement.sheet;
|
|
// begin: pre-serialization
|
|
const ruleIdx0 = styleSheet.insertRule('body { background: #000; }');
|
|
const ruleIdx1 = styleSheet.insertRule('body { background: #111; }');
|
|
styleSheet.deleteRule(ruleIdx1);
|
|
// end: pre-serialization
|
|
setTimeout(() => {
|
|
styleSheet.insertRule('body { color: #fff; }');
|
|
}, 0);
|
|
setTimeout(() => {
|
|
styleSheet.deleteRule(ruleIdx0);
|
|
}, 5);
|
|
setTimeout(() => {
|
|
styleSheet.insertRule('body { color: #ccc; }');
|
|
}, 10);
|
|
});
|
|
await ctx.page.waitForTimeout(50);
|
|
const styleSheetRuleEvents = ctx.events.filter(
|
|
(e) =>
|
|
e.type === EventType.IncrementalSnapshot &&
|
|
e.data.source === IncrementalSource.StyleSheetRule,
|
|
);
|
|
const addRules = styleSheetRuleEvents.filter((e) =>
|
|
Boolean((e.data as styleSheetRuleData).adds),
|
|
);
|
|
const removeRuleCount = styleSheetRuleEvents.filter((e) =>
|
|
Boolean((e.data as styleSheetRuleData).removes),
|
|
).length;
|
|
// pre-serialization insert/delete should be ignored
|
|
expect(addRules.length).toEqual(2);
|
|
expect((addRules[0].data as styleSheetRuleData).adds).toEqual([
|
|
{
|
|
rule: 'body { color: #fff; }',
|
|
},
|
|
]);
|
|
expect(removeRuleCount).toEqual(1);
|
|
assertSnapshot(ctx.events);
|
|
});
|
|
|
|
const captureNestedStylesheetRulesTest = async () => {
|
|
await ctx.page.evaluate(() => {
|
|
const { record } = ((window as unknown) as IWindow).rrweb;
|
|
|
|
record({
|
|
emit: ((window as unknown) as IWindow).emit,
|
|
});
|
|
|
|
const styleElement = document.createElement('style');
|
|
document.head.appendChild(styleElement);
|
|
|
|
const styleSheet = <CSSStyleSheet>styleElement.sheet;
|
|
styleSheet.insertRule('@media {}');
|
|
const atMediaRule = styleSheet.cssRules[0] as CSSMediaRule;
|
|
|
|
const ruleIdx0 = atMediaRule.insertRule('body { background: #000; }', 0);
|
|
const ruleIdx1 = atMediaRule.insertRule('body { background: #111; }', 0);
|
|
atMediaRule.deleteRule(ruleIdx1);
|
|
setTimeout(() => {
|
|
atMediaRule.insertRule('body { color: #fff; }', 0);
|
|
}, 0);
|
|
setTimeout(() => {
|
|
atMediaRule.deleteRule(ruleIdx0);
|
|
}, 5);
|
|
setTimeout(() => {
|
|
atMediaRule.insertRule('body { color: #ccc; }', 0);
|
|
}, 10);
|
|
});
|
|
await ctx.page.waitForTimeout(50);
|
|
const styleSheetRuleEvents = ctx.events.filter(
|
|
(e) =>
|
|
e.type === EventType.IncrementalSnapshot &&
|
|
e.data.source === IncrementalSource.StyleSheetRule,
|
|
);
|
|
const addRuleCount = styleSheetRuleEvents.filter((e) =>
|
|
Boolean((e.data as styleSheetRuleData).adds),
|
|
).length;
|
|
const removeRuleCount = styleSheetRuleEvents.filter((e) =>
|
|
Boolean((e.data as styleSheetRuleData).removes),
|
|
).length;
|
|
// sync insert/delete should be ignored
|
|
expect(addRuleCount).toEqual(2);
|
|
expect(removeRuleCount).toEqual(1);
|
|
assertSnapshot(ctx.events);
|
|
};
|
|
it('captures nested stylesheet rules', captureNestedStylesheetRulesTest);
|
|
|
|
describe('without CSSGroupingRule support', () => {
|
|
// Safari currently doesn't support CSSGroupingRule, let's test without that
|
|
// https://caniuse.com/?search=CSSGroupingRule
|
|
beforeEach(async () => {
|
|
await ctx.page.evaluate(() => {
|
|
/* @ts-ignore: override CSSGroupingRule */
|
|
CSSGroupingRule = undefined;
|
|
});
|
|
// load a fresh rrweb recorder without CSSGroupingRule
|
|
await ctx.page.evaluate(ctx.code);
|
|
});
|
|
it('captures nested stylesheet rules', captureNestedStylesheetRulesTest);
|
|
});
|
|
|
|
it('captures style property changes', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
const { record } = ((window as unknown) as IWindow).rrweb;
|
|
|
|
record({
|
|
emit: ((window as unknown) as IWindow).emit,
|
|
ignoreCSSAttributes: new Set(['color']),
|
|
});
|
|
|
|
const styleElement = document.createElement('style');
|
|
document.head.appendChild(styleElement);
|
|
|
|
const styleSheet = <CSSStyleSheet>styleElement.sheet;
|
|
styleSheet.insertRule('body { background: #000; }');
|
|
setTimeout(() => {
|
|
// should be ignored
|
|
(styleSheet.cssRules[0] as CSSStyleRule).style.setProperty(
|
|
'color',
|
|
'green',
|
|
);
|
|
|
|
// should be captured because we did not block it
|
|
(styleSheet.cssRules[0] as CSSStyleRule).style.setProperty(
|
|
'border-color',
|
|
'green',
|
|
);
|
|
|
|
(styleSheet.cssRules[0] as CSSStyleRule).style.removeProperty(
|
|
'background',
|
|
);
|
|
}, 0);
|
|
});
|
|
await ctx.page.waitForTimeout(50);
|
|
assertSnapshot(ctx.events);
|
|
});
|
|
|
|
it('captures inserted style text nodes correctly', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
const { record } = ((window as unknown) as IWindow).rrweb;
|
|
|
|
const styleEl = document.createElement(`style`);
|
|
styleEl.append(document.createTextNode('div { color: red; }'));
|
|
styleEl.append(document.createTextNode('section { color: blue; }'));
|
|
document.head.appendChild(styleEl);
|
|
|
|
record({
|
|
emit: ((window as unknown) as IWindow).emit,
|
|
});
|
|
|
|
styleEl.append(document.createTextNode('span { color: orange; }'));
|
|
styleEl.append(document.createTextNode('h1 { color: pink; }'));
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
assertSnapshot(ctx.events);
|
|
});
|
|
|
|
it('captures stylesheets with `blob:` url', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
const link1 = document.createElement('link');
|
|
link1.setAttribute('rel', 'stylesheet');
|
|
link1.setAttribute(
|
|
'href',
|
|
URL.createObjectURL(
|
|
new Blob(['body { color: pink; }'], {
|
|
type: 'text/css',
|
|
}),
|
|
),
|
|
);
|
|
document.head.appendChild(link1);
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
await ctx.page.evaluate(() => {
|
|
const { record } = ((window as unknown) as IWindow).rrweb;
|
|
|
|
record({
|
|
inlineStylesheet: true,
|
|
emit: ((window as unknown) as IWindow).emit,
|
|
});
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
assertSnapshot(ctx.events);
|
|
});
|
|
|
|
it('captures mutations on adopted stylesheets', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
return new Promise((resolve) => {
|
|
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 { rrweb, emit } = (window as unknown) as IWindow;
|
|
rrweb.record({
|
|
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);
|
|
|
|
setTimeout(() => {
|
|
resolve(undefined);
|
|
}, 20);
|
|
});
|
|
});
|
|
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 { rrweb, emit } = (window as unknown) as IWindow;
|
|
rrweb.record({
|
|
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 adopted stylesheets of shadow doms in checkout full snapshot', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
return new Promise((resolve) => {
|
|
document.body.innerHTML = `
|
|
<div id="shadow-host-1">entry</div>
|
|
`;
|
|
|
|
let shadowHost = document.querySelector('div')!;
|
|
shadowHost!.attachShadow({ mode: 'open' });
|
|
const sheet = new CSSStyleSheet();
|
|
sheet.replaceSync!('h1 {color: blue;}');
|
|
shadowHost.shadowRoot!.adoptedStyleSheets = [sheet];
|
|
|
|
const { rrweb, emit } = (window as unknown) as IWindow;
|
|
rrweb.record({
|
|
emit,
|
|
});
|
|
|
|
setTimeout(() => {
|
|
// When a full snapshot is checked out manually, all adoptedStylesheets should also be captured.
|
|
rrweb.record.takeFullSnapshot(true);
|
|
resolve(undefined);
|
|
}, 10);
|
|
});
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
assertSnapshot(ctx.events);
|
|
});
|
|
|
|
it('captures stylesheets in iframes with `blob:` url', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
const iframe = document.createElement('iframe');
|
|
iframe.setAttribute('src', 'about:blank');
|
|
document.body.appendChild(iframe);
|
|
|
|
const linkEl = document.createElement('link');
|
|
linkEl.setAttribute('rel', 'stylesheet');
|
|
linkEl.setAttribute(
|
|
'href',
|
|
URL.createObjectURL(
|
|
new Blob(['body { color: pink; }'], {
|
|
type: 'text/css',
|
|
}),
|
|
),
|
|
);
|
|
const iframeDoc = iframe.contentDocument!;
|
|
iframeDoc.head.appendChild(linkEl);
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
await ctx.page.evaluate(() => {
|
|
const { record } = ((window as unknown) as IWindow).rrweb;
|
|
|
|
record({
|
|
inlineStylesheet: true,
|
|
emit: ((window as unknown) as IWindow).emit,
|
|
});
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
assertSnapshot(ctx.events);
|
|
});
|
|
|
|
describe('loading stylesheets', () => {
|
|
let server: Server;
|
|
let serverURL: string;
|
|
|
|
beforeAll(async () => {
|
|
server = await startServer();
|
|
serverURL = getServerURL(server);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
ctx.page = await ctx.browser.newPage();
|
|
await ctx.page.goto(`${serverURL}/html/hello-world.html`);
|
|
await ctx.page.evaluate(ctx.code);
|
|
ctx.events = [];
|
|
await ctx.page.exposeFunction('emit', (e: eventWithTime) => {
|
|
if (
|
|
e.type === EventType.DomContentLoaded ||
|
|
e.type === EventType.Load
|
|
) {
|
|
return;
|
|
}
|
|
ctx.events.push(e);
|
|
});
|
|
|
|
ctx.page.on('console', (msg) => console.log('PAGE LOG:', msg.text()));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server.close();
|
|
});
|
|
|
|
it('captures stylesheets that are still loading', async () => {
|
|
ctx.page.evaluate((serverURL) => {
|
|
const { record } = ((window as unknown) as IWindow).rrweb;
|
|
|
|
record({
|
|
inlineStylesheet: true,
|
|
emit: ((window as unknown) as IWindow).emit,
|
|
});
|
|
|
|
const link1 = document.createElement('link');
|
|
link1.setAttribute('rel', 'stylesheet');
|
|
link1.setAttribute('href', `${serverURL}/html/assets/style.css`);
|
|
document.head.appendChild(link1);
|
|
}, serverURL);
|
|
|
|
await ctx.page.waitForResponse(`${serverURL}/html/assets/style.css`);
|
|
await waitForRAF(ctx.page);
|
|
|
|
assertSnapshot(ctx.events);
|
|
});
|
|
|
|
it('captures stylesheets in iframes that are still loading', async () => {
|
|
ctx.page.evaluate(() => {
|
|
const iframe = document.createElement('iframe');
|
|
iframe.setAttribute('src', `/html/hello-world.html?2`);
|
|
document.body.appendChild(iframe);
|
|
|
|
const { record } = ((window as unknown) as IWindow).rrweb;
|
|
|
|
record({
|
|
inlineStylesheet: true,
|
|
emit: ((window as unknown) as IWindow).emit,
|
|
});
|
|
});
|
|
|
|
await ctx.page.waitForResponse(`${serverURL}/html/hello-world.html?2`);
|
|
|
|
await waitForRAF(ctx.page);
|
|
|
|
ctx.page.evaluate(() => {
|
|
const iframe = document.querySelector('iframe')!;
|
|
const iframeDoc = iframe.contentDocument!;
|
|
const linkEl = document.createElement('link');
|
|
linkEl.setAttribute('rel', 'stylesheet');
|
|
linkEl.setAttribute('href', `/html/assets/style.css`);
|
|
iframeDoc.head.appendChild(linkEl);
|
|
});
|
|
|
|
await ctx.page.waitForResponse(`${serverURL}/html/assets/style.css`);
|
|
|
|
await waitForRAF(ctx.page);
|
|
|
|
assertSnapshot(ctx.events);
|
|
});
|
|
});
|
|
|
|
it('captures CORS stylesheets that are still loading', async () => {
|
|
const corsStylesheetURL =
|
|
'https://cdn.jsdelivr.net/npm/pure@2.85.0/index.css';
|
|
|
|
// do not `await` the following function, otherwise `waitForResponse` _might_ not be called
|
|
void ctx.page.evaluate((corsStylesheetURL) => {
|
|
const { record } = ((window as unknown) as IWindow).rrweb;
|
|
|
|
record({
|
|
inlineStylesheet: true,
|
|
emit: ((window as unknown) as IWindow).emit,
|
|
});
|
|
|
|
const link1 = document.createElement('link');
|
|
link1.setAttribute('rel', 'stylesheet');
|
|
link1.setAttribute('href', corsStylesheetURL);
|
|
document.head.appendChild(link1);
|
|
}, corsStylesheetURL);
|
|
|
|
await ctx.page.waitForResponse(corsStylesheetURL); // wait for stylesheet to be loaded
|
|
await waitForRAF(ctx.page); // wait for rrweb to emit events
|
|
|
|
assertSnapshot(ctx.events);
|
|
});
|
|
|
|
it('captures adopted stylesheets in shadow doms and iframe', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
return new Promise((resolve) => {
|
|
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);
|
|
|
|
setTimeout(() => {
|
|
resolve(null);
|
|
}, 20);
|
|
});
|
|
});
|
|
await waitForRAF(ctx.page); // wait till events get sent
|
|
|
|
assertSnapshot(ctx.events);
|
|
});
|
|
});
|
|
|
|
describe('record iframes', function (this: ISuite) {
|
|
jest.setTimeout(10_000);
|
|
|
|
const ctx: ISuite = setup.call(
|
|
this,
|
|
`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<iframe srcdoc="<button>Mysterious Button</button>" />
|
|
</body>
|
|
</html>
|
|
`,
|
|
);
|
|
|
|
it('captures iframe content in correct order', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
const { record } = ((window as unknown) as IWindow).rrweb;
|
|
record({
|
|
emit: ((window as unknown) as IWindow).emit,
|
|
});
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
// console.log(JSON.stringify(ctx.events));
|
|
|
|
expect(ctx.events.length).toEqual(3);
|
|
const eventTypes = ctx.events
|
|
.filter(
|
|
(e) =>
|
|
e.type === EventType.IncrementalSnapshot ||
|
|
e.type === EventType.FullSnapshot,
|
|
)
|
|
.map((e) => e.type);
|
|
expect(eventTypes).toEqual([
|
|
EventType.FullSnapshot,
|
|
EventType.IncrementalSnapshot,
|
|
]);
|
|
});
|
|
|
|
it('captures stylesheet mutations in iframes', async () => {
|
|
await ctx.page.evaluate(() => {
|
|
const { record } = ((window as unknown) as IWindow).rrweb;
|
|
record({
|
|
// need to reference window.top for when we are in an iframe!
|
|
emit: ((window.top as unknown) as IWindow).emit,
|
|
});
|
|
|
|
const iframe = document.querySelector('iframe');
|
|
// outer timeout is needed to wait for initStyleSheetObserver on iframe to be set up
|
|
setTimeout(() => {
|
|
const idoc = (iframe as HTMLIFrameElement).contentDocument!;
|
|
const styleElement = idoc.createElement('style');
|
|
|
|
idoc.head.appendChild(styleElement);
|
|
|
|
const styleSheet = <CSSStyleSheet>styleElement.sheet;
|
|
styleSheet.insertRule('@media {}');
|
|
const atMediaRule = styleSheet.cssRules[0] as CSSMediaRule;
|
|
const atRuleIdx0 = atMediaRule.insertRule(
|
|
'body { background: #000; }',
|
|
0,
|
|
);
|
|
const ruleIdx0 = styleSheet.insertRule('body { background: #000; }'); // inserted before above
|
|
// pre-serialization insert/delete above should be ignored
|
|
setTimeout(() => {
|
|
styleSheet.insertRule('body { color: #fff; }');
|
|
atMediaRule.insertRule('body { color: #ccc; }', 0);
|
|
}, 0);
|
|
setTimeout(() => {
|
|
styleSheet.deleteRule(ruleIdx0);
|
|
(styleSheet.cssRules[0] as CSSStyleRule).style.setProperty(
|
|
'color',
|
|
'green',
|
|
);
|
|
}, 5);
|
|
setTimeout(() => {
|
|
atMediaRule.deleteRule(atRuleIdx0);
|
|
}, 10);
|
|
}, 10);
|
|
});
|
|
await ctx.page.waitForTimeout(50); // wait till setTimeout is called
|
|
await waitForRAF(ctx.page); // wait till events get sent
|
|
const styleRelatedEvents = ctx.events.filter(
|
|
(e) =>
|
|
e.type === EventType.IncrementalSnapshot &&
|
|
(e.data.source === IncrementalSource.StyleSheetRule ||
|
|
e.data.source === IncrementalSource.StyleDeclaration),
|
|
);
|
|
const addRuleCount = styleRelatedEvents.filter((e) =>
|
|
Boolean((e.data as styleSheetRuleData).adds),
|
|
).length;
|
|
const removeRuleCount = styleRelatedEvents.filter((e) =>
|
|
Boolean((e.data as styleSheetRuleData).removes),
|
|
).length;
|
|
expect(styleRelatedEvents.length).toEqual(5);
|
|
expect(addRuleCount).toEqual(2);
|
|
expect(removeRuleCount).toEqual(2);
|
|
assertSnapshot(ctx.events);
|
|
});
|
|
});
|