From 0e688bba0c6b8fb70535550bb069f5c4b877edb8 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH] impl shadow DOM manager part of #38 1. observe DOM mutations in shadow DOM 2. rebuild DOM mutations in shadow DOM --- package.json | 2 +- src/record/index.ts | 54 ++- src/record/mutation.ts | 41 +- src/record/observer.ts | 10 +- src/record/shadow-dom-manager.ts | 43 +++ src/replay/index.ts | 15 +- src/types.ts | 5 +- src/utils.ts | 24 +- test/__snapshots__/integration.test.ts.snap | 406 ++++++++++++++++++++ test/html/shadow-dom.html | 83 ++++ test/integration.test.ts | 37 ++ yarn.lock | 8 +- 12 files changed, 680 insertions(+), 48 deletions(-) create mode 100644 src/record/shadow-dom-manager.ts create mode 100644 test/html/shadow-dom.html diff --git a/package.json b/package.json index 5a41ca6c..5af64ac8 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,6 @@ "@xstate/fsm": "^1.4.0", "fflate": "^0.4.4", "mitt": "^1.1.3", - "rrweb-snapshot": "^1.0.7" + "rrweb-snapshot": "^1.1.1" } } diff --git a/src/record/index.ts b/src/record/index.ts index 7e229c98..eb8638f6 100644 --- a/src/record/index.ts +++ b/src/record/index.ts @@ -7,6 +7,7 @@ import { getWindowHeight, polyfill, isIframeINode, + hasShadowRoot, } from '../utils'; import { EventType, @@ -16,8 +17,10 @@ import { IncrementalSource, listenerHandler, LogRecordOptions, + mutationCallbackParam, } from '../types'; import { IframeManager } from './iframe-manager'; +import { ShadowDomManager } from './shadow-dom-manager'; function wrapEvent(e: event): eventWithTime { return { @@ -179,17 +182,33 @@ function record( } }; + const wrappedMutationEmit = (m: mutationCallbackParam) => { + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Mutation, + ...m, + }, + }), + ); + }; + const iframeManager = new IframeManager({ - mutationCb: (m) => - wrappedEmit( - wrapEvent({ - type: EventType.IncrementalSnapshot, - data: { - source: IncrementalSource.Mutation, - ...m, - }, - }), - ), + mutationCb: wrappedMutationEmit, + }); + + const shadowDomManager = new ShadowDomManager({ + mutationCb: wrappedMutationEmit, + bypassOptions: { + blockClass, + blockSelector, + inlineStylesheet, + maskInputOptions, + recordCanvas, + slimDOMOptions, + iframeManager, + }, }); takeFullSnapshot = (isCheckout = false) => { @@ -217,6 +236,9 @@ function record( if (isIframeINode(n)) { iframeManager.addIframe(n); } + if (hasShadowRoot(n)) { + shadowDomManager.addShadowRoot(n.shadowRoot, document); + } }, onIframeLoad: (iframe, childSn) => { iframeManager.attachIframe(iframe, childSn); @@ -271,16 +293,7 @@ function record( const observe = (doc: Document) => { return initObservers( { - mutationCb: (m) => - wrappedEmit( - wrapEvent({ - type: EventType.IncrementalSnapshot, - data: { - source: IncrementalSource.Mutation, - ...m, - }, - }), - ), + mutationCb: wrappedMutationEmit, mousemoveCb: (positions, source) => wrappedEmit( wrapEvent({ @@ -394,6 +407,7 @@ function record( blockSelector, slimDOMOptions, iframeManager, + shadowDomManager, }, hooks, ); diff --git a/src/record/mutation.ts b/src/record/mutation.ts index d80fb299..f1f2aa86 100644 --- a/src/record/mutation.ts +++ b/src/record/mutation.ts @@ -5,7 +5,7 @@ import { MaskInputOptions, SlimDOMOptions, IGNORED_NODE, - NodeType, + isShadowRoot, } from 'rrweb-snapshot'; import { mutationRecord, @@ -16,8 +16,16 @@ import { removedNodeMutation, addedNodeMutation, } from '../types'; -import { mirror, isBlocked, isAncestorRemoved, isIgnored } from '../utils'; +import { + mirror, + isBlocked, + isAncestorRemoved, + isIgnored, + isIframeINode, + hasShadowRoot, +} from '../utils'; import { IframeManager } from './iframe-manager'; +import { ShadowDomManager } from './shadow-dom-manager'; type DoubleLinkedListNode = { previous: DoubleLinkedListNode | null; @@ -158,6 +166,7 @@ export default class MutationBuffer { private doc: Document; private iframeManager: IframeManager; + private shadowDomManager: ShadowDomManager; public init( cb: mutationCallBack, @@ -169,6 +178,7 @@ export default class MutationBuffer { slimDOMOptions: SlimDOMOptions, doc: Document, iframeManager: IframeManager, + shadowDomManager: ShadowDomManager, ) { this.blockClass = blockClass; this.blockSelector = blockSelector; @@ -179,6 +189,7 @@ export default class MutationBuffer { this.emissionCallback = cb; this.doc = doc; this.iframeManager = iframeManager; + this.shadowDomManager = shadowDomManager; } public freeze() { @@ -236,10 +247,14 @@ export default class MutationBuffer { return nextId; }; const pushAdd = (n: Node) => { - if (!n.parentNode || !this.doc.contains(n)) { + const shadowHost: Element | null = (n.getRootNode() as ShadowRoot)?.host; + const notInDoc = !this.doc.contains(n) && !this.doc.contains(shadowHost); + if (!n.parentNode || notInDoc) { return; } - const parentId = mirror.getId((n.parentNode as Node) as INode); + const parentId = isShadowRoot(n.parentNode) + ? mirror.getId((shadowHost as unknown) as INode) + : mirror.getId((n.parentNode as Node) as INode); const nextId = getNextId(n); if (parentId === -1 || nextId === -1) { return addList.addNode(n); @@ -255,13 +270,11 @@ export default class MutationBuffer { slimDOMOptions: this.slimDOMOptions, recordCanvas: this.recordCanvas, onSerialize: (currentN) => { - if ( - currentN.__sn.type === NodeType.Element && - currentN.__sn.tagName === 'iframe' - ) { - this.iframeManager.addIframe( - (currentN as unknown) as HTMLIFrameElement, - ); + if (isIframeINode(currentN)) { + this.iframeManager.addIframe(currentN); + } + if (hasShadowRoot(n)) { + this.shadowDomManager.addShadowRoot(n.shadowRoot, document); } }, onIframeLoad: (iframe, childSn) => { @@ -418,6 +431,7 @@ export default class MutationBuffer { // overwrite attribute if the mutations was triggered in same time item.attributes[m.attributeName!] = transformAttribute( this.doc, + (m.target as HTMLElement).tagName, m.attributeName!, value!, ); @@ -427,7 +441,9 @@ export default class MutationBuffer { m.addedNodes.forEach((n) => this.genAdds(n, m.target)); m.removedNodes.forEach((n) => { const nodeId = mirror.getId(n as INode); - const parentId = mirror.getId(m.target as INode); + const parentId = isShadowRoot(m.target) + ? mirror.getId((m.target.host as unknown) as INode) + : mirror.getId(m.target as INode); if ( isBlocked(n, this.blockClass) || isBlocked(m.target, this.blockClass) || @@ -463,6 +479,7 @@ export default class MutationBuffer { this.removes.push({ parentId, id: nodeId, + isShadow: isShadowRoot(m.target) ? true : undefined, }); } this.mapRemoves.push(n); diff --git a/src/record/observer.ts b/src/record/observer.ts index cb996425..ac86d0d5 100644 --- a/src/record/observer.ts +++ b/src/record/observer.ts @@ -44,6 +44,7 @@ import { import MutationBuffer from './mutation'; import { stringify } from './stringify'; import { IframeManager } from './iframe-manager'; +import { ShadowDomManager } from './shadow-dom-manager'; type WindowWithStoredMutationObserver = Window & { __rrMutationObserver?: MutationObserver; @@ -56,7 +57,7 @@ type WindowWithAngularZone = Window & { export const mutationBuffers: MutationBuffer[] = []; -function initMutationObserver( +export function initMutationObserver( cb: mutationCallBack, doc: Document, blockClass: blockClass, @@ -66,6 +67,8 @@ function initMutationObserver( recordCanvas: boolean, slimDOMOptions: SlimDOMOptions, iframeManager: IframeManager, + shadowDomManager: ShadowDomManager, + rootEl: Node, ): MutationObserver { const mutationBuffer = new MutationBuffer(); mutationBuffers.push(mutationBuffer); @@ -80,6 +83,7 @@ function initMutationObserver( slimDOMOptions, doc, iframeManager, + shadowDomManager, ); let mutationObserverCtor = window.MutationObserver || @@ -109,7 +113,7 @@ function initMutationObserver( const observer = new mutationObserverCtor( mutationBuffer.processMutations.bind(mutationBuffer), ); - observer.observe(doc, { + observer.observe(rootEl, { attributes: true, attributeOldValue: true, characterData: true, @@ -763,6 +767,8 @@ export function initObservers( o.recordCanvas, o.slimDOMOptions, o.iframeManager, + o.shadowDomManager, + o.doc, ); const mousemoveHandler = initMoveObserver(o.mousemoveCb, o.sampling, o.doc); const mouseInteractionHandler = initMouseInteractionObserver( diff --git a/src/record/shadow-dom-manager.ts b/src/record/shadow-dom-manager.ts new file mode 100644 index 00000000..ea57a111 --- /dev/null +++ b/src/record/shadow-dom-manager.ts @@ -0,0 +1,43 @@ +import { mutationCallBack, blockClass } from '../types'; +import { MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot'; +import { IframeManager } from './iframe-manager'; +import { initMutationObserver } from './observer'; + +type BypassOptions = { + blockClass: blockClass; + blockSelector: string | null; + inlineStylesheet: boolean; + maskInputOptions: MaskInputOptions; + recordCanvas: boolean; + slimDOMOptions: SlimDOMOptions; + iframeManager: IframeManager; +}; + +export class ShadowDomManager { + private mutationCb: mutationCallBack; + private bypassOptions: BypassOptions; + + constructor(options: { + mutationCb: mutationCallBack; + bypassOptions: BypassOptions; + }) { + this.mutationCb = options.mutationCb; + this.bypassOptions = options.bypassOptions; + } + + public addShadowRoot(shadowRoot: ShadowRoot, doc: Document) { + initMutationObserver( + this.mutationCb, + doc, + this.bypassOptions.blockClass, + this.bypassOptions.blockSelector, + this.bypassOptions.inlineStylesheet, + this.bypassOptions.maskInputOptions, + this.bypassOptions.recordCanvas, + this.bypassOptions.slimDOMOptions, + this.bypassOptions.iframeManager, + this, + shadowRoot, + ); + } +} diff --git a/src/replay/index.ts b/src/replay/index.ts index 19bd6c46..255083b7 100644 --- a/src/replay/index.ts +++ b/src/replay/index.ts @@ -40,6 +40,7 @@ import { AppendedIframe, isIframeINode, getBaseDimension, + hasShadowRoot, } from '../utils'; import getInjectStyleRules from './styles/inject-style'; import './styles/style.css'; @@ -1048,14 +1049,18 @@ export class Replayer { if (!target) { return this.warnNodeNotFound(d, mutation.id); } - const parent = mirror.getNode(mutation.parentId); + let parent: INode | null | ShadowRoot = mirror.getNode(mutation.parentId); if (!parent) { return this.warnNodeNotFound(d, mutation.parentId); } + if (mutation.isShadow && hasShadowRoot(parent)) { + parent = parent.shadowRoot; + } // target may be removed with its parents before mirror.removeNodeFromMap(target); if (parent) { - const realParent = this.fragmentParentMap.get(parent); + const realParent = + '__sn' in parent ? this.fragmentParentMap.get(parent) : undefined; if (realParent && realParent.contains(target)) { realParent.removeChild(target); } else if (this.fragmentParentMap.has(target)) { @@ -1100,7 +1105,7 @@ export class Replayer { if (!this.iframe.contentDocument) { return console.warn('Looks like your replayer has been destroyed.'); } - let parent = mirror.getNode(mutation.parentId); + let parent: INode | null | ShadowRoot = mirror.getNode(mutation.parentId); if (!parent) { if (mutation.node.type === NodeType.Document) { // is newly added document, maybe the document node of an iframe @@ -1133,6 +1138,10 @@ export class Replayer { parent = virtualParent; } + if (mutation.node.isShadow && hasShadowRoot(parent)) { + parent = parent.shadowRoot; + } + let previous: Node | null = null; let next: Node | null = null; if (mutation.previousId) { diff --git a/src/types.ts b/src/types.ts index 66831ffb..171ec7f8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,6 +8,7 @@ import { import { PackFn, UnpackFn } from './packer/base'; import { FontFaceDescriptors } from 'css-font-loading-module'; import { IframeManager } from './record/iframe-manager'; +import { ShadowDomManager } from './record/shadow-dom-manager'; export enum EventType { DomContentLoaded, @@ -231,6 +232,7 @@ export type observerParam = { slimDOMOptions: SlimDOMOptions; doc: Document; iframeManager: IframeManager; + shadowDomManager: ShadowDomManager; }; export type hooksParam = { @@ -282,6 +284,7 @@ export type attributeMutation = { export type removedNodeMutation = { parentId: number; id: number; + isShadow?: boolean; }; export type addedNodeMutation = { @@ -292,7 +295,7 @@ export type addedNodeMutation = { node: serializedNodeWithId; }; -type mutationCallbackParam = { +export type mutationCallbackParam = { texts: textMutation[]; attributes: attributeMutation[]; removes: removedNodeMutation[]; diff --git a/src/utils.ts b/src/utils.ts index c04a8d67..29ee09d7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -21,6 +21,7 @@ import { IGNORED_NODE, serializedNodeWithId, NodeType, + isShadowRoot, } from 'rrweb-snapshot'; export function on( @@ -213,6 +214,9 @@ export function isIgnored(n: Node | INode): boolean { } export function isAncestorRemoved(target: INode): boolean { + if (isShadowRoot(target)) { + return false; + } const id = mirror.getId(target); if (!mirror.has(id)) { return true; @@ -542,12 +546,16 @@ export type AppendedIframe = { builtNode: HTMLIFrameINode; }; -export function isIframeINode(node: INode): node is HTMLIFrameINode { - // node can be document fragment when using the virtual parent feature - if (!node.__sn) { - return false; +export function isIframeINode( + node: INode | ShadowRoot, +): node is HTMLIFrameINode { + if ('__sn' in node) { + return ( + node.__sn.type === NodeType.Element && node.__sn.tagName === 'iframe' + ); } - return node.__sn.type === NodeType.Element && node.__sn.tagName === 'iframe'; + // node can be document fragment when using the virtual parent feature + return false; } export function getBaseDimension( @@ -579,3 +587,9 @@ export function getBaseDimension( absoluteScale: frameBaseDimension.absoluteScale * relativeScale, }; } + +export function hasShadowRoot( + n: T, +): n is T & { shadowRoot: ShadowRoot } { + return Boolean(((n as unknown) as Element)?.shadowRoot); +} diff --git a/test/__snapshots__/integration.test.ts.snap b/test/__snapshots__/integration.test.ts.snap index df914f5b..35bd2406 100644 --- a/test/__snapshots__/integration.test.ts.snap +++ b/test/__snapshots__/integration.test.ts.snap @@ -6982,3 +6982,409 @@ exports[`serialize-before-record 1`] = ` } ]" `; + +exports[`shadow-dom 1`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"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\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Shadow DOM Observer\\", + \\"id\\": 11 + } + ], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 12 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"style\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n .my-element {\\\\n margin: 0 0 1rem 0;\\\\n }\\\\n iframe {\\\\n border: 0;\\\\n width: 100%;\\\\n padding: 0;\\\\n }\\\\n\\\\n body {\\\\n max-width: 400px;\\\\n margin: 1rem auto;\\\\n padding: 0 1rem;\\\\n font-family: 'comic sans ms';\\\\n }\\\\n \\", + \\"isStyle\\": true, + \\"id\\": 14 + } + ], + \\"id\\": 13 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 15 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 16 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 18 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"p\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n Lorem ipsum dolor sit amet, consectetur adipisicing elit. Repellat odit\\\\n officiis necessitatibus laborum asperiores et adipisci dolores corporis,\\\\n vero distinctio voluptas, suscipit commodi architecto, aliquam fugit.\\\\n Nesciunt labore reiciendis blanditiis!\\\\n \\", + \\"id\\": 20 + } + ], + \\"id\\": 19 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n\\\\n \\", + \\"id\\": 21 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": { + \\"class\\": \\"my-element\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 23 + }, + { + \\"type\\": 5, + \\"textContent\\": \\" Also could be a \\\\n \\\\n \\", + \\"id\\": 24 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 25 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 26, + \\"isShadow\\": true + }, + { + \\"type\\": 2, + \\"tagName\\": \\"style\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n body { /* for fallback iframe */\\\\n margin: 0;\\\\n }\\\\n p { \\\\n border: 1px solid #ccc;\\\\n padding: 1rem;\\\\n color: red;\\\\n font-family: sans-serif;\\\\n }\\\\n \\", + \\"isStyle\\": true, + \\"id\\": 28 + } + ], + \\"id\\": 27, + \\"isShadow\\": true + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n\\\\n \\", + \\"id\\": 29, + \\"isShadow\\": true + }, + { + \\"type\\": 2, + \\"tagName\\": \\"p\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Element with Shadow DOM\\", + \\"id\\": 31 + } + ], + \\"id\\": 30, + \\"isShadow\\": true + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n\\", + \\"id\\": 32, + \\"isShadow\\": true + } + ], + \\"id\\": 22, + \\"isShadowHost\\": true + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n\\\\n \\", + \\"id\\": 33 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"p\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n Lorem ipsum dolor sit amet, consectetur adipisicing elit. Repellat odit\\\\n officiis necessitatibus laborum asperiores et adipisci dolores corporis,\\\\n vero distinctio voluptas, suscipit commodi architecto, aliquam fugit.\\\\n Nesciunt labore reiciendis blanditiis!\\\\n \\", + \\"id\\": 35 + } + ], + \\"id\\": 34 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 36 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 38 + } + ], + \\"id\\": 37 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 39 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 41 + } + ], + \\"id\\": 40 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", + \\"id\\": 42 + } + ], + \\"id\\": 17 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 22, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"p\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 43, + \\"isShadow\\": true + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 43, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"p\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 44 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [ + { + \\"parentId\\": 22, + \\"id\\": 30, + \\"isShadow\\": true + } + ], + \\"adds\\": [] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 44, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 3, + \\"textContent\\": \\"hi\\", + \\"id\\": 45 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [ + { + \\"parentId\\": 44, + \\"id\\": 45 + } + ], + \\"adds\\": [ + { + \\"parentId\\": 44, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 3, + \\"textContent\\": \\"123\\", + \\"id\\": 46 + } + } + ] + } + } +]" +`; diff --git a/test/html/shadow-dom.html b/test/html/shadow-dom.html new file mode 100644 index 00000000..bf4c6837 --- /dev/null +++ b/test/html/shadow-dom.html @@ -0,0 +1,83 @@ + + + + + + Shadow DOM Observer + + + +

+ Lorem ipsum dolor sit amet, consectetur adipisicing elit. Repellat odit + officiis necessitatibus laborum asperiores et adipisci dolores corporis, + vero distinctio voluptas, suscipit commodi architecto, aliquam fugit. + Nesciunt labore reiciendis blanditiis! +

+ +
+ +
+ +

+ Lorem ipsum dolor sit amet, consectetur adipisicing elit. Repellat odit + officiis necessitatibus laborum asperiores et adipisci dolores corporis, + vero distinctio voluptas, suscipit commodi architecto, aliquam fugit. + Nesciunt labore reiciendis blanditiis! +

+ + + + diff --git a/test/integration.test.ts b/test/integration.test.ts index 24ece5c0..c9d1f0e1 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -419,4 +419,41 @@ describe('record integration tests', function (this: ISuite) { const snapshots = await page.evaluate('window.snapshots'); assertSnapshot(snapshots, __filename, 'iframe'); }); + + it('should record shadow DOM', async () => { + const page: puppeteer.Page = await this.browser.newPage(); + await page.goto('about:blank'); + await page.setContent(getHtml.call(this, 'shadow-dom.html')); + + await page.evaluate(() => { + const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + + const el = document.querySelector('.my-element') as HTMLDivElement; + const shadowRoot = el.shadowRoot as ShadowRoot; + shadowRoot.appendChild(document.createElement('p')); + sleep(1) + .then(() => { + shadowRoot.lastChild!.appendChild(document.createElement('p')); + return sleep(1); + }) + .then(() => { + const firstP = shadowRoot.querySelector('p') as HTMLParagraphElement; + shadowRoot.removeChild(firstP); + return sleep(1); + }) + .then(() => { + (shadowRoot.lastChild!.childNodes[0] as HTMLElement).innerText = 'hi'; + return sleep(1); + }) + .then(() => { + (shadowRoot.lastChild!.childNodes[0] as HTMLElement).innerText = + '123'; + }); + }); + await page.waitFor(50); + + const snapshots = await page.evaluate('window.snapshots'); + assertSnapshot(snapshots, __filename, 'shadow-dom'); + }); }); diff --git a/yarn.lock b/yarn.lock index c6e42e9b..ff79e7ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2767,10 +2767,10 @@ rollup@^2.3.3: optionalDependencies: fsevents "~2.1.2" -rrweb-snapshot@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/rrweb-snapshot/-/rrweb-snapshot-1.0.7.tgz#9d334590089af4a857970ef4e9e978d986a122d1" - integrity sha512-6fu9+KiQlFPkFk2SdahIDsV+yu1juiAR/o+kOiwKPbXur1TiFGMPAfaQNCkqLc8Nvyx3ItkJmrIldyxnAalEag== +rrweb-snapshot@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/rrweb-snapshot/-/rrweb-snapshot-1.1.1.tgz#71da8792f43b8bd7017851edcd02e3d7c7cfef9f" + integrity sha512-xRX7s2/MA/Ifnul4ImAquD1w/Nkz6WOACm3xdKDdQrCD/xKdgcu1yWoJ8eSIXyfVSuIt4VfrhxJdeHyhC1gmGQ== run-async@^2.2.0: version "2.4.1"