Cross origin iframe support (#1035)

* Add `recordCrossOriginIframe` setting

* Set up messaging between iframes

* should emit full snapshot event from iframe as mutation event

* this.mirror was dropped on attachIframe

* should use unique id for child of iframe

* Cross origin iframe recording in `yarn live-stream`

* Root iframe check thats supported by firefox

* Live stream: Inject script in all frames

* Record same origin and cross origin iframes differently

* Should map Input events correctly

* Turn on other tests

* Fix compatibility with newer puppeteer

* puppeteer vs 12 seems stable without to many changes needed

* normalize port numbers in snapshots

* Handle scroll and ViewportResize events in cross origin iframe

* Correctly map cross origin mutations

* Map selection events for cross origin iframes

* Map canvas mutations for cross origin iframes

* Update snapshot to include canvas events

* Skip all meta events

* Support custom events as best we can in cross origin iframes

* Use earliest version of puppeteer that works with cross origin live-stream

* Map mouse/touch interaction events

* Update snapshots for correctly mapped click events

* Tweak tests for new puppeteer version

* Map MediaInteraction correctly for cross origin iframes

* Make tests consistent between high and low dpi devices

* Make test less flaky

* Make test less flaky

* Make test less flaky

* Make test less flaky

* Add support for styles in cross origin iframes

* Map traditional stylesheet mutations on cross origin iframes

* Add todo

* Add iframe mirror

* Get iframe manager to use iframe mirrors internally

* Rename `IframeMirror` to `CrossOriginIframeMirror`

* Setup basic cross origin canvas webrtc streaming

* Clean up removed canvas elements

* reset style mirror on new full snapshot

* Fix cross origin canvas webrtc streaming

* Make emit optional

* Run tests on github actions

* Upload image artifacts from failed tests

* Use newer github actions

* Test: hopefully adding more wait will fix it

* add extra wait

* Fix image snapshot tests

* Make tests run with new puppeteer version

* upgrade eslint-plugin-jest

* Chore: Remove travis ci as ci's running on github actions

* Chore: Support recording cross origin iframe in repl

* Force developers to update the cross origin iframe mapping when adding new events

https://github.com/rrweb-io/rrweb/pull/1035#discussion_r1012516277

* Document cross origin iframe recording

* Docs: cross origin iframes recording methods

* Docs: AI translated, cross origin iframe recording

* rename getParentId to getId

* Migrate to @rrweb/types

* Run on pull request

* doc: improve Chinese doc

* Rename `parentId` to `Id`

Co-authored-by: Mark-Fenng <f18846188605@gmail.com>
This commit is contained in:
Justin Halsall
2022-11-16 06:11:11 +01:00
committed by GitHub
parent a9fffb3bc0
commit 2a80949948
38 changed files with 7362 additions and 1038 deletions

View File

@@ -1,5 +1,5 @@
import { playerConfig } from 'rrweb/typings/types';
import type { eventWithTime } from '@rrweb/types';
import { eventWithTime } from '@rrweb/types';
import { Replayer, mirror } from 'rrweb';
import { SvelteComponent } from 'svelte';

View File

@@ -6,6 +6,7 @@ import snapshot, {
needMaskingText,
classMatchesRegex,
IGNORED_NODE,
genId,
} from './snapshot';
import rebuild, {
buildNodeWithSN,
@@ -28,4 +29,5 @@ export {
needMaskingText,
classMatchesRegex,
IGNORED_NODE,
genId,
};

View File

@@ -27,7 +27,7 @@ const tagNameRegex = new RegExp('[^a-z0-9-_:]');
export const IGNORED_NODE = -2;
function genId(): number {
export function genId(): number {
return _id++;
}

View File

@@ -50,7 +50,7 @@
"@types/dom-mediacapture-transform": "^0.1.3",
"@types/inquirer": "^8.2.1",
"@types/jest": "^27.4.1",
"@types/jest-image-snapshot": "^4.3.1",
"@types/jest-image-snapshot": "^5.1.0",
"@types/node": "^17.0.21",
"@types/offscreencanvas": "^2019.6.4",
"@types/prettier": "^2.3.2",
@@ -63,10 +63,10 @@
"ignore-styles": "^5.0.1",
"inquirer": "^9.0.0",
"jest": "^27.5.1",
"jest-image-snapshot": "^4.5.1",
"jest-image-snapshot": "^5.2.0",
"jest-snapshot": "^23.6.0",
"prettier": "2.2.1",
"puppeteer": "^9.1.1",
"puppeteer": "^11.0.0",
"rollup": "^2.68.0",
"rollup-plugin-esbuild": "^4.9.1",
"rollup-plugin-postcss": "^3.1.1",

View File

@@ -21,6 +21,43 @@ void (async () => {
const code = getCode();
let events = [];
async function injectRecording(frame) {
await frame.evaluate((rrwebCode) => {
const win = window;
if (win.__IS_RECORDING__) return;
win.__IS_RECORDING__ = true;
(async () => {
function loadScript(code) {
const s = document.createElement('script');
let r = false;
s.type = 'text/javascript';
s.innerHTML = code;
if (document.head) {
document.head.append(s);
} else {
requestAnimationFrame(() => {
document.head.append(s);
});
}
}
loadScript(rrwebCode);
win.events = [];
rrweb.record({
emit: (event) => {
win.events.push(event);
win._replLog(event);
},
plugins: [],
recordCanvas: true,
recordCrossOriginIframes: true,
collectFonts: true,
});
})();
}, code);
}
await start('https://react-redux.realworld.io');
const fakeGoto = async (page, url) => {
@@ -44,8 +81,7 @@ void (async () => {
{
type: 'input',
name: 'url',
message:
`Enter the url you want to record, e.g [${defaultURL}]: `,
message: `Enter the url you want to record, e.g [${defaultURL}]: `,
},
]);
@@ -116,34 +152,18 @@ void (async () => {
],
});
const page = await browser.newPage();
await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 300000,
});
await page.exposeFunction('_replLog', (event) => {
events.push(event);
});
await page.evaluate(`;${code}
window.__IS_RECORDING__ = true
rrweb.record({
emit: event => window._replLog(event),
recordCanvas: true,
collectFonts: true
});
`);
page.on('framenavigated', async () => {
const isRecording = await page.evaluate('window.__IS_RECORDING__');
if (!isRecording) {
await page.evaluate(`;${code}
window.__IS_RECORDING__ = true
rrweb.record({
emit: event => window._replLog(event),
recordCanvas: true,
collectFonts: true
});
`);
}
page.on('framenavigated', async (frame) => {
await injectRecording(frame);
});
await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 300000,
});
emitter.once('done', async (shouldReplay) => {
@@ -211,9 +231,9 @@ void (async () => {
<script>
/*<!--*/
const events = ${JSON.stringify(events).replace(
/<\/script>/g,
'<\\/script>',
)};
/<\/script>/g,
'<\\/script>',
)};
/*-->*/
const replayer = new rrweb.Replayer(events, {
UNSAFE_replayCanvas: true

View File

@@ -17,41 +17,62 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const emitter = new EventEmitter();
const code = fs.readFileSync(path.join(__dirname, '../dist/rrweb.js'), 'utf8');
const pluginCode = fs.readFileSync(
path.join(__dirname, '../dist/plugins/canvas-webrtc-record.js'),
'utf8',
);
async function startRecording(page, serverURL) {
try {
await page.addScriptTag({ url: `${serverURL}/rrweb.js` });
await page.addScriptTag({
url: `${serverURL}/plugins/canvas-webrtc-record.js`,
});
await page.evaluate((serverURL) => {
async function injectRecording(frame) {
await frame.evaluate(
(rrwebCode, pluginCode) => {
const win = window;
if (win.__IS_RECORDING__) return;
win.__IS_RECORDING__ = true;
win.events = [];
window.record = win.rrweb.record;
window.plugin = new rrwebCanvasWebRTCRecord.RRWebPluginCanvasWebRTCRecord(
{
signalSendCallback: (msg) => {
// [record#callback] provides canvas id, stream, and webrtc sdpOffer signal & connect message
_signal(msg);
},
},
);
window.record({
emit: (event) => {
win.events.push(event);
win._captureEvent(event);
},
plugins: [window.plugin.initPlugin()],
recordCanvas: false,
collectFonts: true,
inlineImages: true,
});
});
} catch (error) {
console.error(error);
}
(async () => {
function loadScript(code) {
const s = document.createElement('script');
s.type = 'text/javascript';
s.innerHTML = code;
if (document.head) {
document.head.append(s);
} else {
requestAnimationFrame(() => {
document.head.append(s);
});
}
}
loadScript(rrwebCode);
loadScript(pluginCode);
win.events = [];
window.record = win.rrweb.record;
window.plugin = new rrwebCanvasWebRTCRecord.RRWebPluginCanvasWebRTCRecord(
{
signalSendCallback: (msg) => {
// [record#callback] provides canvas id, stream, and webrtc sdpOffer signal & connect message
_signal(msg);
},
},
);
window.record({
emit: (event) => {
win.events.push(event);
win._captureEvent(event);
},
plugins: [window.plugin.initPlugin()],
recordCanvas: false,
recordCrossOriginIframes: true,
collectFonts: true,
inlineImages: true,
});
})();
},
code,
pluginCode,
);
}
async function startReplay(page, serverURL, recordedPage) {
@@ -209,6 +230,17 @@ void (async () => {
resizeWindow(replayerPage, 0, 800, 800, 800),
]);
await recordedPage.exposeFunction('_captureEvent', (event) => {
replayerPage.evaluate((event) => {
window.replayer.addEvent(event);
}, event);
});
recordedPage.on('framenavigated', async (frame) => {
console.log('framenavigated');
await injectRecording(frame, serverURL);
});
await recordedPage.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 300000,
@@ -216,24 +248,6 @@ void (async () => {
if (!replayerPage) throw new Error('No replayer page found');
await recordedPage.exposeFunction('_captureEvent', (event) => {
replayerPage.evaluate((event) => {
window.replayer.addEvent(event);
}, event);
});
await startRecording(recordedPage, serverURL);
recordedPage.on('framenavigated', async () => {
const isRecording = await recordedPage.evaluate(
'window.__IS_RECORDING__',
);
if (!isRecording) {
// When the page navigates, I notice this event is emitted twice so that there are two recording processes running in a single page.
// Set recording flag True ASAP to prevent recording twice.
await recordedPage.evaluate('window.__IS_RECORDING__ = true');
await startRecording(recordedPage, serverURL);
}
});
emitter.once('done', async () => {
const pages = [
...(await recordingBrowser.pages()),

View File

@@ -1,14 +1,39 @@
import type { Mirror } from 'rrweb-snapshot';
import SimplePeer from 'simple-peer-light';
import type { RecordPlugin } from '@rrweb/types';
import type { RecordPlugin, ICrossOriginIframeMirror } from '@rrweb/types';
import type { WebRTCDataChannel } from '../types';
export const PLUGIN_NAME = 'rrweb/canvas-webrtc@1';
export type CrossOriginIframeMessageEventContent = {
type: 'rrweb-canvas-webrtc';
data:
| {
type: 'signal';
signal: RTCSessionDescriptionInit;
}
| {
type: 'who-has-canvas';
rootId: number;
id: number;
}
| {
type: 'i-have-canvas';
rootId: number;
};
};
export class RRWebPluginCanvasWebRTCRecord {
private peer: SimplePeer.Instance | null = null;
private mirror: Mirror;
private crossOriginIframeMirror: ICrossOriginIframeMirror;
private streamMap: Map<number, MediaStream> = new Map();
private incomingStreams = new Set<MediaStream>();
private outgoingStreams = new Set<MediaStream>();
private streamNodeMap = new Map<string, number>();
private canvasWindowMap = new Map<number, WindowProxy>();
private windowPeerMap = new WeakMap<WindowProxy, SimplePeer.Instance>();
private peerWindowMap = new WeakMap<SimplePeer.Instance, WindowProxy>();
private signalSendCallback: (msg: RTCSessionDescriptionInit) => void;
constructor({
@@ -19,14 +44,19 @@ export class RRWebPluginCanvasWebRTCRecord {
peer?: SimplePeer.Instance;
}) {
this.signalSendCallback = signalSendCallback;
window.addEventListener(
'message',
this.windowPostMessageHandler.bind(this),
);
if (peer) this.peer = peer;
}
public initPlugin(): RecordPlugin {
return {
name: PLUGIN_NAME,
getMirror: (mirror) => {
this.mirror = mirror;
getMirror: ({ nodeMirror, crossOriginIframeMirror }) => {
this.mirror = nodeMirror;
this.crossOriginIframeMirror = crossOriginIframeMirror;
},
options: {},
};
@@ -37,58 +67,239 @@ export class RRWebPluginCanvasWebRTCRecord {
this.peer?.signal(signal);
}
public signalReceiveFromCrossOriginIframe(
signal: RTCSessionDescriptionInit,
source: WindowProxy,
) {
const peer = this.setupPeer(source);
peer.signal(signal);
}
private startStream(id: number, stream: MediaStream) {
if (!this.peer) return this.setupPeer();
if (!this.peer) this.setupPeer();
const data: WebRTCDataChannel = {
nodeId: id,
streamId: stream.id,
};
this.peer?.send(JSON.stringify(data));
this.peer?.addStream(stream);
if (!this.outgoingStreams.has(stream)) this.peer?.addStream(stream);
this.outgoingStreams.add(stream);
}
public setupPeer() {
if (!this.peer) {
this.peer = new SimplePeer({
public setupPeer(source?: WindowProxy): SimplePeer.Instance {
let peer: SimplePeer.Instance;
if (!source) {
if (this.peer) return this.peer;
peer = this.peer = new SimplePeer({
initiator: true,
// trickle: false, // only create one WebRTC offer per session
});
} else {
const peerFromMap = this.windowPeerMap.get(source);
this.peer.on('error', (err: Error) => {
this.peer = null;
console.log('error', err);
});
if (peerFromMap) return peerFromMap;
this.peer.on('close', () => {
this.peer = null;
console.log('closing');
});
this.peer.on('signal', (data: RTCSessionDescriptionInit) => {
this.signalSendCallback(data);
});
this.peer.on('connect', () => {
for (const [id, stream] of this.streamMap) {
this.startStream(id, stream);
}
peer = new SimplePeer({
initiator: false,
// trickle: false, // only create one WebRTC offer per session
});
this.windowPeerMap.set(source, peer);
this.peerWindowMap.set(peer, source);
}
const resetPeer = (source?: WindowProxy) => {
if (!source) return (this.peer = null);
this.windowPeerMap.delete(source);
this.peerWindowMap.delete(peer);
};
peer.on('error', (err: Error) => {
resetPeer(source);
console.log('error', err);
});
peer.on('close', () => {
resetPeer(source);
console.log('closing');
});
peer.on('signal', (data: RTCSessionDescriptionInit) => {
if (this.inRootFrame()) {
if (peer === this.peer) {
// connected to replayer
this.signalSendCallback(data);
} else {
// connected to cross-origin iframe
this.peerWindowMap.get(peer)?.postMessage(
{
type: 'rrweb-canvas-webrtc',
data: {
type: 'signal',
signal: data,
},
} as CrossOriginIframeMessageEventContent,
'*',
);
}
} else {
// connected to root frame
window.top?.postMessage(
{
type: 'rrweb-canvas-webrtc',
data: {
type: 'signal',
signal: data,
},
} as CrossOriginIframeMessageEventContent,
'*',
);
}
});
peer.on('connect', () => {
// connected to cross-origin iframe, no need to do anything
if (this.inRootFrame() && peer !== this.peer) return;
// cross origin frame connected to root frame
// or root frame connected to replayer
// send all streams to peer
for (const [id, stream] of this.streamMap) {
this.startStream(id, stream);
}
});
if (!this.inRootFrame()) return peer;
peer.on('data', (data: SimplePeer.SimplePeerData) => {
try {
const json = JSON.parse(data as string) as WebRTCDataChannel;
this.streamNodeMap.set(json.streamId, json.nodeId);
} catch (error) {
console.error('Could not parse data', error);
}
this.flushStreams();
});
peer.on('stream', (stream: MediaStream) => {
this.incomingStreams.add(stream);
this.flushStreams();
});
return peer;
}
public setupStream(id: number): false | MediaStream {
public setupStream(id: number, rootId?: number): boolean | MediaStream {
if (id === -1) return false;
let stream: MediaStream | undefined = this.streamMap.get(id);
let stream: MediaStream | undefined = this.streamMap.get(rootId || id);
if (stream) return stream;
const el = this.mirror.getNode(id) as HTMLCanvasElement | null;
if (!el || !('captureStream' in el)) return false;
if (!el || !('captureStream' in el))
// we don't have it, lets check our iframes
return this.setupStreamInCrossOriginIframe(id, rootId || id);
if (!this.inRootFrame()) {
window.top?.postMessage(
{
type: 'rrweb-canvas-webrtc',
data: {
type: 'i-have-canvas',
rootId: rootId || id,
},
} as CrossOriginIframeMessageEventContent,
'*',
);
}
stream = el.captureStream();
this.streamMap.set(id, stream);
this.streamMap.set(rootId || id, stream);
this.setupPeer();
return stream;
}
private flushStreams() {
this.incomingStreams.forEach((stream) => {
const nodeId = this.streamNodeMap.get(stream.id);
if (!nodeId) return;
// got remote video stream, now let's send it to the replayer
this.startStream(nodeId, stream);
});
}
private inRootFrame(): boolean {
return Boolean(window.top && window.top === window);
}
public setupStreamInCrossOriginIframe(id: number, rootId: number): boolean {
let found = false;
document.querySelectorAll('iframe').forEach((iframe) => {
if (found) return;
const remoteId = this.crossOriginIframeMirror.getRemoteId(iframe, id);
if (remoteId === -1) return;
found = true;
iframe.contentWindow?.postMessage(
{
type: 'rrweb-canvas-webrtc',
data: {
type: 'who-has-canvas',
id: remoteId,
rootId,
},
} as CrossOriginIframeMessageEventContent,
'*',
);
});
return found;
}
private isCrossOriginIframeMessageEventContent(
event: MessageEvent,
): event is MessageEvent<CrossOriginIframeMessageEventContent> {
return Boolean(
'type' in event.data &&
'data' in event.data &&
(event.data as CrossOriginIframeMessageEventContent).type ===
'rrweb-canvas-webrtc' &&
(event.data as CrossOriginIframeMessageEventContent).data,
);
}
/**
* All messages being sent to the (root or sub) frame are received through `windowPostMessageHandler`.
* @param event - The message event
*/
private windowPostMessageHandler(
event: MessageEvent<CrossOriginIframeMessageEventContent> | MessageEvent,
) {
if (!this.isCrossOriginIframeMessageEventContent(event)) return;
const { type } = event.data.data;
if (type === 'who-has-canvas') {
const { id, rootId } = event.data.data;
this.setupStream(id, rootId);
} else if (type === 'signal') {
const { signal } = event.data.data;
const { source } = event;
if (!source || !('self' in source)) return;
if (this.inRootFrame()) {
this.signalReceiveFromCrossOriginIframe(signal, source);
} else {
this.signalReceive(signal);
}
} else if (type === 'i-have-canvas') {
const { rootId } = event.data.data;
const { source } = event;
if (!source || !('self' in source)) return;
this.canvasWindowMap.set(rootId, source);
}
}
}

View File

@@ -36,8 +36,8 @@ export class RRWebPluginCanvasWebRTCReplay {
this.canvasFoundCallback(node, context);
}
},
getMirror: (mirror: Mirror) => {
this.mirror = mirror;
getMirror: (options) => {
this.mirror = options.nodeMirror;
},
};
}

View File

@@ -0,0 +1,88 @@
import type { ICrossOriginIframeMirror } from '@rrweb/types';
export default class CrossOriginIframeMirror
implements ICrossOriginIframeMirror {
private iframeIdToRemoteIdMap: WeakMap<
HTMLIFrameElement,
Map<number, number>
> = new WeakMap();
private iframeRemoteIdToIdMap: WeakMap<
HTMLIFrameElement,
Map<number, number>
> = new WeakMap();
constructor(private generateIdFn: () => number) {}
getId(
iframe: HTMLIFrameElement,
remoteId: number,
idToRemoteMap?: Map<number, number>,
remoteToIdMap?: Map<number, number>,
): number {
const idToRemoteIdMap = idToRemoteMap || this.getIdToRemoteIdMap(iframe);
const remoteIdToIdMap = remoteToIdMap || this.getRemoteIdToIdMap(iframe);
let id = idToRemoteIdMap.get(remoteId);
if (!id) {
id = this.generateIdFn();
idToRemoteIdMap.set(remoteId, id);
remoteIdToIdMap.set(id, remoteId);
}
return id;
}
getIds(iframe: HTMLIFrameElement, remoteId: number[]): number[] {
const idToRemoteIdMap = this.getIdToRemoteIdMap(iframe);
const remoteIdToIdMap = this.getRemoteIdToIdMap(iframe);
return remoteId.map((id) =>
this.getId(iframe, id, idToRemoteIdMap, remoteIdToIdMap),
);
}
getRemoteId(
iframe: HTMLIFrameElement,
id: number,
map?: Map<number, number>,
): number {
const remoteIdToIdMap = map || this.getRemoteIdToIdMap(iframe);
if (typeof id !== 'number') return id;
const remoteId = remoteIdToIdMap.get(id);
if (!remoteId) return -1;
return remoteId;
}
getRemoteIds(iframe: HTMLIFrameElement, ids: number[]): number[] {
const remoteIdToIdMap = this.getRemoteIdToIdMap(iframe);
return ids.map((id) => this.getRemoteId(iframe, id, remoteIdToIdMap));
}
reset(iframe?: HTMLIFrameElement) {
if (!iframe) {
this.iframeIdToRemoteIdMap = new WeakMap();
this.iframeRemoteIdToIdMap = new WeakMap();
return;
}
this.iframeIdToRemoteIdMap.delete(iframe);
this.iframeRemoteIdToIdMap.delete(iframe);
}
private getIdToRemoteIdMap(iframe: HTMLIFrameElement) {
let idToRemoteIdMap = this.iframeIdToRemoteIdMap.get(iframe);
if (!idToRemoteIdMap) {
idToRemoteIdMap = new Map();
this.iframeIdToRemoteIdMap.set(iframe, idToRemoteIdMap);
}
return idToRemoteIdMap;
}
private getRemoteIdToIdMap(iframe: HTMLIFrameElement) {
let remoteIdToIdMap = this.iframeRemoteIdToIdMap.get(iframe);
if (!remoteIdToIdMap) {
remoteIdToIdMap = new Map();
this.iframeRemoteIdToIdMap.set(iframe, remoteIdToIdMap);
}
return remoteIdToIdMap;
}
}

View File

@@ -1,23 +1,52 @@
import type { Mirror, serializedNodeWithId } from 'rrweb-snapshot';
import type { mutationCallBack } from '@rrweb/types';
import { genId } from 'rrweb-snapshot';
import type { CrossOriginIframeMessageEvent } from '../types';
import CrossOriginIframeMirror from './cross-origin-iframe-mirror';
import { EventType, IncrementalSource } from '@rrweb/types';
import type { eventWithTime, mutationCallBack } from '@rrweb/types';
import type { StylesheetManager } from './stylesheet-manager';
export class IframeManager {
private iframes: WeakMap<HTMLIFrameElement, true> = new WeakMap();
private crossOriginIframeMap: WeakMap<
MessageEventSource,
HTMLIFrameElement
> = new WeakMap();
public crossOriginIframeMirror = new CrossOriginIframeMirror(genId);
public crossOriginIframeStyleMirror: CrossOriginIframeMirror;
private mirror: Mirror;
private mutationCb: mutationCallBack;
private wrappedEmit: (e: eventWithTime, isCheckout?: boolean) => void;
private loadListener?: (iframeEl: HTMLIFrameElement) => unknown;
private stylesheetManager: StylesheetManager;
private recordCrossOriginIframes: boolean;
constructor(options: {
mirror: Mirror;
mutationCb: mutationCallBack;
stylesheetManager: StylesheetManager;
recordCrossOriginIframes: boolean;
wrappedEmit: (e: eventWithTime, isCheckout?: boolean) => void;
}) {
this.mutationCb = options.mutationCb;
this.wrappedEmit = options.wrappedEmit;
this.stylesheetManager = options.stylesheetManager;
this.recordCrossOriginIframes = options.recordCrossOriginIframes;
this.crossOriginIframeStyleMirror = new CrossOriginIframeMirror(
this.stylesheetManager.styleMirror.generateId.bind(
this.stylesheetManager.styleMirror,
),
);
this.mirror = options.mirror;
if (this.recordCrossOriginIframes) {
window.addEventListener('message', this.handleMessage.bind(this));
}
}
public addIframe(iframeEl: HTMLIFrameElement) {
this.iframes.set(iframeEl, true);
if (iframeEl.contentWindow)
this.crossOriginIframeMap.set(iframeEl.contentWindow, iframeEl);
}
public addLoadListener(cb: (iframeEl: HTMLIFrameElement) => unknown) {
@@ -27,12 +56,11 @@ export class IframeManager {
public attachIframe(
iframeEl: HTMLIFrameElement,
childSn: serializedNodeWithId,
mirror: Mirror,
) {
this.mutationCb({
adds: [
{
parentId: mirror.getId(iframeEl),
parentId: this.mirror.getId(iframeEl),
nextId: null,
node: childSn,
},
@@ -51,7 +79,199 @@ export class IframeManager {
)
this.stylesheetManager.adoptStyleSheets(
iframeEl.contentDocument.adoptedStyleSheets,
mirror.getId(iframeEl.contentDocument),
this.mirror.getId(iframeEl.contentDocument),
);
}
private handleMessage(message: MessageEvent | CrossOriginIframeMessageEvent) {
if ((message as CrossOriginIframeMessageEvent).data.type === 'rrweb') {
const iframeSourceWindow = message.source;
if (!iframeSourceWindow) return;
const iframeEl = this.crossOriginIframeMap.get(message.source);
if (!iframeEl) return;
const transformedEvent = this.transformCrossOriginEvent(
iframeEl,
(message as CrossOriginIframeMessageEvent).data.event,
);
if (transformedEvent)
this.wrappedEmit(
transformedEvent,
(message as CrossOriginIframeMessageEvent).data.isCheckout,
);
}
}
private transformCrossOriginEvent(
iframeEl: HTMLIFrameElement,
e: eventWithTime,
): eventWithTime | false {
switch (e.type) {
case EventType.FullSnapshot: {
this.crossOriginIframeMirror.reset(iframeEl);
this.crossOriginIframeStyleMirror.reset(iframeEl);
/**
* Replaces the original id of the iframe with a new set of unique ids
*/
this.replaceIdOnNode(e.data.node, iframeEl);
return {
timestamp: e.timestamp,
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
adds: [
{
parentId: this.mirror.getId(iframeEl),
nextId: null,
node: e.data.node,
},
],
removes: [],
texts: [],
attributes: [],
isAttachIframe: true,
},
};
}
case EventType.Meta:
case EventType.Load:
case EventType.DomContentLoaded: {
return false;
}
case EventType.Plugin: {
return e;
}
case EventType.Custom: {
this.replaceIds(
e.data.payload as {
id?: unknown;
parentId?: unknown;
previousId?: unknown;
nextId?: unknown;
},
iframeEl,
['id', 'parentId', 'previousId', 'nextId'],
);
return e;
}
case EventType.IncrementalSnapshot: {
switch (e.data.source) {
case IncrementalSource.Mutation: {
e.data.adds.forEach((n) => {
this.replaceIds(n, iframeEl, [
'parentId',
'nextId',
'previousId',
]);
this.replaceIdOnNode(n.node, iframeEl);
});
e.data.removes.forEach((n) => {
this.replaceIds(n, iframeEl, ['parentId', 'id']);
});
e.data.attributes.forEach((n) => {
this.replaceIds(n, iframeEl, ['id']);
});
e.data.texts.forEach((n) => {
this.replaceIds(n, iframeEl, ['id']);
});
return e;
}
case IncrementalSource.Drag:
case IncrementalSource.TouchMove:
case IncrementalSource.MouseMove: {
e.data.positions.forEach((p) => {
this.replaceIds(p, iframeEl, ['id']);
});
return e;
}
case IncrementalSource.ViewportResize: {
// can safely ignore these events
return false;
}
case IncrementalSource.MediaInteraction:
case IncrementalSource.MouseInteraction:
case IncrementalSource.Scroll:
case IncrementalSource.CanvasMutation:
case IncrementalSource.Input: {
this.replaceIds(e.data, iframeEl, ['id']);
return e;
}
case IncrementalSource.StyleSheetRule:
case IncrementalSource.StyleDeclaration: {
this.replaceIds(e.data, iframeEl, ['id']);
this.replaceStyleIds(e.data, iframeEl, ['styleId']);
return e;
}
case IncrementalSource.Font: {
// fine as-is no modification needed
return e;
}
case IncrementalSource.Selection: {
e.data.ranges.forEach((range) => {
this.replaceIds(range, iframeEl, ['start', 'end']);
});
return e;
}
case IncrementalSource.AdoptedStyleSheet: {
this.replaceIds(e.data, iframeEl, ['id']);
this.replaceStyleIds(e.data, iframeEl, ['styleIds']);
e.data.styles?.forEach((style) => {
this.replaceStyleIds(style, iframeEl, ['styleId']);
});
return e;
}
}
}
}
}
private replace<T extends Record<string, unknown>>(
iframeMirror: CrossOriginIframeMirror,
obj: T,
iframeEl: HTMLIFrameElement,
keys: Array<keyof T>,
): T {
for (const key of keys) {
if (!Array.isArray(obj[key]) && typeof obj[key] !== 'number') continue;
if (Array.isArray(obj[key])) {
obj[key] = iframeMirror.getIds(
iframeEl,
obj[key] as number[],
) as T[keyof T];
} else {
(obj[key] as number) = iframeMirror.getId(iframeEl, obj[key] as number);
}
}
return obj;
}
private replaceIds<T extends Record<string, unknown>>(
obj: T,
iframeEl: HTMLIFrameElement,
keys: Array<keyof T>,
): T {
return this.replace(this.crossOriginIframeMirror, obj, iframeEl, keys);
}
private replaceStyleIds<T extends Record<string, unknown>>(
obj: T,
iframeEl: HTMLIFrameElement,
keys: Array<keyof T>,
): T {
return this.replace(this.crossOriginIframeStyleMirror, obj, iframeEl, keys);
}
private replaceIdOnNode(
node: serializedNodeWithId,
iframeEl: HTMLIFrameElement,
) {
this.replaceIds(node, iframeEl, ['id']);
if ('childNodes' in node) {
node.childNodes.forEach((child) => {
this.replaceIdOnNode(child, iframeEl);
});
}
}
}

View File

@@ -26,6 +26,7 @@ import {
canvasMutationParam,
adoptedStyleSheetParam,
} from '@rrweb/types';
import type { CrossOriginIframeMessageEventContent } from '../types';
import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager';
import { CanvasManager } from './observers/canvas/canvas-manager';
@@ -69,6 +70,7 @@ function record<T = eventWithTime>(
dataURLOptions = {},
mousemoveWait,
recordCanvas = false,
recordCrossOriginIframes = false,
userTriggeredOnInput = false,
collectFonts = false,
inlineImages = false,
@@ -77,8 +79,22 @@ function record<T = eventWithTime>(
ignoreCSSAttributes = new Set([]),
} = options;
const inEmittingFrame = recordCrossOriginIframes
? window.parent === window
: true;
let passEmitsToParent = false;
if (!inEmittingFrame) {
try {
window.parent.document; // throws if parent is cross-origin
passEmitsToParent = false; // if parent is same origin we collect iframe events from the parent
} catch (e) {
passEmitsToParent = true;
}
}
// runtime checks for user options
if (!emit) {
if (inEmittingFrame && !emit) {
throw new Error('emit function is required');
}
// move departed options to new options
@@ -138,13 +154,6 @@ function record<T = eventWithTime>(
let lastFullSnapshotEvent: eventWithTime;
let incrementalSnapshotCount = 0;
/**
* Exposes mirror to the plugins
*/
for (const plugin of plugins || []) {
if (plugin.getMirror) plugin.getMirror(mirror);
}
const eventProcessor = (e: eventWithTime): T => {
for (const plugin of plugins || []) {
if (plugin.eventProcessor) {
@@ -170,7 +179,17 @@ function record<T = eventWithTime>(
mutationBuffers.forEach((buf) => buf.unfreeze());
}
emit(eventProcessor(e), isCheckout);
if (inEmittingFrame) {
emit?.(eventProcessor(e), isCheckout);
} else if (passEmitsToParent) {
const message: CrossOriginIframeMessageEventContent<T> = {
type: 'rrweb',
event: eventProcessor(e),
isCheckout,
};
window.parent.postMessage(message, '*');
}
if (e.type === EventType.FullSnapshot) {
lastFullSnapshotEvent = e;
incrementalSnapshotCount = 0;
@@ -244,10 +263,26 @@ function record<T = eventWithTime>(
});
const iframeManager = new IframeManager({
mirror,
mutationCb: wrappedMutationEmit,
stylesheetManager: stylesheetManager,
recordCrossOriginIframes,
wrappedEmit,
});
/**
* Exposes mirror to the plugins
*/
for (const plugin of plugins || []) {
if (plugin.getMirror)
plugin.getMirror({
nodeMirror: mirror,
crossOriginIframeMirror: iframeManager.crossOriginIframeMirror,
crossOriginIframeStyleMirror:
iframeManager.crossOriginIframeStyleMirror,
});
}
canvasManager = new CanvasManager({
recordCanvas,
mutationCb: wrappedCanvasMutationEmit,
@@ -326,7 +361,7 @@ function record<T = eventWithTime>(
}
},
onIframeLoad: (iframe, childSn) => {
iframeManager.attachIframe(iframe, childSn, mirror);
iframeManager.attachIframe(iframe, childSn);
shadowDomManager.observeAttachShadow(iframe);
},
onStylesheetLoad: (linkEl, childSn) => {

View File

@@ -324,7 +324,7 @@ export default class MutationBuffer {
}
},
onIframeLoad: (iframe, childSn) => {
this.iframeManager.attachIframe(iframe, childSn, this.mirror);
this.iframeManager.attachIframe(iframe, childSn);
this.shadowDomManager.observeAttachShadow(iframe);
},
onStylesheetLoad: (link, childSn) => {

View File

@@ -198,7 +198,7 @@ export class Replayer {
* Exposes mirror to the plugins
*/
for (const plugin of this.config.plugins || []) {
if (plugin.getMirror) plugin.getMirror(this.mirror);
if (plugin.getMirror) plugin.getMirror({ nodeMirror: this.mirror });
}
this.emitter.on(ReplayerEvents.Flush, () => {

View File

@@ -1,7 +1,5 @@
import type {
serializedNodeWithId,
Mirror,
INode,
MaskInputOptions,
SlimDOMOptions,
MaskInputFn,
@@ -61,6 +59,7 @@ export type recordOptions<T> = {
sampling?: SamplingStrategy;
dataURLOptions?: DataURLOptions;
recordCanvas?: boolean;
recordCrossOriginIframes?: boolean;
userTriggeredOnInput?: boolean;
collectFonts?: boolean;
inlineImages?: boolean;
@@ -152,7 +151,7 @@ export type ReplayPlugin = {
node: Node | RRNode,
context: { id: number; replayer: Replayer },
) => void;
getMirror?: (mirror: Mirror) => void;
getMirror?: (mirrors: { nodeMirror: Mirror }) => void;
};
export type playerConfig = {
speed: number;
@@ -194,3 +193,10 @@ declare global {
FontFace: typeof FontFace;
}
}
export type CrossOriginIframeMessageEventContent<T = eventWithTime> = {
type: 'rrweb';
event: T;
isCheckout?: boolean;
};
export type CrossOriginIframeMessageEvent = MessageEvent<CrossOriginIframeMessageEventContent>;

View File

@@ -491,4 +491,8 @@ export class StyleSheetMirror {
this.idStyleMap = new Map();
this.id = 1;
}
generateId(): number {
return this.id++;
}
}

View File

@@ -4014,8 +4014,27 @@ exports[`record integration tests mutations should work when blocked class is un
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n\\",
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"style\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"#b-class, #b-class-2 { height: 33px; width: 200px; }\\",
\\"isStyle\\": true,
\\"id\\": 9
}
],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\",
\\"id\\": 10
}
],
\\"id\\": 3
@@ -4023,7 +4042,7 @@ exports[`record integration tests mutations should work when blocked class is un
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n\\",
\\"id\\": 8
\\"id\\": 11
},
{
\\"type\\": 2,
@@ -4033,7 +4052,7 @@ exports[`record integration tests mutations should work when blocked class is un
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 10
\\"id\\": 13
},
{
\\"type\\": 2,
@@ -4043,40 +4062,22 @@ exports[`record integration tests mutations should work when blocked class is un
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 12
\\"id\\": 15
}
],
\\"id\\": 11
\\"id\\": 14
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 13
\\"id\\": 16
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 14
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 15
},
{
\\"type\\": 2,
\\"tagName\\": \\"h1\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n Verify that block class bugs are fixed\\\\n \\",
\\"id\\": 17
}
],
\\"id\\": 16
\\"id\\": 17
},
{
\\"type\\": 3,
@@ -4085,15 +4086,33 @@ exports[`record integration tests mutations should work when blocked class is un
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"tagName\\": \\"h1\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"Verify that block class bugs are fixed\\",
\\"id\\": 20
}
],
\\"id\\": 19
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 20
\\"id\\": 21
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 22
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 23
},
{
\\"type\\": 2,
@@ -4105,7 +4124,7 @@ exports[`record integration tests mutations should work when blocked class is un
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 22
\\"id\\": 25
},
{
\\"type\\": 2,
@@ -4117,7 +4136,7 @@ exports[`record integration tests mutations should work when blocked class is un
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 24
\\"id\\": 27
},
{
\\"type\\": 2,
@@ -4127,91 +4146,91 @@ exports[`record integration tests mutations should work when blocked class is un
{
\\"type\\": 3,
\\"textContent\\": \\"VISIBLE\\",
\\"id\\": 26
\\"id\\": 29
}
],
\\"id\\": 25
\\"id\\": 28
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 27
\\"id\\": 30
}
],
\\"id\\": 23
\\"id\\": 26
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 28
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 29
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 30
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 31
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 32
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 33
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 34
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 32
\\"id\\": 35
},
{
\\"type\\": 2,
\\"tagName\\": \\"div\\",
\\"attributes\\": {
\\"class\\": \\"rr-block\\",
\\"rr_width\\": \\"1904px\\",
\\"rr_height\\": \\"21px\\"
\\"rr_width\\": \\"200px\\",
\\"rr_height\\": \\"33px\\"
},
\\"childNodes\\": [],
\\"id\\": 33
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 34
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 35
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 36
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 37
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 37
\\"id\\": 38
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 39
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 40
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 38
\\"id\\": 41
},
{
\\"type\\": 2,
@@ -4223,49 +4242,49 @@ exports[`record integration tests mutations should work when blocked class is un
{
\\"type\\": 3,
\\"textContent\\": \\"MUTATE\\",
\\"id\\": 40
\\"id\\": 43
}
],
\\"id\\": 39
\\"id\\": 42
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 41
\\"id\\": 44
}
],
\\"id\\": 21
\\"id\\": 24
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 42
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 43
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 44
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 45
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 46
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 47
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 48
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 46
\\"id\\": 49
},
{
\\"type\\": 2,
@@ -4277,7 +4296,7 @@ exports[`record integration tests mutations should work when blocked class is un
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 48
\\"id\\": 51
},
{
\\"type\\": 2,
@@ -4289,7 +4308,7 @@ exports[`record integration tests mutations should work when blocked class is un
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 50
\\"id\\": 53
},
{
\\"type\\": 2,
@@ -4299,91 +4318,91 @@ exports[`record integration tests mutations should work when blocked class is un
{
\\"type\\": 3,
\\"textContent\\": \\"VISIBLE\\",
\\"id\\": 52
\\"id\\": 55
}
],
\\"id\\": 51
\\"id\\": 54
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 53
\\"id\\": 56
}
],
\\"id\\": 49
\\"id\\": 52
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 54
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 55
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 56
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 57
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 58
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 59
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 60
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 58
\\"id\\": 61
},
{
\\"type\\": 2,
\\"tagName\\": \\"div\\",
\\"attributes\\": {
\\"class\\": \\"rr-block\\",
\\"rr_width\\": \\"1904px\\",
\\"rr_height\\": \\"21px\\"
\\"rr_width\\": \\"200px\\",
\\"rr_height\\": \\"33px\\"
},
\\"childNodes\\": [],
\\"id\\": 59
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 60
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 61
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 62
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 63
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 63
\\"id\\": 64
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 65
},
{
\\"type\\": 2,
\\"tagName\\": \\"br\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 66
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 64
\\"id\\": 67
},
{
\\"type\\": 2,
@@ -4395,23 +4414,23 @@ exports[`record integration tests mutations should work when blocked class is un
{
\\"type\\": 3,
\\"textContent\\": \\"MUTATE\\",
\\"id\\": 66
\\"id\\": 69
}
],
\\"id\\": 65
\\"id\\": 68
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 67
\\"id\\": 70
}
],
\\"id\\": 47
\\"id\\": 50
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"id\\": 68
\\"id\\": 71
},
{
\\"type\\": 2,
@@ -4421,18 +4440,18 @@ exports[`record integration tests mutations should work when blocked class is un
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 70
\\"id\\": 73
}
],
\\"id\\": 69
\\"id\\": 72
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\",
\\"id\\": 71
\\"id\\": 74
}
],
\\"id\\": 9
\\"id\\": 12
}
],
\\"id\\": 2
@@ -4452,7 +4471,7 @@ exports[`record integration tests mutations should work when blocked class is un
\\"data\\": {
\\"source\\": 2,
\\"type\\": 1,
\\"id\\": 39
\\"id\\": 42
}
},
{
@@ -4460,7 +4479,7 @@ exports[`record integration tests mutations should work when blocked class is un
\\"data\\": {
\\"source\\": 2,
\\"type\\": 5,
\\"id\\": 39
\\"id\\": 42
}
},
{
@@ -4468,7 +4487,7 @@ exports[`record integration tests mutations should work when blocked class is un
\\"data\\": {
\\"source\\": 2,
\\"type\\": 0,
\\"id\\": 39
\\"id\\": 42
}
},
{
@@ -4476,7 +4495,7 @@ exports[`record integration tests mutations should work when blocked class is un
\\"data\\": {
\\"source\\": 2,
\\"type\\": 2,
\\"id\\": 39
\\"id\\": 42
}
},
{
@@ -4486,7 +4505,7 @@ exports[`record integration tests mutations should work when blocked class is un
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 33,
\\"id\\": 36,
\\"attributes\\": {
\\"class\\": \\"notB\\"
}
@@ -4495,50 +4514,19 @@ exports[`record integration tests mutations should work when blocked class is un
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 23,
\\"parentId\\": 26,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"div\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 72
}
},
{
\\"parentId\\": 72,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"div\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 73
}
},
{
\\"parentId\\": 73,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"button\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 74
}
},
{
\\"parentId\\": 74,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"I1I2 VISIBLE\\",
\\"id\\": 75
}
},
{
\\"parentId\\": 72,
\\"nextId\\": 73,
\\"parentId\\": 75,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"div\\",
@@ -4563,71 +4551,13 @@ exports[`record integration tests mutations should work when blocked class is un
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"I1I1 VISIBLE\\",
\\"textContent\\": \\"I1I2 VISIBLE\\",
\\"id\\": 78
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 1,
\\"id\\": 65
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 6,
\\"id\\": 39
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 5,
\\"id\\": 65
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 0,
\\"id\\": 65
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 2,
\\"id\\": 65
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
},
{
\\"id\\": 59,
\\"attributes\\": {
\\"class\\": \\"notB\\"
}
}
],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 49,
\\"nextId\\": null,
\\"parentId\\": 75,
\\"nextId\\": 76,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"div\\",
@@ -4641,7 +4571,7 @@ exports[`record integration tests mutations should work when blocked class is un
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"div\\",
\\"tagName\\": \\"button\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 80
@@ -4651,25 +4581,83 @@ exports[`record integration tests mutations should work when blocked class is un
\\"parentId\\": 80,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"button\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"type\\": 3,
\\"textContent\\": \\"I1I1 VISIBLE\\",
\\"id\\": 81
}
},
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 1,
\\"id\\": 68
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 6,
\\"id\\": 42
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 5,
\\"id\\": 68
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 0,
\\"id\\": 68
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 2,
\\"id\\": 68
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"parentId\\": 81,
\\"id\\": 62,
\\"attributes\\": {
\\"class\\": \\"notB\\"
}
}
],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 52,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"I1I2 VISIBLE\\",
\\"type\\": 2,
\\"tagName\\": \\"div\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 82
}
},
{
\\"parentId\\": 79,
\\"nextId\\": 80,
\\"parentId\\": 82,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"div\\",
@@ -4694,9 +4682,40 @@ exports[`record integration tests mutations should work when blocked class is un
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"I1I1 VISIBLE\\",
\\"textContent\\": \\"I1I2 VISIBLE\\",
\\"id\\": 85
}
},
{
\\"parentId\\": 82,
\\"nextId\\": 83,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"div\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 86
}
},
{
\\"parentId\\": 86,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"button\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 87
}
},
{
\\"parentId\\": 87,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"I1I1 VISIBLE\\",
\\"id\\": 88
}
}
]
}

View File

@@ -1905,168 +1905,6 @@ exports[`record captures stylesheet rules 1`] = `
]"
`;
exports[`record captures stylesheets in iframes that are still loading 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"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\\": \\"input\\",
\\"attributes\\": {
\\"type\\": \\"text\\",
\\"size\\": \\"40\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
},
{
\\"type\\": 2,
\\"tagName\\": \\"iframe\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 9
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"adds\\": [
{
\\"parentId\\": 9,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"link\\",
\\"attributes\\": {
\\"rel\\": \\"stylesheet\\",
\\"href\\": \\"blob:null\\"
},
\\"childNodes\\": [],
\\"rootId\\": 10,
\\"id\\": 13
}
],
\\"rootId\\": 10,
\\"id\\": 12
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"rootId\\": 10,
\\"id\\": 14
}
],
\\"rootId\\": 10,
\\"id\\": 11
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 10
}
}
],
\\"removes\\": [],
\\"texts\\": [],
\\"attributes\\": [],
\\"isAttachIframe\\": true
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"adds\\": [],
\\"removes\\": [],
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 13,
\\"attributes\\": {
\\"_cssText\\": \\"body { color: pink; }\\"
}
}
]
}
}
]"
`;
exports[`record captures stylesheets in iframes with \`blob:\` url 1`] = `
"[
{
@@ -2211,126 +2049,6 @@ exports[`record captures stylesheets in iframes with \`blob:\` url 1`] = `
]"
`;
exports[`record captures stylesheets that are still loading 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"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\\": \\"input\\",
\\"attributes\\": {
\\"type\\": \\"text\\",
\\"size\\": \\"40\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 4,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"link\\",
\\"attributes\\": {
\\"rel\\": \\"stylesheet\\",
\\"href\\": \\"blob:null\\"
},
\\"childNodes\\": [],
\\"id\\": 9
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"adds\\": [],
\\"removes\\": [],
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 9,
\\"attributes\\": {
\\"_cssText\\": \\"body { color: pink; }\\"
}
}
]
}
}
]"
`;
exports[`record captures stylesheets with \`blob:\` url 1`] = `
"[
{
@@ -2910,6 +2628,522 @@ exports[`record is safe to checkout during async callbacks 1`] = `
]"
`;
exports[`record loading stylesheets captures stylesheets in iframes that are still loading 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {
\\"lang\\": \\"en\\"
},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 5
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"charset\\": \\"UTF-8\\"
},
\\"childNodes\\": [],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"http-equiv\\": \\"X-UA-Compatible\\",
\\"content\\": \\"IE=edge\\"
},
\\"childNodes\\": [],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 9
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"name\\": \\"viewport\\",
\\"content\\": \\"width=device-width, initial-scale=1.0\\"
},
\\"childNodes\\": [],
\\"id\\": 10
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 11
},
{
\\"type\\": 2,
\\"tagName\\": \\"title\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"Hello World!\\",
\\"id\\": 13
}
],
\\"id\\": 12
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 14
}
],
\\"id\\": 4
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 15
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n Hello world!\\\\n \\\\n\\\\n\\",
\\"id\\": 17
},
{
\\"type\\": 2,
\\"tagName\\": \\"iframe\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 18
}
],
\\"id\\": 16
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"adds\\": [
{
\\"parentId\\": 18,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"rootId\\": 19,
\\"id\\": 20
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {
\\"lang\\": \\"en\\"
},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"rootId\\": 19,
\\"id\\": 23
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"charset\\": \\"UTF-8\\"
},
\\"childNodes\\": [],
\\"rootId\\": 19,
\\"id\\": 24
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"rootId\\": 19,
\\"id\\": 25
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"http-equiv\\": \\"X-UA-Compatible\\",
\\"content\\": \\"IE=edge\\"
},
\\"childNodes\\": [],
\\"rootId\\": 19,
\\"id\\": 26
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"rootId\\": 19,
\\"id\\": 27
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"name\\": \\"viewport\\",
\\"content\\": \\"width=device-width, initial-scale=1.0\\"
},
\\"childNodes\\": [],
\\"rootId\\": 19,
\\"id\\": 28
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"rootId\\": 19,
\\"id\\": 29
},
{
\\"type\\": 2,
\\"tagName\\": \\"title\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"Hello World!\\",
\\"rootId\\": 19,
\\"id\\": 31
}
],
\\"rootId\\": 19,
\\"id\\": 30
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"rootId\\": 19,
\\"id\\": 32
}
],
\\"rootId\\": 19,
\\"id\\": 22
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"rootId\\": 19,
\\"id\\": 33
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n Hello world!\\\\n \\\\n\\\\n\\",
\\"rootId\\": 19,
\\"id\\": 35
}
],
\\"rootId\\": 19,
\\"id\\": 34
}
],
\\"rootId\\": 19,
\\"id\\": 21
}
],
\\"id\\": 19
}
}
],
\\"removes\\": [],
\\"texts\\": [],
\\"attributes\\": [],
\\"isAttachIframe\\": true
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 22,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"link\\",
\\"attributes\\": {
\\"rel\\": \\"stylesheet\\",
\\"href\\": \\"http://localhost:3030/html/assets/style.css\\"
},
\\"childNodes\\": [],
\\"rootId\\": 19,
\\"id\\": 36
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"adds\\": [],
\\"removes\\": [],
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 36,
\\"attributes\\": {
\\"_cssText\\": \\"body { color: pink; }\\"
}
}
]
}
}
]"
`;
exports[`record loading stylesheets captures stylesheets that are still loading 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {
\\"lang\\": \\"en\\"
},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 5
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"charset\\": \\"UTF-8\\"
},
\\"childNodes\\": [],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"http-equiv\\": \\"X-UA-Compatible\\",
\\"content\\": \\"IE=edge\\"
},
\\"childNodes\\": [],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 9
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"name\\": \\"viewport\\",
\\"content\\": \\"width=device-width, initial-scale=1.0\\"
},
\\"childNodes\\": [],
\\"id\\": 10
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 11
},
{
\\"type\\": 2,
\\"tagName\\": \\"title\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"Hello World!\\",
\\"id\\": 13
}
],
\\"id\\": 12
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 14
}
],
\\"id\\": 4
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 15
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n Hello world!\\\\n \\\\n\\\\n\\",
\\"id\\": 17
}
],
\\"id\\": 16
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 4,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"link\\",
\\"attributes\\": {
\\"rel\\": \\"stylesheet\\",
\\"href\\": \\"http://localhost:3030/html/assets/style.css\\"
},
\\"childNodes\\": [],
\\"id\\": 18
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"adds\\": [],
\\"removes\\": [],
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 18,
\\"attributes\\": {
\\"_cssText\\": \\"body { color: pink; }\\"
}
}
]
}
}
]"
`;
exports[`record should record scroll position 1`] = `
"[
{

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -76,7 +76,9 @@ describe('e2e webgl', () => {
const hideMouseAnimation = async (p: puppeteer.Page) => {
await p.addStyleTag({
content: '.replayer-mouse-tail{display: none !important;}',
content: `.replayer-mouse-tail{display: none !important;}
html, body { margin: 0; padding: 0; }
iframe { border: none; }`,
});
};
@@ -90,7 +92,9 @@ describe('e2e webgl', () => {
await waitForRAF(page);
const snapshots: eventWithTime[] = await page.evaluate('window.snapshots');
const snapshots: eventWithTime[] = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
page = await browser.newPage();
@@ -108,9 +112,8 @@ describe('e2e webgl', () => {
`);
await waitForRAF(page);
const element = await page.$('iframe');
const frameImage = await element!.screenshot();
const frameImage = await page!.screenshot();
await waitForRAF(page);
expect(frameImage).toMatchImageSnapshot();
});
@@ -122,8 +125,11 @@ describe('e2e webgl', () => {
getHtml.call(this, 'canvas-webgl-image.html', { recordCanvas: true }),
);
await waitForRAF(page);
await page.waitForTimeout(100);
const snapshots: eventWithTime[] = await page.evaluate('window.snapshots');
const snapshots: eventWithTime[] = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
page = await browser.newPage();
@@ -143,9 +149,7 @@ describe('e2e webgl', () => {
await page.evaluate(`replayer.play(500);`);
await waitForRAF(page);
const element = await page.$('iframe');
const frameImage = await element!.screenshot();
const frameImage = await page!.screenshot();
expect(frameImage).toMatchImageSnapshot();
});
});

View File

@@ -0,0 +1,3 @@
body {
color: pink;
}

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Audio</title>
</head>
<body>
<h1>1 minute of silence</h1>
<audio controls>
<source src="assets/1-minute-of-silence.mp3" type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
</body>
</html>

View File

@@ -1,88 +1,92 @@
<head>
<title>Uber Application for Codegen Testing</title>
<style>
#b-class,
#b-class-2 {
height: 33px;
width: 200px;
}
</style>
</head>
<body>
<script>
function mutate1() {
const bClassDiv = document.getElementById("b-class");
bClassDiv.className = "notB";
function mutate1() {
const bClassDiv = document.getElementById('b-class');
bClassDiv.className = 'notB';
const removeBlockedButton = document.getElementById("remove");
removeBlockedButton.remove();
const removeBlockedButton = document.getElementById('remove');
removeBlockedButton.remove();
const visibleCollection = document.getElementsByClassName("visible");
const i1Div = document.createElement("div");
const i1i1Div = document.createElement("div");
const i1i2Div = document.createElement("div");
const visibleCollection = document.getElementsByClassName('visible');
const i1Div = document.createElement('div');
const i1i1Div = document.createElement('div');
const i1i2Div = document.createElement('div');
const i1i1Button = document.createElement("button");
i1i1Button.innerHTML = "I1I1 VISIBLE";
i1i1Div.appendChild(i1i1Button);
const i1i1Button = document.createElement('button');
i1i1Button.innerHTML = 'I1I1 VISIBLE';
i1i1Div.appendChild(i1i1Button);
const i1i2Button = document.createElement("button");
i1i2Button.innerHTML = "I1I2 VISIBLE";
i1i2Div.appendChild(i1i2Button);
const i1i2Button = document.createElement('button');
i1i2Button.innerHTML = 'I1I2 VISIBLE';
i1i2Div.appendChild(i1i2Button);
i1Div.appendChild(i1i1Div);
i1Div.appendChild(i1i2Div);
visibleCollection[0].appendChild(i1Div);
}
function mutate2() {
const bClassDiv = document.getElementById("b-class-2");
bClassDiv.className = "notB";
i1Div.appendChild(i1i1Div);
i1Div.appendChild(i1i2Div);
visibleCollection[0].appendChild(i1Div);
}
function mutate2() {
const bClassDiv = document.getElementById('b-class-2');
bClassDiv.className = 'notB';
const removeBlockedButton = document.getElementById("remove2");
const innerButton = document.createElement("button");
innerButton.innerHTML = "INNER BLOCKED";
removeBlockedButton.appendChild(innerButton)
removeBlockedButton.remove();
const removeBlockedButton = document.getElementById('remove2');
const innerButton = document.createElement('button');
innerButton.innerHTML = 'INNER BLOCKED';
removeBlockedButton.appendChild(innerButton);
removeBlockedButton.remove();
const visibleCollection = document.getElementsByClassName("visible2");
const i1Div = document.createElement("div");
const i1i1Div = document.createElement("div");
const i1i2Div = document.createElement("div");
const visibleCollection = document.getElementsByClassName('visible2');
const i1Div = document.createElement('div');
const i1i1Div = document.createElement('div');
const i1i2Div = document.createElement('div');
const i1i1Button = document.createElement("button");
i1i1Button.innerHTML = "I1I1 VISIBLE";
i1i1Div.appendChild(i1i1Button);
const i1i1Button = document.createElement('button');
i1i1Button.innerHTML = 'I1I1 VISIBLE';
i1i1Div.appendChild(i1i1Button);
const i1i2Button = document.createElement("button");
i1i2Button.innerHTML = "I1I2 VISIBLE";
i1i2Div.appendChild(i1i2Button);
const i1i2Button = document.createElement('button');
i1i2Button.innerHTML = 'I1I2 VISIBLE';
i1i2Div.appendChild(i1i2Button);
i1Div.appendChild(i1i1Div);
i1Div.appendChild(i1i2Div);
visibleCollection[0].appendChild(i1Div);
}
i1Div.appendChild(i1i1Div);
i1Div.appendChild(i1i2Div);
visibleCollection[0].appendChild(i1Div);
}
</script>
<br/>
<h1>
Verify that block class bugs are fixed
</h1>
<br/>
<br />
<h1>Verify that block class bugs are fixed</h1>
<br />
<div class="first">
<div class="visible">
<button>VISIBLE</button>
</div>
<br/><br/><br/>
<br /><br /><br />
<div class="rr-block" id="b-class">
<button id="remove">BLOCKED</button>
</div>
<br/><br/><br/>
<br /><br /><br />
<button onclick="mutate1()">MUTATE</button>
</div>
<br/><br/><br/>
<br /><br /><br />
<div class="second">
<div class="visible2">
<button>VISIBLE</button>
</div>
<br/><br/><br/>
<br /><br /><br />
<div class="rr-block" id="b-class-2">
<button id="remove2">BLOCKED</button>
</div>
<br/><br/><br/>
<br /><br /><br />
<button onclick="mutate2()">MUTATE</button>
</div>
</body>

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hello World!</title>
</head>
<body>
Hello world!
</body>
</html>

View File

@@ -73,7 +73,9 @@ describe('record integration tests', function (this: ISuite) {
await page.type('textarea', 'textarea test');
await page.select('select', '1');
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -91,7 +93,9 @@ describe('record integration tests', function (this: ISuite) {
p.appendChild(document.createElement('span'));
});
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -111,7 +115,9 @@ describe('record integration tests', function (this: ISuite) {
p.innerText = 'mutated';
});
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -129,7 +135,9 @@ describe('record integration tests', function (this: ISuite) {
document.body.setAttribute('test', 'true');
});
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -146,7 +154,9 @@ describe('record integration tests', function (this: ISuite) {
await page.evaluate(
'document.getElementById("select2-drop").setAttribute("style", document.getElementById("select2-drop").style.cssText + "color:black !important")',
);
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -180,7 +190,9 @@ describe('record integration tests', function (this: ISuite) {
await waitForRAF(page);
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -191,7 +203,9 @@ describe('record integration tests', function (this: ISuite) {
await page.type('.rr-ignore', 'secret');
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -209,7 +223,9 @@ describe('record integration tests', function (this: ISuite) {
await page.type('textarea', 'textarea test');
await page.select('select', '1');
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -233,7 +249,9 @@ describe('record integration tests', function (this: ISuite) {
await page.type('input[type="password"]', 'password');
await page.select('select', '1');
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -250,7 +268,9 @@ describe('record integration tests', function (this: ISuite) {
await page.type('input[type="password"]', 'secr3t');
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -268,7 +288,9 @@ describe('record integration tests', function (this: ISuite) {
await page.type('textarea', 'textarea test');
await page.select('select', '1');
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -281,7 +303,9 @@ describe('record integration tests', function (this: ISuite) {
await page.evaluate(`document.getElementById('text').innerText = '1'`);
await page.click('#text');
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -301,7 +325,9 @@ describe('record integration tests', function (this: ISuite) {
nextElement.parentNode!.insertBefore(el, nextElement);
});
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -310,13 +336,19 @@ describe('record integration tests', function (this: ISuite) {
await page.goto('about: blank');
await page.setContent(getHtml.call(this, 'blocked-unblocked.html'));
const elements1 = await page.$x('/html/body/div[1]/button');
const elements1 = (await page.$x(
'/html/body/div[1]/button',
)) as puppeteer.ElementHandle<HTMLButtonElement>[];
await elements1[0].click();
const elements2 = await page.$x('/html/body/div[2]/button');
const elements2 = (await page.$x(
'/html/body/div[2]/button',
)) as puppeteer.ElementHandle<HTMLButtonElement>[];
await elements2[0].click();
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -334,7 +366,9 @@ describe('record integration tests', function (this: ISuite) {
p.removeChild(span);
div.appendChild(span);
});
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -349,7 +383,9 @@ describe('record integration tests', function (this: ISuite) {
document.body.appendChild(div);
div.appendChild(span);
});
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -358,7 +394,9 @@ describe('record integration tests', function (this: ISuite) {
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'react-styled-components.html'));
await page.click('.toggle');
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -371,7 +409,9 @@ describe('record integration tests', function (this: ISuite) {
}),
);
await waitForRAF(page);
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
for (const event of snapshots) {
if (event.type === EventType.FullSnapshot) {
visitSnapshot(event.data.node, (n) => {
@@ -393,7 +433,9 @@ describe('record integration tests', function (this: ISuite) {
}),
);
await page.waitForTimeout(50);
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -406,7 +448,9 @@ describe('record integration tests', function (this: ISuite) {
}),
);
await waitForRAF(page);
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -425,7 +469,9 @@ describe('record integration tests', function (this: ISuite) {
}
});
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -483,11 +529,15 @@ describe('record integration tests', function (this: ISuite) {
document.body.appendChild(iframe);
});
await waitForRAF(page);
await page.frames()[1].evaluate(() => {
console.log('from iframe');
});
await waitForRAF(page);
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -504,7 +554,9 @@ describe('record integration tests', function (this: ISuite) {
await page.waitForTimeout(50);
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -519,7 +571,9 @@ describe('record integration tests', function (this: ISuite) {
await page.waitForSelector('img'); // wait for image to get added
await waitForRAF(page); // wait for image to be captured
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -534,7 +588,9 @@ describe('record integration tests', function (this: ISuite) {
await page.waitForTimeout(50); // wait for image to get added
await waitForRAF(page); // wait for image to be captured
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -555,7 +611,9 @@ describe('record integration tests', function (this: ISuite) {
await page.waitForTimeout(50); // wait for image to get added
await waitForRAF(page); // wait for image to be captured
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -603,7 +661,9 @@ describe('record integration tests', function (this: ISuite) {
});
await page.waitForTimeout(50);
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -646,7 +706,9 @@ describe('record integration tests', function (this: ISuite) {
});
await waitForRAF(page); // wait till browser sent snapshots
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -674,7 +736,9 @@ describe('record integration tests', function (this: ISuite) {
});
await waitForRAF(page); // wait for snapshot to be updated
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -706,7 +770,9 @@ describe('record integration tests', function (this: ISuite) {
});
await waitForRAF(page); // wait till browser sent snapshots
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -746,7 +812,9 @@ describe('record integration tests', function (this: ISuite) {
});
await waitForRAF(page); // wait till browser sent snapshots
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -759,7 +827,9 @@ describe('record integration tests', function (this: ISuite) {
}),
);
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -773,7 +843,9 @@ describe('record integration tests', function (this: ISuite) {
}),
);
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -794,7 +866,9 @@ describe('record integration tests', function (this: ISuite) {
p.innerText = 'mutated';
});
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
});

View File

@@ -11,7 +11,14 @@ import {
styleSheetRuleData,
selectionData,
} from '@rrweb/types';
import { assertSnapshot, launchPuppeteer, waitForRAF } from './utils';
import {
assertSnapshot,
getServerURL,
launchPuppeteer,
startServer,
waitForRAF,
} from './utils';
import type { Server } from 'http';
interface ISuite {
code: string;
@@ -465,54 +472,63 @@ describe('record', function (this: ISuite) {
it('captures mutations on adopted stylesheets', async () => {
await ctx.page.evaluate(() => {
document.body.innerHTML = `
return new Promise((resolve) => {
document.body.innerHTML = `
<div>div in outermost document</div>
<iframe></iframe>
`;
const sheet = new CSSStyleSheet();
// Add stylesheet to a document.
const sheet = new CSSStyleSheet();
// Add stylesheet to a document.
document.adoptedStyleSheets = [sheet];
document.adoptedStyleSheets = [sheet];
const iframe = document.querySelector('iframe');
const sheet2 = new (iframe!.contentWindow! as Window &
typeof globalThis).CSSStyleSheet();
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>';
// 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,
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);
setTimeout(() => {
resolve(undefined);
}, 20);
});
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);
@@ -602,70 +618,91 @@ describe('record', function (this: ISuite) {
assertSnapshot(ctx.events);
});
it('captures stylesheets that are still loading', async () => {
await ctx.page.evaluate(() => {
const { record } = ((window as unknown) as IWindow).rrweb;
describe('loading stylesheets', () => {
let server: Server;
let serverURL: string;
record({
inlineStylesheet: true,
emit: ((window as unknown) as IWindow).emit,
});
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);
beforeAll(async () => {
server = await startServer();
serverURL = getServerURL(server);
});
// `blob:` URLs are not available immediately, so we need to wait for the browser to load them
await waitForRAF(ctx.page);
// 'blob' URL is different in every execution so we need to remove it from the snapshot.
const filteredEvents = JSON.parse(
JSON.stringify(ctx.events).replace(/blob\:[\w\d-/]+"/, 'blob:null"'),
);
assertSnapshot(filteredEvents);
});
it('captures stylesheets in iframes that are still loading', async () => {
await ctx.page.evaluate(() => {
const iframe = document.createElement('iframe');
iframe.setAttribute('src', 'about:blank');
document.body.appendChild(iframe);
const iframeDoc = iframe.contentDocument!;
const { record } = ((window as unknown) as IWindow).rrweb;
record({
inlineStylesheet: true,
emit: ((window as unknown) as IWindow).emit,
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);
});
const linkEl = document.createElement('link');
linkEl.setAttribute('rel', 'stylesheet');
linkEl.setAttribute(
'href',
URL.createObjectURL(
new Blob(['body { color: pink; }'], {
type: 'text/css',
}),
),
);
iframeDoc.head.appendChild(linkEl);
ctx.page.on('console', (msg) => console.log('PAGE LOG:', msg.text()));
});
// `blob:` URLs are not available immediately, so we need to wait for the browser to load them
await waitForRAF(ctx.page);
const filteredEvents = JSON.parse(
JSON.stringify(ctx.events).replace(/blob\:[\w\d-/]+"/, 'blob:null"'),
);
assertSnapshot(filteredEvents);
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 () => {
@@ -695,65 +732,71 @@ describe('record', function (this: ISuite) {
it('captures adopted stylesheets in shadow doms and iframe', async () => {
await ctx.page.evaluate(() => {
document.body.innerHTML = `
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.
const sheet = new CSSStyleSheet();
sheet.replaceSync!(
'div { color: yellow; } h2 { color: orange; } h3 { font-size: larger;}',
);
// Add stylesheet to a document.
document.adoptedStyleSheets = [sheet];
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');
// 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 2</div><span>span in shadow dom 2</span>';
const sheet4 = new CSSStyleSheet();
sheet4.replaceSync!('span { color: green; }');
shadow.adoptedStyleSheets = [sheet, sheet4];
'<div>div in shadow dom 1</div><span>span in shadow dom 1</span>';
const sheet2 = new CSSStyleSheet();
document.adoptedStyleSheets = [sheet4, sheet, sheet2];
sheet2.replaceSync!('span { color: red; }');
const sheet5 = new (iframe!.contentWindow! as IWindow &
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();
sheet5.replaceSync!('h2 { color: purple; }');
iframe!.contentDocument!.adoptedStyleSheets = [sheet5, sheet3];
}, 10);
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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,514 @@
import * as fs from 'fs';
import * as path from 'path';
import type * as puppeteer from 'puppeteer';
import type { recordOptions } from '../../src/types';
import type {
listenerHandler,
eventWithTime,
mutationData,
} from '@rrweb/types';
import { EventType, IncrementalSource } from '@rrweb/types';
import {
assertSnapshot,
getServerURL,
launchPuppeteer,
startServer,
stripBase64,
waitForRAF,
} from '../utils';
import type * as http from 'http';
interface ISuite {
code: string;
browser: puppeteer.Browser;
page: puppeteer.Page;
events: eventWithTime[];
server: http.Server;
serverURL: string;
}
interface IWindow extends Window {
rrweb: {
record: (
options: recordOptions<eventWithTime>,
) => listenerHandler | undefined;
addCustomEvent<T>(tag: string, payload: T): void;
};
emit: (e: eventWithTime) => undefined;
snapshots: eventWithTime[];
}
async function injectRecordScript(frame: puppeteer.Frame) {
await frame.addScriptTag({
path: path.resolve(__dirname, '../../dist/rrweb.js'),
});
await frame.evaluate(() => {
((window as unknown) as IWindow).snapshots = [];
const { record } = ((window as unknown) as IWindow).rrweb;
record({
recordCrossOriginIframes: true,
recordCanvas: true,
emit(event) {
((window as unknown) as IWindow).snapshots.push(event);
((window as unknown) as IWindow).emit(event);
},
});
});
for (const child of frame.childFrames()) {
await injectRecordScript(child);
}
}
const setup = function (this: ISuite, content: string): ISuite {
const ctx = {} as ISuite;
beforeAll(async () => {
ctx.browser = await launchPuppeteer();
ctx.server = await startServer();
ctx.serverURL = getServerURL(ctx.server);
// ctx.serverB = await startServer();
// ctx.serverBURL = getServerURL(ctx.serverB);
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.replace(/\{SERVER_URL\}/g, ctx.serverURL),
);
// 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()));
await injectRecordScript(ctx.page.mainFrame());
});
afterEach(async () => {
await ctx.page.close();
});
afterAll(async () => {
await ctx.browser.close();
ctx.server.close();
// ctx.serverB.close();
});
return ctx;
};
describe('cross origin iframes', function (this: ISuite) {
jest.setTimeout(100_000);
describe('form.html', function (this: ISuite) {
const ctx: ISuite = setup.call(
this,
`
<!DOCTYPE html>
<html>
<body>
<iframe src="{SERVER_URL}/html/form.html" style="width: 400px; height: 400px;"></iframe>
</body>
</html>
`,
);
it("won't emit events if it isn't in the top level iframe", async () => {
const el = (await ctx.page.$(
'body > iframe',
)) as puppeteer.ElementHandle<Element>;
const frame = await el.contentFrame();
const events = await frame?.evaluate(
() => ((window as unknown) as IWindow).snapshots,
);
expect(events).toMatchObject([]);
});
it('will emit events if it is in the top level iframe', async () => {
const events = await ctx.page.evaluate(
() => ((window as unknown) as IWindow).snapshots,
);
expect(events.length).not.toBe(0);
});
it('should emit contents of iframe', async () => {
const events = await ctx.page.evaluate(
() => ((window as unknown) as IWindow).snapshots,
);
await waitForRAF(ctx.page);
// two events (full snapshot + meta) from main frame, and one full snapshot from iframe
expect(events.length).toBe(3);
});
it('should emit full snapshot event from iframe as mutation event', async () => {
const events = await ctx.page.evaluate(
() => ((window as unknown) as IWindow).snapshots,
);
await waitForRAF(ctx.page);
// two events from main frame, and two from iframe
expect(events[events.length - 1]).toMatchObject({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
adds: [
{
parentId: expect.any(Number),
node: {
id: expect.any(Number),
},
},
],
},
});
});
it('should use unique id for child of iframes', async () => {
const events: eventWithTime[] = await ctx.page.evaluate(
() => ((window as unknown) as IWindow).snapshots,
);
await waitForRAF(ctx.page);
expect(
(events[events.length - 1].data as mutationData).adds[0].node.id,
).not.toBe(1);
});
it('should replace the existing DOM nodes on iframe navigation with `isAttachIframe`', async () => {
await ctx.page.evaluate((url) => {
const iframe = document.querySelector('iframe') as HTMLIFrameElement;
iframe.src = `${url}/html/form.html?2`;
}, ctx.serverURL);
await waitForRAF(ctx.page); // loads iframe
await injectRecordScript(ctx.page.mainFrame().childFrames()[0]); // injects script into new iframe
const events: eventWithTime[] = await ctx.page.evaluate(
() => ((window as unknown) as IWindow).snapshots,
);
expect(
(events[events.length - 1].data as mutationData).removes,
).toMatchObject([]);
expect(
(events[events.length - 1].data as mutationData).isAttachIframe,
).toBeTruthy();
});
it('should map input events correctly', async () => {
const frame = ctx.page.mainFrame().childFrames()[0];
await frame.type('input[type="text"]', 'test');
await frame.click('input[type="radio"]');
await frame.click('input[type="checkbox"]');
await frame.type('input[type="password"]', 'password');
await frame.type('textarea', 'textarea test');
await frame.select('select', '1');
const snapshots = (await ctx.page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
it('should map scroll events correctly', async () => {
// force scrollbars in iframe
ctx.page.evaluate(() => {
const iframe = document.querySelector('iframe') as HTMLIFrameElement;
iframe.style.width = '50px';
iframe.style.height = '50px';
});
await waitForRAF(ctx.page);
const frame = ctx.page.mainFrame().childFrames()[0];
// scroll a little
frame.evaluate(() => {
window.scrollTo(0, 10);
});
await waitForRAF(ctx.page);
const snapshots = (await ctx.page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
});
describe('move-node.html', function (this: ISuite) {
const ctx: ISuite = setup.call(
this,
`
<!DOCTYPE html>
<html>
<body>
<iframe src="{SERVER_URL}/html/move-node.html"></iframe>
</body>
</html>
`,
);
it('should record DOM node movement', async () => {
const frame = ctx.page.mainFrame().childFrames()[0];
await frame.evaluate(() => {
const div = document.createElement('div');
const span = document.querySelector('span')!;
document.body.appendChild(div);
div.appendChild(span);
});
const snapshots = (await ctx.page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
it('should record DOM node removal', async () => {
const frame = ctx.page.mainFrame().childFrames()[0];
await frame.evaluate(() => {
const span = document.querySelector('span')!;
span.remove();
});
const snapshots = (await ctx.page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
it('should record DOM attribute changes', async () => {
const frame = ctx.page.mainFrame().childFrames()[0];
await frame.evaluate(() => {
const span = document.querySelector('span')!;
span.className = 'added-class-name';
});
const snapshots = (await ctx.page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
it('should record DOM text changes', async () => {
const frame = ctx.page.mainFrame().childFrames()[0];
await frame.evaluate(() => {
const b = document.querySelector('b')!;
b.childNodes[0].textContent = 'replaced text';
});
const snapshots = (await ctx.page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
it('should record canvas elements', async () => {
const frame = ctx.page.mainFrame().childFrames()[0];
await frame.evaluate(() => {
var canvas = document.createElement('canvas');
var gl = canvas.getContext('webgl')!;
var program = gl.createProgram()!;
gl.linkProgram(program);
gl.clear(gl.COLOR_BUFFER_BIT);
document.body.appendChild(canvas);
});
await waitForRAF(ctx.page);
const snapshots = (await ctx.page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
it('should record custom events', async () => {
const frame = ctx.page.mainFrame().childFrames()[0];
await frame.evaluate(() => {
((window as unknown) as IWindow).rrweb.addCustomEvent('test', {
id: 1,
parentId: 1,
nextId: 2,
});
});
await waitForRAF(ctx.page);
const snapshots = (await ctx.page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
it('captures mutations on adopted stylesheets', async () => {
const frame = ctx.page.mainFrame().childFrames()[0];
await ctx.page.evaluate(() => {
const sheet = new CSSStyleSheet();
// Add stylesheet to a document.
document.adoptedStyleSheets = [sheet];
});
await frame.evaluate(() => {
const sheet = new CSSStyleSheet();
// Add stylesheet to a document.
document.adoptedStyleSheets = [sheet];
});
await waitForRAF(ctx.page);
await ctx.page.evaluate(() => {
document.adoptedStyleSheets![0].replace!('div { color: yellow; }');
});
await frame.evaluate(() => {
document.adoptedStyleSheets![0].replace!('h1 { color: blue; }');
});
await waitForRAF(ctx.page);
await ctx.page.evaluate(() => {
document.adoptedStyleSheets![0].replaceSync!(
'div { display: inline ; }',
);
});
await frame.evaluate(() => {
document.adoptedStyleSheets![0].replaceSync!(
'h1 { font-size: large; }',
);
});
await waitForRAF(ctx.page);
await ctx.page.evaluate(() => {
(document.adoptedStyleSheets![0]
.cssRules[0] as CSSStyleRule).style.setProperty('color', 'green');
(document.adoptedStyleSheets![0]
.cssRules[0] as CSSStyleRule).style.removeProperty('display');
});
await frame.evaluate(() => {
(document.adoptedStyleSheets![0]
.cssRules[0] as CSSStyleRule).style.setProperty(
'font-size',
'medium',
'important',
);
document.adoptedStyleSheets![0].insertRule('h2 { color: red; }');
});
await waitForRAF(ctx.page);
await ctx.page.evaluate(() => {
document.adoptedStyleSheets![0].insertRule(
'body { border: 2px solid blue; }',
1,
);
});
await frame.evaluate(() => {
document.adoptedStyleSheets![0].deleteRule(0);
});
await waitForRAF(ctx.page);
const snapshots = (await ctx.page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
it('captures mutations on stylesheets', async () => {
const frame = ctx.page.mainFrame().childFrames()[0];
await ctx.page.evaluate(() => {
// Add stylesheet to a document.
const style = document.createElement('style');
document.head.appendChild(style);
});
await frame.evaluate(() => {
// Add stylesheet to a document.
const style = document.createElement('style');
document.head.appendChild(style);
});
await waitForRAF(ctx.page);
await ctx.page.evaluate(() => {
document.styleSheets[0].insertRule('div { color: yellow; }');
});
await frame.evaluate(() => {
document.styleSheets[0].insertRule('h1 { color: blue; }');
});
await waitForRAF(ctx.page);
await ctx.page.evaluate(() => {
(document.styleSheets[0].cssRules[0] as CSSStyleRule).style.setProperty(
'color',
'green',
);
(document.styleSheets[0]
.cssRules[0] as CSSStyleRule).style.removeProperty('display');
});
await frame.evaluate(() => {
(document.styleSheets[0].cssRules[0] as CSSStyleRule).style.setProperty(
'font-size',
'medium',
'important',
);
document.styleSheets[0].insertRule('h2 { color: red; }');
});
await waitForRAF(ctx.page);
await ctx.page.evaluate(() => {
document.styleSheets[0].insertRule(
'body { border: 2px solid blue; }',
1,
);
});
await frame.evaluate(() => {
document.styleSheets[0].deleteRule(0);
});
await waitForRAF(ctx.page);
const snapshots = (await ctx.page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
});
describe('audio.html', function (this: ISuite) {
jest.setTimeout(100_000);
const ctx: ISuite = setup.call(
this,
`
<!DOCTYPE html>
<html>
<body>
<iframe src="{SERVER_URL}/html/audio.html"></iframe>
</body>
</html>
`,
);
it('should emit contents of iframe once', async () => {
const frame = ctx.page.mainFrame().childFrames()[0];
await frame.evaluate(() => {
const el = document.querySelector('audio')!;
el.play();
});
await waitForRAF(ctx.page);
const snapshots = (await ctx.page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
});
});
describe('same origin iframes', function (this: ISuite) {
jest.setTimeout(100_000);
const ctx: ISuite = setup.call(
this,
`
<!DOCTYPE html>
<html>
<body>
<iframe src="about:blank"></iframe>
</body>
</html>
`,
);
it('should emit contents of iframe once', async () => {
const events = await ctx.page.evaluate(
() => ((window as unknown) as IWindow).snapshots,
);
await waitForRAF(ctx.page);
// two events (full snapshot + meta) from main frame,
// and two (full snapshot + mutation) from iframe
expect(events.length).toBe(4);
assertSnapshot(events);
});
});

View File

@@ -191,6 +191,11 @@ function stringifySnapshots(snapshots: eventWithTime[]): string {
}),
null,
2,
).replace(
// servers might get run on a random port,
// so we need to normalize the port number
/http:\/\/localhost:\d+/g,
'http://localhost:3030',
);
}

View File

@@ -216,6 +216,23 @@ export type SamplingStrategy = Partial<{
canvas: 'all' | number;
}>;
export interface ICrossOriginIframeMirror {
getId(
iframe: HTMLIFrameElement,
remoteId: number,
parentToRemoteMap?: Map<number, number>,
remoteToParentMap?: Map<number, number>,
): number;
getIds(iframe: HTMLIFrameElement, remoteId: number[]): number[];
getRemoteId(
iframe: HTMLIFrameElement,
parentId: number,
map?: Map<number, number>,
): number;
getRemoteIds(iframe: HTMLIFrameElement, parentId: number[]): number[];
reset(iframe?: HTMLIFrameElement): void;
}
export type RecordPlugin<TOptions = unknown> = {
name: string;
observer?: (
@@ -224,7 +241,11 @@ export type RecordPlugin<TOptions = unknown> = {
options: TOptions,
) => listenerHandler;
eventProcessor?: <TExtend>(event: eventWithTime) => eventWithTime & TExtend;
getMirror?: (mirror: Mirror) => void;
getMirror?: (mirrors: {
nodeMirror: Mirror;
crossOriginIframeMirror: ICrossOriginIframeMirror;
crossOriginIframeStyleMirror: ICrossOriginIframeMirror;
}) => void;
options: TOptions;
};
@@ -636,3 +657,16 @@ declare global {
export type IWindow = Window & typeof globalThis;
export type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
export type GetTypedKeys<Obj extends object, ValueType> = TakeTypeHelper<
Obj,
ValueType
>[keyof TakeTypeHelper<Obj, ValueType>];
export type TakeTypeHelper<Obj extends object, ValueType> = {
[K in keyof Obj]: Obj[K] extends ValueType ? K : never;
};
export type TakeTypedKeyValues<Obj extends object, Type> = Pick<
Obj,
TakeTypeHelper<Obj, Type>[keyof TakeTypeHelper<Obj, Type>]
>;