From e2315f3f76921d516409449ec8c58b953b82570f Mon Sep 17 00:00:00 2001 From: Samuel Berthe Date: Wed, 12 Oct 2022 02:18:36 +0200 Subject: [PATCH 1/5] feat(recorder): run recorder on a custom window html element --- packages/rrweb/src/record/index.ts | 121 +++++++++--------- packages/rrweb/src/record/mutation.ts | 4 +- packages/rrweb/src/record/observer.ts | 31 ++--- .../rrweb/src/record/shadow-dom-manager.ts | 5 + packages/rrweb/src/types.ts | 85 ++++++------ packages/rrweb/src/utils.ts | 44 +++---- 6 files changed, 152 insertions(+), 138 deletions(-) diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 26e07fc83a..8436a583ec 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -71,6 +71,7 @@ function record( userTriggeredOnInput = false, collectFonts = false, inlineImages = false, + window: win = window, plugins, keepIframeSrcFn = () => false, ignoreCSSAttributes = new Set([]), @@ -91,46 +92,46 @@ function record( const maskInputOptions: MaskInputOptions = maskAllInputs === true ? { - color: true, - date: true, - 'datetime-local': true, - email: true, - month: true, - number: true, - range: true, - search: true, - tel: true, - text: true, - time: true, - url: true, - week: true, - textarea: true, - select: true, - password: true, - } + color: true, + date: true, + 'datetime-local': true, + email: true, + month: true, + number: true, + range: true, + search: true, + tel: true, + text: true, + time: true, + url: true, + week: true, + textarea: true, + select: true, + password: true, + } : _maskInputOptions !== undefined - ? _maskInputOptions - : { password: true }; + ? _maskInputOptions + : { password: true }; const slimDOMOptions: SlimDOMOptions = _slimDOMOptions === true || _slimDOMOptions === 'all' ? { - script: true, - comment: true, - headFavicon: true, - headWhitespace: true, - headMetaSocial: true, - headMetaRobots: true, - headMetaHttpEquiv: true, - headMetaVerification: true, - // the following are off for slimDOMOptions === true, - // as they destroy some (hidden) info: - headMetaAuthorship: _slimDOMOptions === 'all', - headMetaDescKeywords: _slimDOMOptions === 'all', - } + script: true, + comment: true, + headFavicon: true, + headWhitespace: true, + headMetaSocial: true, + headMetaRobots: true, + headMetaHttpEquiv: true, + headMetaVerification: true, + // the following are off for slimDOMOptions === true, + // as they destroy some (hidden) info: + headMetaAuthorship: _slimDOMOptions === 'all', + headMetaDescKeywords: _slimDOMOptions === 'all', + } : _slimDOMOptions - ? _slimDOMOptions - : {}; + ? _slimDOMOptions + : {}; polyfill(); @@ -250,7 +251,7 @@ function record( canvasManager = new CanvasManager({ recordCanvas, mutationCb: wrappedCanvasMutationEmit, - win: window, + win: win, blockClass, blockSelector, mirror, @@ -259,6 +260,7 @@ function record( }); const shadowDomManager = new ShadowDomManager({ + win, mutationCb: wrappedMutationEmit, scrollCb: wrappedScrollEmit, bypassOptions: { @@ -288,9 +290,9 @@ function record( wrapEvent({ type: EventType.Meta, data: { - href: window.location.href, - width: getWindowWidth(), - height: getWindowHeight(), + href: win.location.href, + width: getWindowWidth(win), + height: getWindowHeight(win), }, }), isCheckout, @@ -300,7 +302,7 @@ function record( stylesheetManager.reset(); mutationBuffers.forEach((buf) => buf.lock()); // don't allow any mirror modifications during snapshotting - const node = snapshot(document, { + const node = snapshot(win.document, { mirror, blockClass, blockSelector, @@ -321,7 +323,7 @@ function record( stylesheetManager.trackLinkElement(n as HTMLLinkElement); } if (hasShadowRoot(n)) { - shadowDomManager.addShadowRoot(n.shadowRoot, document); + shadowDomManager.addShadowRoot(n.shadowRoot, win.document); } }, onIframeLoad: (iframe, childSn) => { @@ -345,19 +347,19 @@ function record( node, initialOffset: { left: - window.pageXOffset !== undefined - ? window.pageXOffset - : document?.documentElement.scrollLeft || - document?.body?.parentElement?.scrollLeft || - document?.body?.scrollLeft || - 0, + win.pageXOffset !== undefined + ? win.pageXOffset + : win.document?.documentElement.scrollLeft || + win.document?.body?.parentElement?.scrollLeft || + win.document?.body?.scrollLeft || + 0, top: - window.pageYOffset !== undefined - ? window.pageYOffset - : document?.documentElement.scrollTop || - document?.body?.parentElement?.scrollTop || - document?.body?.scrollTop || - 0, + win.pageYOffset !== undefined + ? win.pageYOffset + : win.document?.documentElement.scrollTop || + win.document?.body?.parentElement?.scrollTop || + win.document?.body?.scrollTop || + 0, }, }, }), @@ -365,10 +367,10 @@ function record( mutationBuffers.forEach((buf) => buf.unlock()); // generate & emit any mutations that happened during snapshotting, as can now apply against the newly built mirror // Some old browsers don't support adoptedStyleSheets. - if (document.adoptedStyleSheets && document.adoptedStyleSheets.length > 0) + if (win.document.adoptedStyleSheets && win.document.adoptedStyleSheets.length > 0) stylesheetManager.adoptStyleSheets( - document.adoptedStyleSheets, - mirror.getId(document), + win.document.adoptedStyleSheets, + mirror.getId(win.document), ); }; @@ -493,6 +495,7 @@ function record( inlineImages, userTriggeredOnInput, collectFonts, + window: win, doc, maskInputFn, maskTextFn, @@ -534,11 +537,11 @@ function record( const init = () => { takeFullSnapshot(); - handlers.push(observe(document)); + handlers.push(observe(win.document)); }; if ( - document.readyState === 'interactive' || - document.readyState === 'complete' + win.document.readyState === 'interactive' || + win.document.readyState === 'complete' ) { init(); } else { @@ -554,7 +557,7 @@ function record( ); init(); }, - window, + win, ), ); } diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 5f54842b95..2871538a62 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -170,6 +170,7 @@ export default class MutationBuffer { private inlineImages: observerParam['inlineImages']; private slimDOMOptions: observerParam['slimDOMOptions']; private dataURLOptions: observerParam['dataURLOptions']; + private window: observerParam['window']; private doc: observerParam['doc']; private mirror: observerParam['mirror']; private iframeManager: observerParam['iframeManager']; @@ -193,6 +194,7 @@ export default class MutationBuffer { 'inlineImages', 'slimDOMOptions', 'dataURLOptions', + 'window', 'doc', 'mirror', 'iframeManager', @@ -321,7 +323,7 @@ export default class MutationBuffer { ); } if (hasShadowRoot(n)) { - this.shadowDomManager.addShadowRoot(n.shadowRoot, document); + this.shadowDomManager.addShadowRoot(n.shadowRoot, this.window.document); } }, onIframeLoad: (iframe, childSn) => { diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 6fb2edad22..37fa314106 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -87,7 +87,7 @@ export function initMutationObserver( // see mutation.ts for details mutationBuffer.init(options); let mutationObserverCtor = - window.MutationObserver || + options.window.MutationObserver || /** * Some websites may disable MutationObserver by removing it from the window object. * If someone is using rrweb to build a browser extention or things like it, they @@ -96,17 +96,17 @@ export function initMutationObserver( * Then they can do this to store the native MutationObserver: * window.__rrMutationObserver = MutationObserver */ - (window as WindowWithStoredMutationObserver).__rrMutationObserver; - const angularZoneSymbol = (window as WindowWithAngularZone)?.Zone?.__symbol__?.( + (options.window as WindowWithStoredMutationObserver).__rrMutationObserver; + const angularZoneSymbol = (options.window as WindowWithAngularZone)?.Zone?.__symbol__?.( 'MutationObserver', ); if ( angularZoneSymbol && - ((window as unknown) as Record)[ - angularZoneSymbol + ((options.window as unknown) as Record)[ + angularZoneSymbol ] ) { - mutationObserverCtor = ((window as unknown) as Record< + mutationObserverCtor = ((options.window as unknown) as Record< string, typeof MutationObserver >)[angularZoneSymbol]; @@ -187,8 +187,8 @@ function initMoveObserver({ typeof DragEvent !== 'undefined' && evt instanceof DragEvent ? IncrementalSource.Drag : evt instanceof MouseEvent - ? IncrementalSource.MouseMove - : IncrementalSource.TouchMove, + ? IncrementalSource.MouseMove + : IncrementalSource.TouchMove, ); }, threshold, @@ -221,7 +221,7 @@ function initMouseInteractionObserver({ } const disableMap: Record = sampling.mouseInteraction === true || - sampling.mouseInteraction === undefined + sampling.mouseInteraction === undefined ? {} : sampling.mouseInteraction; @@ -299,13 +299,14 @@ export function initScrollObserver({ } function initViewportResizeObserver({ + window: win, viewportResizeCb, }: observerParam): listenerHandler { let lastH = -1; let lastW = -1; const updateDimension = throttle(() => { - const height = getWindowHeight(); - const width = getWindowWidth(); + const height = getWindowHeight(win); + const width = getWindowWidth(win); if (lastH !== height || lastW !== width) { viewportResizeCb({ width: Number(width), @@ -315,7 +316,7 @@ function initViewportResizeObserver({ lastW = width; } }, 200); - return on('resize', updateDimension, window); + return on('resize', updateDimension, win); } function wrapEventWithUserTriggeredFlag( @@ -368,7 +369,7 @@ function initInputObserver({ isChecked = (target as HTMLInputElement).checked; } else if ( maskInputOptions[ - (target as Element).tagName.toLowerCase() as keyof MaskInputOptions + (target as Element).tagName.toLowerCase() as keyof MaskInputOptions ] || maskInputOptions[type as keyof MaskInputOptions] ) { @@ -1130,8 +1131,8 @@ export function initObservers( const fontObserver = o.collectFonts ? initFontObserver(o) : () => { - // - }; + // + }; const selectionObserver = initSelectionObserver(o); // plugins diff --git a/packages/rrweb/src/record/shadow-dom-manager.ts b/packages/rrweb/src/record/shadow-dom-manager.ts index 16f679c861..7704d44d83 100644 --- a/packages/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/src/record/shadow-dom-manager.ts @@ -3,6 +3,7 @@ import type { scrollCallback, MutationBufferParam, SamplingStrategy, + IWindow, } from '../types'; import { initMutationObserver, @@ -21,6 +22,7 @@ type BypassOptions = Omit< }; export class ShadowDomManager { + private win: IWindow private shadowDoms = new WeakSet(); private mutationCb: mutationCallBack; private scrollCb: scrollCallback; @@ -29,11 +31,13 @@ export class ShadowDomManager { private restorePatches: (() => void)[] = []; constructor(options: { + win: IWindow, mutationCb: mutationCallBack; scrollCb: scrollCallback; bypassOptions: BypassOptions; mirror: Mirror; }) { + this.win = options.win; this.mutationCb = options.mutationCb; this.scrollCb = options.scrollCb; this.bypassOptions = options.bypassOptions; @@ -65,6 +69,7 @@ export class ShadowDomManager { initMutationObserver( { ...this.bypassOptions, + window: this.win, doc, mutationCb: this.mutationCb, mirror: this.mirror, diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 235005073a..eb9dd0667b 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -102,9 +102,9 @@ export type mutationData = { export type mousemoveData = { source: - | IncrementalSource.MouseMove - | IncrementalSource.TouchMove - | IncrementalSource.Drag; + | IncrementalSource.MouseMove + | IncrementalSource.TouchMove + | IncrementalSource.Drag; positions: mousePosition[]; }; @@ -263,6 +263,7 @@ export type recordOptions = { userTriggeredOnInput?: boolean; collectFonts?: boolean; inlineImages?: boolean; + window?: IWindow; plugins?: RecordPlugin[]; // departed, please use sampling options mousemoveWait?: number; @@ -299,6 +300,7 @@ export type observerParam = { collectFonts: boolean; slimDOMOptions: SlimDOMOptions; dataURLOptions: DataURLOptions; + window: IWindow, doc: Document; mirror: Mirror; iframeManager: IframeManager; @@ -333,6 +335,7 @@ export type MutationBufferParam = Pick< | 'inlineImages' | 'slimDOMOptions' | 'dataURLOptions' + | 'window' | 'doc' | 'mirror' | 'iframeManager' @@ -462,26 +465,26 @@ export enum CanvasContext { export type SerializedCanvasArg = | { - rr_type: 'ArrayBuffer'; - base64: string; // base64 - } + rr_type: 'ArrayBuffer'; + base64: string; // base64 + } | { - rr_type: 'Blob'; - data: Array; - type?: string; - } + rr_type: 'Blob'; + data: Array; + type?: string; + } | { - rr_type: string; - src: string; // url of image - } + rr_type: string; + src: string; // url of image + } | { - rr_type: string; - args: Array; - } + rr_type: string; + args: Array; + } | { - rr_type: string; - index: number; - }; + rr_type: string; + index: number; + }; export type CanvasArg = | SerializedCanvasArg @@ -566,14 +569,14 @@ export type canvasMutationCommand = { export type canvasMutationParam = | { - id: number; - type: CanvasContext; - commands: canvasMutationCommand[]; - } + id: number; + type: CanvasContext; + commands: canvasMutationCommand[]; + } | ({ - id: number; - type: CanvasContext; - } & canvasMutationCommand); + id: number; + type: CanvasContext; + } & canvasMutationCommand); export type canvasMutationWithType = { type: CanvasContext; @@ -596,15 +599,15 @@ export type ImageBitmapDataURLWorkerParams = { export type ImageBitmapDataURLWorkerResponse = | { - id: number; - } + id: number; + } | { - id: number; - type: string; - base64: string; - width: number; - height: number; - }; + id: number; + type: string; + base64: string; + width: number; + height: number; + }; export type fontParam = { family: string; @@ -722,13 +725,13 @@ export type playerConfig = { UNSAFE_replayCanvas: boolean; pauseAnimation?: boolean; mouseTail: - | boolean - | { - duration?: number; - lineCap?: string; - lineWidth?: number; - strokeStyle?: string; - }; + | boolean + | { + duration?: number; + lineCap?: string; + lineWidth?: number; + strokeStyle?: string; + }; unpackFn?: UnpackFn; useVirtualDom: boolean; plugins?: ReplayPlugin[]; diff --git a/packages/rrweb/src/utils.ts b/packages/rrweb/src/utils.ts index b782bd52e2..c64a31ea94 100644 --- a/packages/rrweb/src/utils.ts +++ b/packages/rrweb/src/utils.ts @@ -110,16 +110,16 @@ export function hookSetter( isRevoked ? d : { - set(value) { - // put hooked setter into event loop to avoid of set latency - setTimeout(() => { - d.set!.call(this, value); - }, 0); - if (original && original.set) { - original.set.call(this, value); - } - }, + set(value) { + // put hooked setter into event loop to avoid of set latency + setTimeout(() => { + d.set!.call(this, value); + }, 0); + if (original && original.set) { + original.set.call(this, value); + } }, + }, ); return () => hookSetter(target, key, original || {}, true); } @@ -167,19 +167,19 @@ export function patch( } } -export function getWindowHeight(): number { +export function getWindowHeight(win: Window): number { return ( - window.innerHeight || - (document.documentElement && document.documentElement.clientHeight) || - (document.body && document.body.clientHeight) + win.innerHeight || + (win.document.documentElement && win.document.documentElement.clientHeight) || + (win.document.body && win.document.body.clientHeight) ); } -export function getWindowWidth(): number { +export function getWindowWidth(win: Window): number { return ( - window.innerWidth || - (document.documentElement && document.documentElement.clientWidth) || - (document.body && document.body.clientWidth) + win.innerWidth || + (win.document.documentElement && win.document.documentElement.clientWidth) || + (win.document.body && win.document.body.clientWidth) ); } @@ -371,10 +371,10 @@ export function isSerializedStylesheet( ): boolean { return Boolean( n.nodeName === 'LINK' && - n.nodeType === n.ELEMENT_NODE && - (n as HTMLElement).getAttribute && - (n as HTMLElement).getAttribute('rel') === 'stylesheet' && - mirror.getMeta(n), + n.nodeType === n.ELEMENT_NODE && + (n as HTMLElement).getAttribute && + (n as HTMLElement).getAttribute('rel') === 'stylesheet' && + mirror.getMeta(n), ); } @@ -444,7 +444,7 @@ export function uniqueTextMutations(mutations: textMutation[]): textMutation[] { const idSet = new Set(); const uniqueMutations: textMutation[] = []; - for (let i = mutations.length; i--; ) { + for (let i = mutations.length; i--;) { const mutation = mutations[i]; if (!idSet.has(mutation.id)) { uniqueMutations.push(mutation); From 9eca4600eb327ed12be97016102b74c7f41c5299 Mon Sep 17 00:00:00 2001 From: Samuel Berthe Date: Sat, 15 Oct 2022 17:31:51 +0200 Subject: [PATCH 2/5] test: run recorder on a custom window html element --- packages/rrweb/src/types.ts | 2 +- .../test/__snapshots__/record.test.ts.snap | 167 ++++++++++++++++++ packages/rrweb/test/record.test.ts | 86 +++++++++ 3 files changed, 254 insertions(+), 1 deletion(-) diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index eb9dd0667b..36f6007ade 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -263,7 +263,7 @@ export type recordOptions = { userTriggeredOnInput?: boolean; collectFonts?: boolean; inlineImages?: boolean; - window?: IWindow; + window?: Window; plugins?: RecordPlugin[]; // departed, please use sampling options mousemoveWait?: number; diff --git a/packages/rrweb/test/__snapshots__/record.test.ts.snap b/packages/rrweb/test/__snapshots__/record.test.ts.snap index 7133805635..c29e542df3 100644 --- a/packages/rrweb/test/__snapshots__/record.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/record.test.ts.snap @@ -1,5 +1,172 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`be loaded from an iframe captures activity in outer frame 1`] = ` +"[ + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 3 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"text\\", + \\"size\\": \\"40\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"srcdoc\\": \\"
rrweb loaded in here!
\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 9 + } + ], + \\"id\\": 8 + } + ], + \\"id\\": 4 + } + ], + \\"id\\": 2 + } + ], + \\"compatMode\\": \\"BackCompat\\", + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"adds\\": [ + { + \\"parentId\\": 8, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"rootId\\": 10, + \\"id\\": 12 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"rrweb loaded in here!\\", + \\"rootId\\": 10, + \\"id\\": 15 + } + ], + \\"rootId\\": 10, + \\"id\\": 14 + } + ], + \\"rootId\\": 10, + \\"id\\": 13 + } + ], + \\"rootId\\": 10, + \\"id\\": 11 + } + ], + \\"id\\": 10 + } + } + ], + \\"removes\\": [], + \\"texts\\": [], + \\"attributes\\": [], + \\"isAttachIframe\\": true + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 2, + \\"type\\": 5, + \\"id\\": 6 + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 5, + \\"text\\": \\"a\\", + \\"isChecked\\": false, + \\"id\\": 6 + } + } +]" +`; + exports[`record can add custom event 1`] = ` "[ { diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index e353559a26..e7c3b0c63a 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -69,6 +69,45 @@ const setup = function (this: ISuite, content: string): ISuite { return ctx; }; +const iframeSetup = function (this: ISuite, content: string): ISuite { + const ctx = {} as ISuite; + + beforeAll(async () => { + ctx.browser = await launchPuppeteer({ + devtools: true, + }); + + const bundlePath = path.resolve(__dirname, '../dist/rrweb.js'); + ctx.code = fs.readFileSync(bundlePath, 'utf8'); + }); + + beforeEach(async () => { + ctx.page = await ctx.browser.newPage(); + await ctx.page.goto('about:blank?outer'); + await ctx.page.setContent(content); + await ctx.page.frames()[1].evaluate(ctx.code); + ctx.events = []; + await ctx.page.exposeFunction('emit', (e: eventWithTime) => { + if (e.type === EventType.DomContentLoaded || e.type === EventType.Load) { + return; + } + ctx.events.push(e); + }); + + ctx.page.on('console', (msg) => console.log('PAGE LOG:', msg.text())); + }); + + afterEach(async () => { + await ctx.page.close(); + }); + + afterAll(async () => { + await ctx.browser.close(); + }); + + return ctx; +}; + describe('record', function (this: ISuite) { jest.setTimeout(10_000); @@ -861,3 +900,50 @@ describe('record iframes', function (this: ISuite) { assertSnapshot(ctx.events); }); }); + +describe('be loaded from an iframe', function (this: ISuite) { + jest.setTimeout(10_000); + + const ctx: ISuite = iframeSetup.call( + this, + ` + + + +