From ceed21c7843228dd4281fe56ed4bd25dfaa7b6ef Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Mon, 1 Apr 2019 10:28:04 +0800 Subject: [PATCH 1/3] Impl nested record in iframe In this implementation, we add a root id to the serialized node when its target document is not the top scope document object. Currently we do not rely on the root id in rebuilding, but has an assertion to make sure we are handling this right. BTW, iframe contentDocument will only be available when it was append to the DOM tree. Since we are building nodes recursively, we put iframe stuffs into a callback array which will execute after the whole rebuilding. --- src/rebuild.ts | 59 +++++++++++++++++++++++++++++++++++++++++++------ src/snapshot.ts | 29 ++++++++++++++++++++++++ src/types.ts | 8 +++++-- 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/src/rebuild.ts b/src/rebuild.ts index 1d2c535..b7c0fe9 100644 --- a/src/rebuild.ts +++ b/src/rebuild.ts @@ -5,6 +5,7 @@ import { elementNode, idNodeMap, INode, + CallbackArray, } from './types'; const tagMap: tagMap = { @@ -138,15 +139,38 @@ function buildNode(n: serializedNodeWithId, doc: Document): Node | null { } } +function isIframe(n: serializedNodeWithId) { + return n.type === NodeType.Element && n.tagName === 'iframe'; +} + +function buildIframe( + iframe: HTMLIFrameElement, + childNodes: serializedNodeWithId[], + map: idNodeMap, + cbs: CallbackArray, +) { + const targetDoc = iframe.contentDocument!; + for (const childN of childNodes) { + buildNodeWithSN(childN, targetDoc, map, cbs); + } +} + export function buildNodeWithSN( n: serializedNodeWithId, doc: Document, map: idNodeMap, + cbs: CallbackArray, skipChild = false, -): INode | null { +): [INode | null, serializedNodeWithId[]] { let node = buildNode(n, doc); if (!node) { - return null; + return [null, []]; + } + if (n.rootId) { + console.assert( + ((map[n.rootId] as unknown) as Document) === doc, + 'Target document should has the same root id.', + ); } // use target document as root document if (n.type === NodeType.Document) { @@ -162,16 +186,34 @@ export function buildNodeWithSN( (n.type === NodeType.Document || n.type === NodeType.Element) && !skipChild ) { + const nodeIsIframe = isIframe(n); + if (nodeIsIframe) { + return [node as INode, n.childNodes]; + } for (const childN of n.childNodes) { - const childNode = buildNodeWithSN(childN, doc, map); + const [childNode, nestedNodes] = buildNodeWithSN(childN, doc, map, cbs); if (!childNode) { console.warn('Failed to rebuild', childN); - } else { - node.appendChild(childNode); + continue; + } + node.appendChild(childNode); + if (nestedNodes.length === 0) { + continue; + } + const childNodeIsIframe = isIframe(childN); + if (childNodeIsIframe) { + cbs.push(() => + buildIframe( + (childNode as unknown) as HTMLIFrameElement, + nestedNodes, + map, + cbs, + ), + ); } } } - return node as INode; + return [node as INode, []]; } function rebuild( @@ -179,7 +221,10 @@ function rebuild( doc: Document, ): [Node | null, idNodeMap] { const idNodeMap: idNodeMap = {}; - return [buildNodeWithSN(n, doc, idNodeMap), idNodeMap]; + const callbackArray: CallbackArray = []; + const [node] = buildNodeWithSN(n, doc, idNodeMap, callbackArray); + callbackArray.forEach(f => f()); + return [node, idNodeMap]; } export default rebuild; diff --git a/src/snapshot.ts b/src/snapshot.ts index 49642ed..08ad433 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -91,11 +91,18 @@ function serializeNode( doc: Document, blockClass: string | RegExp, ): serializedNode | false { + // Only record root id when document object is not the base document + let rootId: number | undefined; + if (((doc as unknown) as INode).__sn) { + const docId = ((doc as unknown) as INode).__sn.id; + rootId = docId === 1 ? undefined : docId; + } switch (n.nodeType) { case n.DOCUMENT_NODE: return { type: NodeType.Document, childNodes: [], + rootId, }; case n.DOCUMENT_TYPE_NODE: return { @@ -103,6 +110,7 @@ function serializeNode( name: (n as DocumentType).name, publicId: (n as DocumentType).publicId, systemId: (n as DocumentType).systemId, + rootId, }; case n.ELEMENT_NODE: let needBlock = false; @@ -190,6 +198,7 @@ function serializeNode( childNodes: [], isSVG: isSVGElement(n as Element) || undefined, needBlock, + rootId, }; case n.TEXT_NODE: // The parent node may not be a html element which has a tagName attribute. @@ -208,16 +217,19 @@ function serializeNode( type: NodeType.Text, textContent: textContent || '', isStyle, + rootId, }; case n.CDATA_SECTION_NODE: return { type: NodeType.CDATA, textContent: '', + rootId, }; case n.COMMENT_NODE: return { type: NodeType.Comment, textContent: (n as Comment).textContent || '', + rootId, }; default: return false; @@ -265,6 +277,23 @@ export function serializeNodeWithId( } } } + if ( + serializedNode.type === NodeType.Element && + serializedNode.tagName === 'iframe' + ) { + const iframeDoc = (n as HTMLIFrameElement).contentDocument; + if (iframeDoc) { + const serializedIframeNode = serializeNodeWithId( + iframeDoc, + iframeDoc, + map, + blockClass, + ); + if (serializedIframeNode) { + serializedNode.childNodes.push(serializedIframeNode); + } + } + } return serializedNode; } diff --git a/src/types.ts b/src/types.ts index 2bdbd74..7a989b8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,13 +47,15 @@ export type commentNode = { textContent: string; }; -export type serializedNode = +export type serializedNode = ( | documentNode | documentTypeNode | elementNode | textNode | cdataNode - | commentNode; + | commentNode) & { + rootId?: number; +}; export type serializedNodeWithId = serializedNode & { id: number }; @@ -68,3 +70,5 @@ export interface INode extends Node { export type idNodeMap = { [key: number]: INode; }; + +export type CallbackArray = T[]; From 5f926fb8341e5279d6d5f477d13e26b34d143fb6 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Sat, 6 Apr 2019 18:11:38 +0800 Subject: [PATCH 2/3] add test suite for nested iframe --- test/__snapshots__/integration.ts.snap | 206 +++++++++++++++++++++++++ test/integration.ts | 29 ++++ 2 files changed, 235 insertions(+) diff --git a/test/__snapshots__/integration.ts.snap b/test/__snapshots__/integration.ts.snap index 2c3d42b..813e175 100644 --- a/test/__snapshots__/integration.ts.snap +++ b/test/__snapshots__/integration.ts.snap @@ -205,3 +205,209 @@ exports[`[html file]: with-style-sheet.html 1`] = ` " `; + +exports[`[iframe]: about-mozilla.html 1`] = ` +" + The Book of Mozilla, 11:9 + +

+ Mammon slept. And the beast reborn spread over the earth and its numbers + grew legion. And they proclaimed the times and sacrificed crops unto the + fire, with the cunning of foxes. And they built a new world in their own + image as promised by the + sacred words, and spoke + of the beast with their children. Mammon awoke, and lo! it was + naught but a follower. +

+ from The Book of Mozilla, 11:9
(10th Edition) +

" +`; + +exports[`[iframe]: basic.html 1`] = ` +" + + + + Document +" +`; + +exports[`[iframe]: block-element.html 1`] = ` +" + + + + Document + + +
+
record 2
+
+
+ " +`; + +exports[`[iframe]: cors-style-sheet.html 1`] = ` +" + + + + with style sheet + +" +`; + +exports[`[iframe]: dynamic-stylesheet.html 1`] = ` +" + + + + dynamic stylesheet + + + + +

p tag

+ " +`; + +exports[`[iframe]: form-fields.html 1`] = ` +" + + + + form fields + +
+ + + + + +
+" +`; + +exports[`[iframe]: hover.html 1`] = ` +" + + + + hover selector + + +
hover me
+" +`; + +exports[`[iframe]: iframe.html 1`] = ` +" + + + + iframe + + + +" +`; + +exports[`[iframe]: iframe-inner.html 1`] = ` +" +" +`; + +exports[`[iframe]: invalid-attribute.html 1`] = ` +" +" +`; + +exports[`[iframe]: with-relative-res.html 1`] = ` +" + + + + Document + + + + \\"\\"" +`; + +exports[`[iframe]: with-script.html 1`] = ` +" + + + + with script + + + " +`; + +exports[`[iframe]: with-style-sheet.html 1`] = ` +" + + + + with style sheet + + +" +`; diff --git a/test/integration.ts b/test/integration.ts index 8edcebe..eeffd26 100644 --- a/test/integration.ts +++ b/test/integration.ts @@ -105,6 +105,7 @@ describe('integration tests', function(this: ISuite) { page.on('console', msg => console.log(msg.text())); await page.goto(`http://localhost:3030/html`); await page.setContent(html.src); + await page.waitFor(100); const rebuildHtml = (await page.evaluate(`${this.code} const x = new XMLSerializer(); const [snap] = rrweb.snapshot(document); @@ -114,4 +115,32 @@ describe('integration tests', function(this: ISuite) { assert(result.pass, result.pass ? '' : result.report()); }).timeout(5000); } + + for (const html of htmls) { + const title = '[iframe]: ' + html.filePath; + it(title, async () => { + const page: puppeteer.Page = await this.browser.newPage(); + // console for debug + // tslint:disable-next-line: no-console + page.on('console', msg => console.log(msg.text())); + await page.goto(`http://localhost:3030/html`); + await page.setContent(` + + + + + + `); + await page.waitFor(100); + const rebuildHtml = (await page.evaluate(`${this.code} + const x = new XMLSerializer(); + const doc = document.querySelector('#frame').contentDocument + const [snap] = rrweb.snapshot(doc); + x.serializeToString(rrweb.rebuild(snap, document)[0]); + `)).replace(/\n\n/g, ''); + const result = matchSnapshot(rebuildHtml, __filename, title); + assert(result.pass, result.pass ? '' : result.report()); + }).timeout(5000); + } }); From d1fe8c62b0c2ca161eada5d3e1aa9bf598c1016b Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Sun, 7 Apr 2019 21:16:06 +0800 Subject: [PATCH 3/3] add snapshot options and update typings --- src/rebuild.ts | 8 ++++---- src/snapshot.ts | 17 +++++++++++------ src/types.ts | 11 ++++++++++- typings/rebuild.d.ts | 4 ++-- typings/snapshot.d.ts | 6 +++--- typings/types.d.ts | 12 +++++++++++- 6 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/rebuild.ts b/src/rebuild.ts index b7c0fe9..41ed9e7 100644 --- a/src/rebuild.ts +++ b/src/rebuild.ts @@ -5,7 +5,7 @@ import { elementNode, idNodeMap, INode, - CallbackArray, + callbackArray, } from './types'; const tagMap: tagMap = { @@ -147,7 +147,7 @@ function buildIframe( iframe: HTMLIFrameElement, childNodes: serializedNodeWithId[], map: idNodeMap, - cbs: CallbackArray, + cbs: callbackArray, ) { const targetDoc = iframe.contentDocument!; for (const childN of childNodes) { @@ -159,7 +159,7 @@ export function buildNodeWithSN( n: serializedNodeWithId, doc: Document, map: idNodeMap, - cbs: CallbackArray, + cbs: callbackArray, skipChild = false, ): [INode | null, serializedNodeWithId[]] { let node = buildNode(n, doc); @@ -221,7 +221,7 @@ function rebuild( doc: Document, ): [Node | null, idNodeMap] { const idNodeMap: idNodeMap = {}; - const callbackArray: CallbackArray = []; + const callbackArray: callbackArray = []; const [node] = buildNodeWithSN(n, doc, idNodeMap, callbackArray); callbackArray.forEach(f => f()); return [node, idNodeMap]; diff --git a/src/snapshot.ts b/src/snapshot.ts index 08ad433..697ed4b 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -5,6 +5,8 @@ import { attributes, INode, idNodeMap, + snapshotOptions, + serializeOptions, } from './types'; let _id = 1; @@ -240,9 +242,9 @@ export function serializeNodeWithId( n: Node, doc: Document, map: idNodeMap, - blockClass: string | RegExp, - skipChild = false, + options: serializeOptions = {}, ): serializedNodeWithId | null { + const { blockClass = 'rr-block', skipChild = false, onVisit } = options; const _serializedNode = serializeNode(n, doc, blockClass); if (!_serializedNode) { // TODO: dev only @@ -254,6 +256,9 @@ export function serializeNodeWithId( }); (n as INode).__sn = serializedNode; map[serializedNode.id] = n as INode; + if (onVisit) { + onVisit(n as INode); + } let recordChild = !skipChild; if (serializedNode.type === NodeType.Element) { recordChild = recordChild && !serializedNode.needBlock; @@ -270,7 +275,7 @@ export function serializeNodeWithId( childN, doc, map, - blockClass, + options, ); if (serializedChildNode) { serializedNode.childNodes.push(serializedChildNode); @@ -287,7 +292,7 @@ export function serializeNodeWithId( iframeDoc, iframeDoc, map, - blockClass, + options, ); if (serializedIframeNode) { serializedNode.childNodes.push(serializedIframeNode); @@ -299,11 +304,11 @@ export function serializeNodeWithId( function snapshot( n: Document, - blockClass: string | RegExp = 'rr-block', + options?: snapshotOptions, ): [serializedNodeWithId | null, idNodeMap] { resetId(); const idNodeMap: idNodeMap = {}; - return [serializeNodeWithId(n, n, idNodeMap, blockClass), idNodeMap]; + return [serializeNodeWithId(n, n, idNodeMap, options), idNodeMap]; } export default snapshot; diff --git a/src/types.ts b/src/types.ts index 7a989b8..fb31e3b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -71,4 +71,13 @@ export type idNodeMap = { [key: number]: INode; }; -export type CallbackArray = T[]; +export type callbackArray = T[]; + +export type snapshotOptions = { + blockClass?: string | RegExp; + onVisit?: (n: INode) => void; +}; + +export type serializeOptions = snapshotOptions & { + skipChild?: boolean; +}; diff --git a/typings/rebuild.d.ts b/typings/rebuild.d.ts index 99c76da..0c2dcff 100644 --- a/typings/rebuild.d.ts +++ b/typings/rebuild.d.ts @@ -1,5 +1,5 @@ -import { serializedNodeWithId, idNodeMap, INode } from './types'; +import { serializedNodeWithId, idNodeMap, INode, callbackArray } from './types'; export declare function addHoverClass(cssText: string): string; -export declare function buildNodeWithSN(n: serializedNodeWithId, doc: Document, map: idNodeMap, skipChild?: boolean): INode | null; +export declare function buildNodeWithSN(n: serializedNodeWithId, doc: Document, map: idNodeMap, cbs: callbackArray, skipChild?: boolean): [INode | null, serializedNodeWithId[]]; declare function rebuild(n: serializedNodeWithId, doc: Document): [Node | null, idNodeMap]; export default rebuild; diff --git a/typings/snapshot.d.ts b/typings/snapshot.d.ts index 7de5fb4..9be7d6a 100644 --- a/typings/snapshot.d.ts +++ b/typings/snapshot.d.ts @@ -1,6 +1,6 @@ -import { serializedNodeWithId, idNodeMap } from './types'; +import { serializedNodeWithId, idNodeMap, snapshotOptions, serializeOptions } from './types'; export declare function resetId(): void; export declare function absoluteToStylesheet(cssText: string, href: string): string; -export declare function serializeNodeWithId(n: Node, doc: Document, map: idNodeMap, blockClass: string | RegExp, skipChild?: boolean): serializedNodeWithId | null; -declare function snapshot(n: Document, blockClass?: string | RegExp): [serializedNodeWithId | null, idNodeMap]; +export declare function serializeNodeWithId(n: Node, doc: Document, map: idNodeMap, options?: serializeOptions): serializedNodeWithId | null; +declare function snapshot(n: Document, options?: snapshotOptions): [serializedNodeWithId | null, idNodeMap]; export default snapshot; diff --git a/typings/types.d.ts b/typings/types.d.ts index 556e0bd..275dcb6 100644 --- a/typings/types.d.ts +++ b/typings/types.d.ts @@ -40,7 +40,9 @@ export declare type commentNode = { type: NodeType.Comment; textContent: string; }; -export declare type serializedNode = documentNode | documentTypeNode | elementNode | textNode | cdataNode | commentNode; +export declare type serializedNode = (documentNode | documentTypeNode | elementNode | textNode | cdataNode | commentNode) & { + rootId?: number; +}; export declare type serializedNodeWithId = serializedNode & { id: number; }; @@ -53,3 +55,11 @@ export interface INode extends Node { export declare type idNodeMap = { [key: number]: INode; }; +export declare type callbackArray = T[]; +export declare type snapshotOptions = { + blockClass?: string | RegExp; + onVisit?: (n: INode) => void; +}; +export declare type serializeOptions = snapshotOptions & { + skipChild?: boolean; +};