Fix: shadow dom bugs (#1049)

* Add test cases for bugs

* Fix shadow dom recording

When moving an element containing shadow dom
When adding an element to shadow dom before its attached to the dom

* Apply formatting changes

* Refactor in dom checking code

* Nodes don't get processed in more than one mutation buffer

* Constrain node mutations to one mutation buffer per request animation frame

* Make tests less flaky under heavy load

* Apply suggestions from code review

* Update packages/rrweb-snapshot/test/rebuild.test.ts

* Remove unused nodeSet

Co-authored-by: Yun Feng <yun.feng0817@gmail.com>
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent e91433bb66
commit 236d7a3f3c
11 changed files with 831 additions and 29 deletions

View File

@@ -47,6 +47,37 @@ describe('rebuild', function () {
});
});
describe('shadowDom', function () {
it('rebuild shadowRoot without siblings', function () {
const node = buildNodeWithSN(
{
id: 1,
tagName: 'div',
type: NodeType.Element,
attributes: {},
childNodes: [
{
id: 2,
tagName: 'div',
type: NodeType.Element,
attributes: {},
childNodes: [],
isShadow: true,
},
],
isShadowHost: true,
},
{
doc: document,
mirror,
hackCss: false,
cache,
},
) as HTMLDivElement;
expect(node.shadowRoot?.childNodes.length).toBe(1);
});
});
describe('add hover class to hover selector related rules', function () {
it('will do nothing to css text without :hover', () => {
const cssText = 'body { color: white }';

View File

@@ -4,7 +4,11 @@ import {
SlimDOMOptions,
createMirror,
} from 'rrweb-snapshot';
import { initObservers, mutationBuffers } from './observer';
import {
initObservers,
mutationBuffers,
processedNodeManager,
} from './observer';
import {
on,
getWindowWidth,
@@ -316,6 +320,7 @@ function record<T = eventWithTime>(
stylesheetManager,
canvasManager,
keepIframeSrcFn,
processedNodeManager,
},
mirror,
});
@@ -528,6 +533,7 @@ function record<T = eventWithTime>(
iframeManager,
stylesheetManager,
shadowDomManager,
processedNodeManager,
canvasManager,
ignoreCSSAttributes,
plugins:

View File

@@ -26,6 +26,7 @@ import {
hasShadowRoot,
isSerializedIframe,
isSerializedStylesheet,
inDom,
} from '../utils';
type DoubleLinkedListNode = {
@@ -175,6 +176,7 @@ export default class MutationBuffer {
private stylesheetManager: observerParam['stylesheetManager'];
private shadowDomManager: observerParam['shadowDomManager'];
private canvasManager: observerParam['canvasManager'];
private processedNodeManager: observerParam['processedNodeManager'];
public init(options: MutationBufferParam) {
([
@@ -198,6 +200,7 @@ export default class MutationBuffer {
'stylesheetManager',
'shadowDomManager',
'canvasManager',
'processedNodeManager',
] as const).forEach((key) => {
// just a type trick, the runtime result is correct
this[key] = options[key] as never;
@@ -271,19 +274,8 @@ export default class MutationBuffer {
(n.getRootNode() as ShadowRoot).host
)
shadowHost = (n.getRootNode() as ShadowRoot).host;
// If n is in a nested shadow dom.
let rootShadowHost = shadowHost;
while (
rootShadowHost?.getRootNode?.()?.nodeType ===
Node.DOCUMENT_FRAGMENT_NODE &&
(rootShadowHost.getRootNode() as ShadowRoot).host
)
rootShadowHost = (rootShadowHost.getRootNode() as ShadowRoot).host;
// ensure contains is passed a Node, or it will throw an error
const notInDoc =
!this.doc.contains(n) &&
(!rootShadowHost || !this.doc.contains(rootShadowHost));
if (!n.parentNode || notInDoc) {
if (!n.parentNode || !inDom(n)) {
return;
}
const parentId = isShadowRoot(n.parentNode)
@@ -647,6 +639,9 @@ export default class MutationBuffer {
* Make sure you check if `n`'s parent is blocked before calling this function
* */
private genAdds = (n: Node, target?: Node) => {
// this node was already recorded in other buffer, ignore it
if (this.processedNodeManager.inOtherBuffer(n, this)) return;
if (this.mirror.hasNode(n)) {
if (isIgnored(n, this.mirror)) {
return;
@@ -666,8 +661,15 @@ export default class MutationBuffer {
// if this node is blocked `serializeNode` will turn it into a placeholder element
// but we have to remove it's children otherwise they will be added as placeholders too
if (!isBlocked(n, this.blockClass, this.blockSelector, false))
if (!isBlocked(n, this.blockClass, this.blockSelector, false)) {
n.childNodes.forEach((childN) => this.genAdds(childN));
if (hasShadowRoot(n)) {
n.shadowRoot.childNodes.forEach((childN) => {
this.processedNodeManager.add(childN, this);
this.genAdds(childN, n);
});
}
}
};
}

View File

@@ -40,6 +40,7 @@ import {
selectionCallback,
} from '@rrweb/types';
import MutationBuffer from './mutation';
import ProcessedNodeManager from './processed-node-manager';
type WindowWithStoredMutationObserver = IWindow & {
__rrMutationObserver?: MutationObserver;
@@ -51,6 +52,7 @@ type WindowWithAngularZone = IWindow & {
};
export const mutationBuffers: MutationBuffer[] = [];
export const processedNodeManager = new ProcessedNodeManager();
const isCSSGroupingRuleSupported = typeof CSSGroupingRule !== 'undefined';
const isCSSMediaRuleSupported = typeof CSSMediaRule !== 'undefined';

View File

@@ -0,0 +1,34 @@
import type MutationBuffer from './mutation';
/**
* Keeps a log of nodes that could show up in multiple mutation buffer but shouldn't be handled twice.
*/
export default class ProcessedNodeManager {
private nodeMap: WeakMap<Node, Set<MutationBuffer>> = new WeakMap();
constructor() {
this.periodicallyClear();
}
private periodicallyClear() {
requestAnimationFrame(() => {
this.clear();
this.periodicallyClear();
});
}
public inOtherBuffer(node: Node, thisBuffer: MutationBuffer) {
const buffers = this.nodeMap.get(node);
return (
buffers && Array.from(buffers).some((buffer) => buffer !== thisBuffer)
);
}
public add(node: Node, buffer: MutationBuffer) {
this.nodeMap.set(node, (this.nodeMap.get(node) || new Set()).add(buffer));
}
private clear() {
this.nodeMap = new WeakMap();
}
}

View File

@@ -9,7 +9,7 @@ import {
initScrollObserver,
initAdoptedStyleSheetObserver,
} from './observer';
import { patch } from '../utils';
import { patch, inDom } from '../utils';
import type { Mirror } from 'rrweb-snapshot';
import { isNativeShadowDom } from 'rrweb-snapshot';
@@ -49,7 +49,11 @@ export class ShadowDomManager {
function (original: (init: ShadowRootInit) => ShadowRoot) {
return function (this: HTMLElement, option: ShadowRootInit) {
const shadowRoot = original.call(this, option);
if (this.shadowRoot)
// For the shadow dom elements in the document, monitor their dom mutations.
// For shadow dom elements that aren't in the document yet,
// we start monitoring them once their shadow dom host is appended to the document.
if (this.shadowRoot && inDom(this))
manager.addShadowRoot(this.shadowRoot, this.ownerDocument);
return shadowRoot;
};

View File

@@ -37,6 +37,7 @@ import type {
styleSheetRuleCallback,
viewportResizeCallback,
} from '@rrweb/types';
import type ProcessedNodeManager from './record/processed-node-manager';
export type recordOptions<T> = {
emit?: (e: T, isCheckout?: boolean) => void;
@@ -105,6 +106,7 @@ export type observerParam = {
stylesheetManager: StylesheetManager;
shadowDomManager: ShadowDomManager;
canvasManager: CanvasManager;
processedNodeManager: ProcessedNodeManager;
ignoreCSSAttributes: Set<string>;
plugins: Array<{
observer: (
@@ -139,6 +141,7 @@ export type MutationBufferParam = Pick<
| 'stylesheetManager'
| 'shadowDomManager'
| 'canvasManager'
| 'processedNodeManager'
>;
export type ReplayPlugin = {

View File

@@ -518,3 +518,30 @@ export class StyleSheetMirror {
return this.id++;
}
}
export function getRootShadowHost(n: Node): Node | null {
const shadowHost = (n.getRootNode() as ShadowRoot).host;
// If n is in a nested shadow dom.
let rootShadowHost = shadowHost;
while (
rootShadowHost?.getRootNode?.()?.nodeType === Node.DOCUMENT_FRAGMENT_NODE &&
(rootShadowHost.getRootNode() as ShadowRoot).host
)
rootShadowHost = (rootShadowHost.getRootNode() as ShadowRoot).host;
return rootShadowHost;
}
export function shadowHostInDom(n: Node): boolean {
const doc = n.ownerDocument;
if (!doc) return false;
const shadowHost = getRootShadowHost(n);
return Boolean(shadowHost && doc.contains(shadowHost));
}
export function inDom(n: Node): boolean {
const doc = n.ownerDocument;
if (!doc) return false;
return doc.contains(n) || shadowHostInDom(n);
}

View File

@@ -11514,6 +11514,358 @@ exports[`record integration tests should record input userTriggered values if us
]"
`;
exports[`record integration tests should record moved 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\\": 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\\": \\"script\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 7
}
],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\",
\\"id\\": 8
}
],
\\"id\\": 4
}
],
\\"id\\": 2
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 4,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"div\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 9,
\\"isShadowHost\\": true
}
},
{
\\"parentId\\": 9,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 10,
\\"isShadow\\": true
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [
{
\\"parentId\\": 4,
\\"id\\": 9
}
],
\\"adds\\": [
{
\\"parentId\\": 9,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 10,
\\"isShadow\\": true
}
},
{
\\"parentId\\": 4,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"div\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 11
}
},
{
\\"parentId\\": 11,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"div\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 9,
\\"isShadowHost\\": true
}
}
]
}
}
]"
`;
exports[`record integration tests should record moved shadow DOM 2 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\\": 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\\": \\"script\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 7
}
],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\",
\\"id\\": 8
}
],
\\"id\\": 4
}
],
\\"id\\": 2
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 4,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"div\\",
\\"attributes\\": {
\\"id\\": \\"newEl\\"
},
\\"childNodes\\": [],
\\"id\\": 9
}
},
{
\\"parentId\\": 9,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"div\\",
\\"attributes\\": {
\\"id\\": \\"el\\"
},
\\"childNodes\\": [],
\\"id\\": 10,
\\"isShadowHost\\": true
}
},
{
\\"parentId\\": 10,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"p\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 11,
\\"isShadow\\": true
}
},
{
\\"parentId\\": 10,
\\"nextId\\": 11,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"span\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 12,
\\"isShadow\\": true
}
},
{
\\"parentId\\": 12,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 13
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [
{
\\"parentId\\": 12,
\\"id\\": 13
}
],
\\"adds\\": [
{
\\"parentId\\": 11,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 13
}
}
]
}
}
]"
`;
exports[`record integration tests should record mutations in iframes accross pages 1`] = `
"[
{
@@ -12620,6 +12972,242 @@ exports[`record integration tests should record shadow DOM 1`] = `
]"
`;
exports[`record integration tests should record shadow DOM 2 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\\": 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\\": \\"script\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 7
}
],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\",
\\"id\\": 8
}
],
\\"id\\": 4
}
],
\\"id\\": 2
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 4,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"div\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 9,
\\"isShadowHost\\": true
}
},
{
\\"parentId\\": 9,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 10,
\\"isShadow\\": true
}
}
]
}
}
]"
`;
exports[`record integration tests should record shadow DOM 3 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\\": 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\\": \\"script\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 7
}
],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\",
\\"id\\": 8
}
],
\\"id\\": 4
}
],
\\"id\\": 2
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 4,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"div\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 9,
\\"isShadowHost\\": true
}
},
{
\\"parentId\\": 9,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 10,
\\"isShadow\\": true
}
}
]
}
}
]"
`;
exports[`record integration tests should record shadow doms polyfilled by shadydom 1`] = `
"[
{
@@ -13121,18 +13709,7 @@ exports[`record integration tests should record shadow doms polyfilled by synthe
\\"id\\": 31,
\\"isShadowHost\\": true
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
},
{
\\"parentId\\": 31,
\\"nextId\\": null,

View File

@@ -0,0 +1,3 @@
<html>
<body></body>
</html>

View File

@@ -667,6 +667,119 @@ describe('record integration tests', function (this: ISuite) {
assertSnapshot(snapshots);
});
it('should record shadow DOM 2', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'blank.html'));
await page.evaluate(() => {
return new Promise((resolve) => {
const el = document.createElement('div') as HTMLDivElement;
el.attachShadow({ mode: 'open' });
(el.shadowRoot as ShadowRoot).appendChild(
document.createElement('input'),
);
setTimeout(() => {
document.body.append(el);
resolve(null);
}, 10);
});
});
await waitForRAF(page);
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
it('should record shadow DOM 3', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'blank.html'));
await page.evaluate(() => {
const el = document.createElement('div') as HTMLDivElement;
el.attachShadow({ mode: 'open' });
(el.shadowRoot as ShadowRoot).appendChild(
document.createElement('input'),
);
document.body.append(el);
});
await waitForRAF(page);
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
it('should record moved shadow DOM', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'blank.html'));
await page.evaluate(() => {
return new Promise((resolve) => {
const el = document.createElement('div') as HTMLDivElement;
el.attachShadow({ mode: 'open' });
(el.shadowRoot as ShadowRoot).appendChild(
document.createElement('input'),
);
document.body.append(el);
setTimeout(() => {
const newEl = document.createElement('div') as HTMLDivElement;
document.body.append(newEl);
newEl.append(el);
resolve(null);
}, 50);
});
});
await waitForRAF(page);
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
it('should record moved shadow DOM 2', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'blank.html'));
await page.evaluate(() => {
const el = document.createElement('div') as HTMLDivElement;
el.id = 'el';
el.attachShadow({ mode: 'open' });
(el.shadowRoot as ShadowRoot).appendChild(
document.createElement('input'),
);
document.body.append(el);
(el.shadowRoot as ShadowRoot).appendChild(document.createElement('span'));
(el.shadowRoot as ShadowRoot).appendChild(document.createElement('p'));
const newEl = document.createElement('div') as HTMLDivElement;
newEl.id = 'newEl';
document.body.append(newEl);
newEl.append(el);
const input = el.shadowRoot?.children[0] as HTMLInputElement;
const span = el.shadowRoot?.children[1] as HTMLSpanElement;
const p = el.shadowRoot?.children[2] as HTMLParagraphElement;
input.remove();
span.append(input);
p.append(input);
span.append(input);
setTimeout(() => {
p.append(input);
}, 0);
});
await waitForRAF(page);
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
it('should record nested iframes and shadow doms', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank');