diff --git a/src/rebuild.ts b/src/rebuild.ts index 1d2c535..41ed9e7 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..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; @@ -91,11 +93,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 +112,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 +200,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 +219,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; @@ -228,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 @@ -242,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; @@ -258,23 +275,40 @@ export function serializeNodeWithId( childN, doc, map, - blockClass, + options, ); if (serializedChildNode) { serializedNode.childNodes.push(serializedChildNode); } } } + if ( + serializedNode.type === NodeType.Element && + serializedNode.tagName === 'iframe' + ) { + const iframeDoc = (n as HTMLIFrameElement).contentDocument; + if (iframeDoc) { + const serializedIframeNode = serializeNodeWithId( + iframeDoc, + iframeDoc, + map, + options, + ); + if (serializedIframeNode) { + serializedNode.childNodes.push(serializedIframeNode); + } + } + } return serializedNode; } 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 2bdbd74..fb31e3b 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,14 @@ export interface INode extends Node { export type idNodeMap = { [key: number]: INode; }; + +export type callbackArray = T[]; + +export type snapshotOptions = { + blockClass?: string | RegExp; + onVisit?: (n: INode) => void; +}; + +export type serializeOptions = snapshotOptions & { + skipChild?: boolean; +}; 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); + } }); 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; +};