Support top-layer <dialog> recording & replay (#1503)

* chore: its important to run `yarn build:all` before running `yarn dev`

* feat: trigger showModal from rrdom and rrweb

* feat: Add support for replaying modal and non modal dialog elements

* chore: Update dev script to remove CLEAR_DIST_DIR flag

* Get modal recording and replay working

* DRY up dialog test and dedupe snapshot images

* feat: Refactor dialog test to use updated attribute name

* feat: Update dialog test to include rr_open attribute

* chore: Add npm dependency happy-dom@14.12.0

* Add more test cases for dialog

* Clean up naming

* Refactor dialog open code

* Revert changed code that doesn't do anything

* Add documentation for unimplemented type

* chore: Remove unnecessary comments in dialog.test.ts

* rename rr_open to rr_openMode

* Replace todo with a skipped test

* Add better logging for CI

* Rename rr_openMode to rr_open_mode

rrdom downcases all attribute names which made `rr_openMode` tricky to deal with

* Remove unused images

* Move after iframe append based on @YunFeng0817's comment
https://github.com/rrweb-io/rrweb/pull/1503#discussion_r1666363931

* Remove redundant dialog handling from rrdom.

rrdom already handles dialog element creation it's self

* Rename variables for dialog handling in rrweb replay module

* Update packages/rrdom/src/document.ts

---------

Co-authored-by: Eoghan Murray <eoghan@getthere.ie>
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent c110e1c21d
commit 5217a09c60
38 changed files with 1902 additions and 75 deletions

View File

@@ -663,6 +663,12 @@ export default class MutationBuffer {
item.styleDiff[pname] = false; // delete
}
}
} else if (attributeName === 'open' && target.tagName === 'DIALOG') {
if (target.matches('dialog:modal')) {
item.attributes['rr_open_mode'] = 'modal';
} else {
item.attributes['rr_open_mode'] = 'non-modal';
}
}
}
break;

View File

@@ -0,0 +1,67 @@
import type { attributeMutation } from '@rrweb/types';
import { RRNode } from 'rrdom';
/**
* Checks if the dialog is a top level dialog and applies the dialog to the top level
* @param node - potential dialog element to apply top level `showModal()` to, or other node (which will be ignored)
* @param attributeMutation - the attribute mutation used to change the dialog (optional)
* @returns void
*/
export function applyDialogToTopLevel(
node: HTMLDialogElement | Node | RRNode,
attributeMutation?: attributeMutation,
): void {
if (node.nodeName !== 'DIALOG' || node instanceof RRNode) return;
const dialog = node as HTMLDialogElement;
const oldIsOpen = dialog.open;
const oldIsModalState = oldIsOpen && dialog.matches('dialog:modal');
const rrOpenMode = dialog.getAttribute('rr_open_mode');
const newIsOpen =
typeof attributeMutation?.attributes.open === 'string' ||
typeof dialog.getAttribute('open') === 'string';
const newIsModalState = rrOpenMode === 'modal';
const newIsNonModalState = rrOpenMode === 'non-modal';
const modalStateChanged =
(oldIsModalState && newIsNonModalState) ||
(!oldIsModalState && newIsModalState);
if (oldIsOpen && !modalStateChanged) return;
// complain if dialog is not attached to the dom
if (!dialog.isConnected) {
console.warn('dialog is not attached to the dom', dialog);
return;
}
if (oldIsOpen) dialog.close();
if (!newIsOpen) return;
if (newIsModalState) dialog.showModal();
else dialog.show();
}
/**
* Check if the dialog is a top level dialog and removes the dialog from the top level if necessary
* @param node - potential dialog element to remove from top level, or other node (which will be ignored)
* @param attributeMutation - the attribute mutation used to change the dialog
* @returns void
*/
export function removeDialogFromTopLevel(
node: HTMLDialogElement | Node | RRNode,
attributeMutation: attributeMutation,
): void {
if (node.nodeName !== 'DIALOG' || node instanceof RRNode) return;
const dialog = node as HTMLDialogElement;
// complain if dialog is not attached to the dom
if (!dialog.isConnected) {
console.warn('dialog is not attached to the dom', dialog);
return;
}
if (attributeMutation.attributes.open === null) {
dialog.removeAttribute('open');
dialog.removeAttribute('rr_open_mode');
}
}

View File

@@ -88,6 +88,7 @@ import './styles/style.css';
import canvasMutation from './canvas';
import { deserializeArg } from './canvas/deserialize-args';
import { MediaManager } from './media';
import { applyDialogToTopLevel, removeDialogFromTopLevel } from './dialog';
const SKIP_TIME_INTERVAL = 5 * 1000;
@@ -803,9 +804,12 @@ export class Replayer {
);
}
this.legacy_missingNodeRetryMap = {};
const collected: AppendedIframe[] = [];
const collectedIframes: AppendedIframe[] = [];
const collectedDialogs = new Set<HTMLDialogElement>();
const afterAppend = (builtNode: Node, id: number) => {
this.collectIframeAndAttachDocument(collected, builtNode);
if (builtNode.nodeName === 'DIALOG')
collectedDialogs.add(builtNode as HTMLDialogElement);
this.collectIframeAndAttachDocument(collectedIframes, builtNode);
if (this.mediaManager.isSupportedMediaElement(builtNode)) {
const { events } = this.service.state.context;
this.mediaManager.addMediaElements(
@@ -842,7 +846,7 @@ export class Replayer {
});
afterAppend(this.iframe.contentDocument, event.data.node.id);
for (const { mutationInQueue, builtNode } of collected) {
for (const { mutationInQueue, builtNode } of collectedIframes) {
this.attachDocumentToIframe(mutationInQueue, builtNode);
this.newDocumentQueue = this.newDocumentQueue.filter(
(m) => m !== mutationInQueue,
@@ -850,6 +854,7 @@ export class Replayer {
}
const { documentElement, head } = this.iframe.contentDocument;
this.insertStyleRules(documentElement, head);
collectedDialogs.forEach((d) => applyDialogToTopLevel(d));
if (!this.service.state.matches('playing')) {
this.iframe.contentDocument
.getElementsByTagName('html')[0]
@@ -912,9 +917,12 @@ export class Replayer {
type TNode = typeof mirror extends Mirror ? Node : RRNode;
type TMirror = typeof mirror extends Mirror ? Mirror : RRDOMMirror;
const collected: AppendedIframe[] = [];
const collectedIframes: AppendedIframe[] = [];
const collectedDialogs = new Set<HTMLDialogElement>();
const afterAppend = (builtNode: Node, id: number) => {
this.collectIframeAndAttachDocument(collected, builtNode);
if (builtNode.nodeName === 'DIALOG')
collectedDialogs.add(builtNode as HTMLDialogElement);
this.collectIframeAndAttachDocument(collectedIframes, builtNode);
const sn = (mirror as TMirror).getMeta(builtNode as unknown as TNode);
if (
sn?.type === NodeType.Element &&
@@ -948,12 +956,14 @@ export class Replayer {
});
afterAppend(iframeEl.contentDocument! as Document, mutation.node.id);
for (const { mutationInQueue, builtNode } of collected) {
for (const { mutationInQueue, builtNode } of collectedIframes) {
this.attachDocumentToIframe(mutationInQueue, builtNode);
this.newDocumentQueue = this.newDocumentQueue.filter(
(m) => m !== mutationInQueue,
);
}
collectedDialogs.forEach((d) => applyDialogToTopLevel(d));
}
private collectIframeAndAttachDocument(
@@ -1534,6 +1544,7 @@ export class Replayer {
const afterAppend = (node: Node | RRNode, id: number) => {
// Skip the plugin onBuild callback for virtual dom
if (this.usingVirtualDom) return;
applyDialogToTopLevel(node);
for (const plugin of this.config.plugins || []) {
if (plugin.onBuild) plugin.onBuild(node, { id, replayer: this });
}
@@ -1757,6 +1768,8 @@ export class Replayer {
const value = mutation.attributes[attributeName];
if (value === null) {
(target as Element | RRElement).removeAttribute(attributeName);
if (attributeName === 'open')
removeDialogFromTopLevel(target, mutation);
} else if (typeof value === 'string') {
try {
// When building snapshot, some link styles haven't loaded. Then they are loaded, they will be inlined as incremental mutation change of attribute. We need to replace the old elements whose styles aren't inlined.
@@ -1812,6 +1825,13 @@ export class Replayer {
value,
);
}
if (
attributeName === 'rr_open_mode' &&
target.nodeName === 'DIALOG'
) {
applyDialogToTopLevel(target, mutation);
}
} catch (error) {
this.warn(
'An error occurred may due to the checkout feature.',

View File

@@ -0,0 +1,458 @@
import { eventWithTime, IncrementalSource } from '@rrweb/types';
const startTime = 1900000000;
export const closedFullSnapshotTime = 132;
export const showIncrementalAttributeTime = 1500;
export const closeIncrementalAttributeTime = 2000;
export const showModalIncrementalAttributeTime = 2500;
export const switchBetweenShowModalAndShowIncrementalAttributeTime = 2600;
export const switchBetweenShowAndShowModalIncrementalAttributeTime = 2700;
export const showFullSnapshotTime = 3000;
export const showModalFullSnapshotTime = 3500;
export const showModalIncrementalAddTime = 4000;
const events: eventWithTime[] = [
{ type: 0, data: {}, timestamp: startTime + 1 },
{ type: 1, data: {}, timestamp: startTime + closedFullSnapshotTime },
{
type: 4,
data: {
href: 'http://127.0.0.1:5500/test/html/dialog.html',
width: 1600,
height: 900,
},
timestamp: startTime + closedFullSnapshotTime,
},
{
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: {
'http-equiv': 'X-UA-Compatible',
content: 'IE=edge',
},
childNodes: [],
id: 8,
},
{ type: 3, textContent: '\n ', id: 9 },
{
type: 2,
tagName: 'meta',
attributes: {
name: 'viewport',
content: 'width=device-width, initial-scale=1.0',
},
childNodes: [],
id: 10,
},
{ type: 3, textContent: '\n ', id: 11 },
{
type: 2,
tagName: 'title',
attributes: {},
childNodes: [{ type: 3, textContent: '<Dialog>', id: 13 }],
id: 12,
},
],
id: 4,
},
{ type: 3, textContent: '\n ', id: 21 },
{
type: 2,
tagName: 'body',
attributes: {},
childNodes: [
{ type: 3, textContent: '\n ', id: 23 },
{
type: 2,
tagName: 'dialog',
attributes: {
style: 'outline: blue solid 1px;',
},
childNodes: [{ type: 3, textContent: 'Dialog 1', id: 25 }],
id: 24,
},
{ type: 3, textContent: '\n ', id: 26 },
{
type: 2,
tagName: 'dialog',
attributes: {
style: 'outline: red solid 1px;',
},
childNodes: [{ type: 3, textContent: 'Dialog 2', id: 28 }],
id: 27,
},
{ type: 3, textContent: '\n ', id: 31 },
],
id: 22,
},
],
id: 3,
},
],
id: 1,
},
initialOffset: { left: 0, top: 0 },
},
timestamp: startTime + closedFullSnapshotTime,
},
// open dialog with .show()
{
type: 3,
data: {
source: IncrementalSource.Mutation,
adds: [],
removes: [],
texts: [],
attributes: [
{
id: 27,
attributes: { open: '', rr_open_mode: 'non-modal', class: 'show' },
},
],
},
timestamp: startTime + showIncrementalAttributeTime,
},
// close dialog with .close()
{
type: 3,
data: {
source: IncrementalSource.Mutation,
adds: [],
removes: [],
texts: [],
attributes: [
{
id: 27,
attributes: { open: null, class: 'closed' },
},
],
},
timestamp: startTime + closeIncrementalAttributeTime,
},
// open dialog with .showModal()
{
type: 3,
data: {
source: IncrementalSource.Mutation,
adds: [],
removes: [],
texts: [],
attributes: [
{
id: 27,
attributes: { rr_open_mode: 'modal', open: '', class: 'showModal' },
},
],
},
timestamp: startTime + showModalIncrementalAttributeTime,
},
// switch between .showModal() and .show()
{
type: 3,
data: {
source: IncrementalSource.Mutation,
adds: [],
removes: [],
texts: [],
attributes: [
{
id: 27,
attributes: {
rr_open_mode: 'non-modal',
class: 'switched-from-show-modal-to-show',
},
},
],
},
timestamp:
startTime + switchBetweenShowModalAndShowIncrementalAttributeTime,
},
// switch between .show() and .showModal()
{
type: 3,
data: {
source: IncrementalSource.Mutation,
adds: [],
removes: [],
texts: [],
attributes: [
{
id: 27,
attributes: {
rr_open_mode: 'modal',
class: 'switched-from-show-to-show-modal',
},
},
],
},
timestamp:
startTime + switchBetweenShowAndShowModalIncrementalAttributeTime,
},
// open dialog with .show()
{
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: {
'http-equiv': 'X-UA-Compatible',
content: 'IE=edge',
},
childNodes: [],
id: 8,
},
{ type: 3, textContent: '\n ', id: 9 },
{
type: 2,
tagName: 'meta',
attributes: {
name: 'viewport',
content: 'width=device-width, initial-scale=1.0',
},
childNodes: [],
id: 10,
},
{ type: 3, textContent: '\n ', id: 11 },
{
type: 2,
tagName: 'title',
attributes: {},
childNodes: [{ type: 3, textContent: '<Dialog>', id: 13 }],
id: 12,
},
],
id: 4,
},
{ type: 3, textContent: '\n ', id: 21 },
{
type: 2,
tagName: 'body',
attributes: {},
childNodes: [
{ type: 3, textContent: '\n ', id: 23 },
{
type: 2,
tagName: 'dialog',
attributes: {
open: '',
rr_open_mode: 'non-modal',
style: 'outline: blue solid 1px;',
},
childNodes: [{ type: 3, textContent: 'Dialog 1', id: 25 }],
id: 24,
},
{ type: 3, textContent: '\n ', id: 26 },
{
type: 2,
tagName: 'dialog',
attributes: {
style: 'outline: red solid 1px;',
},
childNodes: [{ type: 3, textContent: 'Dialog 2', id: 28 }],
id: 27,
},
{ type: 3, textContent: '\n ', id: 31 },
],
id: 22,
},
],
id: 3,
},
],
id: 1,
},
initialOffset: { left: 0, top: 0 },
},
timestamp: startTime + showFullSnapshotTime,
},
{
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: {
'http-equiv': 'X-UA-Compatible',
content: 'IE=edge',
},
childNodes: [],
id: 8,
},
{ type: 3, textContent: '\n ', id: 9 },
{
type: 2,
tagName: 'meta',
attributes: {
name: 'viewport',
content: 'width=device-width, initial-scale=1.0',
},
childNodes: [],
id: 10,
},
{ type: 3, textContent: '\n ', id: 11 },
{
type: 2,
tagName: 'title',
attributes: {},
childNodes: [{ type: 3, textContent: '<Dialog>', id: 13 }],
id: 12,
},
],
id: 4,
},
{ type: 3, textContent: '\n ', id: 21 },
{
type: 2,
tagName: 'body',
attributes: {},
childNodes: [
{ type: 3, textContent: '\n ', id: 23 },
{
type: 2,
tagName: 'dialog',
attributes: {
rr_open_mode: 'modal',
open: '',
style: 'outline: blue solid 1px;',
class: 'existing-1',
},
childNodes: [{ type: 3, textContent: 'Dialog 1', id: 25 }],
id: 24,
},
{ type: 3, textContent: '\n ', id: 26 },
{
type: 2,
tagName: 'dialog',
attributes: {
style: 'outline: red solid 1px;',
class: 'existing-2',
},
childNodes: [{ type: 3, textContent: 'Dialog 2', id: 28 }],
id: 27,
},
{ type: 3, textContent: '\n ', id: 31 },
],
id: 22,
},
],
id: 3,
},
],
id: 1,
},
initialOffset: { left: 0, top: 0 },
},
timestamp: startTime + showModalFullSnapshotTime,
},
// add open dialog with .showModal()
{
type: 3,
data: {
source: IncrementalSource.Mutation,
adds: [
{
parentId: 22,
previousId: 23,
nextId: 24,
node: {
type: 2,
tagName: 'dialog',
attributes: {
rr_open_mode: 'modal',
open: '',
style: 'outline: orange solid 1px;',
class: 'new-dialog',
},
childNodes: [],
id: 32,
},
},
{
parentId: 32,
previousId: null,
nextId: null,
node: { type: 3, textContent: 'Dialog 3', id: 33 },
},
],
removes: [],
texts: [],
attributes: [],
},
timestamp: startTime + showModalIncrementalAddTime,
},
];
export default events;

View File

@@ -0,0 +1,5 @@
<html>
<body>
<dialog>I'm a dialog</dialog>
</body>
</html>

View File

@@ -0,0 +1,487 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`dialog > add dialog and show 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\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 5
}
],
\\"id\\": 4
}
],
\\"id\\": 3
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"dialog\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"I'm a dialog\\",
\\"id\\": 9
}
],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n\\\\n\\",
\\"id\\": 10
}
],
\\"id\\": 6
}
],
\\"id\\": 2
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 6,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"dialog\\",
\\"attributes\\": {
\\"open\\": \\"\\",
\\"rr_open_mode\\": \\"non-modal\\"
},
\\"childNodes\\": [],
\\"id\\": 11
}
}
]
}
}
]"
`;
exports[`dialog > add dialog and showModal 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\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 5
}
],
\\"id\\": 4
}
],
\\"id\\": 3
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"dialog\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"I'm a dialog\\",
\\"id\\": 9
}
],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n\\\\n\\",
\\"id\\": 10
}
],
\\"id\\": 6
}
],
\\"id\\": 2
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 6,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"dialog\\",
\\"attributes\\": {
\\"open\\": \\"\\",
\\"rr_open_mode\\": \\"modal\\"
},
\\"childNodes\\": [],
\\"id\\": 11
}
}
]
}
}
]"
`;
exports[`dialog > switch to show dialog 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\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 5
}
],
\\"id\\": 4
}
],
\\"id\\": 3
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"dialog\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"I'm a dialog\\",
\\"id\\": 9
}
],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n\\\\n\\",
\\"id\\": 10
}
],
\\"id\\": 6
}
],
\\"id\\": 2
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 8,
\\"attributes\\": {
\\"open\\": \\"\\",
\\"rr_open_mode\\": \\"modal\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 8,
\\"attributes\\": {
\\"open\\": \\"\\",
\\"rr_open_mode\\": \\"non-modal\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
}
]"
`;
exports[`dialog > switch to showModal dialog 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\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 5
}
],
\\"id\\": 4
}
],
\\"id\\": 3
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"dialog\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"I'm a dialog\\",
\\"id\\": 9
}
],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n\\\\n\\",
\\"id\\": 10
}
],
\\"id\\": 6
}
],
\\"id\\": 2
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 8,
\\"attributes\\": {
\\"open\\": \\"\\",
\\"rr_open_mode\\": \\"non-modal\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 8,
\\"attributes\\": {
\\"open\\": \\"\\",
\\"rr_open_mode\\": \\"modal\\"
}
}
],
\\"removes\\": [],
\\"adds\\": []
}
}
]"
`;

View File

@@ -0,0 +1,229 @@
import * as fs from 'fs';
import * as path from 'path';
import { vi } from 'vitest';
import {
assertSnapshot,
getServerURL,
ISuite,
launchPuppeteer,
startServer,
waitForRAF,
} from '../utils';
import {
attributeMutation,
EventType,
eventWithTime,
listenerHandler,
} from '@rrweb/types';
import { recordOptions } from '../../src/types';
interface IWindow extends Window {
rrweb: {
record: (
options: recordOptions<eventWithTime>,
) => listenerHandler | undefined;
addCustomEvent<T>(tag: string, payload: T): void;
};
emit: (e: eventWithTime) => undefined;
}
const attributeMutationFactory = (
mutation: attributeMutation['attributes'],
) => {
return {
data: {
attributes: [
{
attributes: mutation,
},
],
},
};
};
describe('dialog', () => {
vi.setConfig({ testTimeout: 100_000 });
let code: ISuite['code'];
let page: ISuite['page'];
let browser: ISuite['browser'];
let server: ISuite['server'];
let serverURL: ISuite['serverURL'];
let events: ISuite['events'];
beforeAll(async () => {
server = await startServer();
serverURL = getServerURL(server);
browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../../dist/rrweb.umd.cjs');
code = fs.readFileSync(bundlePath, 'utf8');
});
afterEach(async () => {
await page.close();
});
afterAll(async () => {
await server.close();
await browser.close();
});
beforeEach(async () => {
page = await browser.newPage();
page.on('console', (msg) => {
console.log(msg.text());
});
await page.goto(`${serverURL}/html/dialog.html`);
await page.addScriptTag({
path: path.resolve(__dirname, '../../dist/rrweb.umd.cjs'),
});
await waitForRAF(page);
events = [];
await page.exposeFunction('emit', (e: eventWithTime) => {
if (e.type === EventType.DomContentLoaded || e.type === EventType.Load) {
return;
}
events.push(e);
});
page.on('console', (msg) => console.log('PAGE LOG:', msg.text()));
await page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
emit: (window as unknown as IWindow).emit,
});
});
await waitForRAF(page);
});
it('show dialog', async () => {
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.show();
});
const lastEvent = events[events.length - 1];
expect(lastEvent).toMatchObject(attributeMutationFactory({ open: '' }));
// assertSnapshot(events);
});
it('showModal dialog', async () => {
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.showModal();
});
const lastEvent = events[events.length - 1];
expect(lastEvent).toMatchObject(
attributeMutationFactory({ rr_open_mode: 'modal' }),
);
});
it('showModal & close dialog', async () => {
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.showModal();
});
await waitForRAF(page);
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.close();
});
const lastEvent = events[events.length - 1];
expect(lastEvent).toMatchObject(attributeMutationFactory({ open: null }));
});
it('show & close dialog', async () => {
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.show();
});
await waitForRAF(page);
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.close();
});
const lastEvent = events[events.length - 1];
expect(lastEvent).toMatchObject(attributeMutationFactory({ open: null }));
});
it('switch to showModal dialog', async () => {
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.show();
});
await waitForRAF(page);
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.close();
dialog.showModal();
});
await assertSnapshot(events);
});
it('switch to show dialog', async () => {
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.showModal();
});
await waitForRAF(page);
await page.evaluate(() => {
const dialog = document.querySelector('dialog') as HTMLDialogElement;
dialog.close();
dialog.show();
});
await assertSnapshot(events);
});
it('add dialog and showModal', async () => {
await page.evaluate(() => {
const dialog = document.createElement('dialog') as HTMLDialogElement;
document.body.appendChild(dialog);
dialog.showModal();
});
await waitForRAF(page);
await assertSnapshot(events);
});
it('add dialog and show', async () => {
await page.evaluate(() => {
const dialog = document.createElement('dialog') as HTMLDialogElement;
document.body.appendChild(dialog);
dialog.show();
});
await waitForRAF(page);
await assertSnapshot(events);
});
// TODO: implement me in the future
it.skip('should record playback order with multiple dialogs opening', async () => {
await page.evaluate(() => {
const dialog1 = document.createElement('dialog') as HTMLDialogElement;
dialog1.className = 'dialog1';
document.body.appendChild(dialog1);
const dialog2 = document.createElement('dialog') as HTMLDialogElement;
dialog1.className = 'dialog2';
document.body.appendChild(dialog2);
dialog2.showModal(); // <== Note that dialog TWO is being triggered first
dialog1.showModal();
});
await waitForRAF(page);
await assertSnapshot(events); // <== This should trigger showModal() on dialog2 first, then dialog1
});
});

View File

@@ -0,0 +1,159 @@
import * as fs from 'fs';
import { toMatchImageSnapshot } from 'jest-image-snapshot';
import * as path from 'path';
import { vi } from 'vitest';
import dialogPlaybackEvents, {
closedFullSnapshotTime,
showIncrementalAttributeTime,
closeIncrementalAttributeTime,
showModalIncrementalAttributeTime,
showFullSnapshotTime,
showModalFullSnapshotTime,
showModalIncrementalAddTime,
switchBetweenShowModalAndShowIncrementalAttributeTime,
switchBetweenShowAndShowModalIncrementalAttributeTime,
} from '../events/dialog-playback';
import {
fakeGoto,
getServerURL,
hideMouseAnimation,
ISuite,
launchPuppeteer,
startServer,
waitForRAF,
} from '../utils';
expect.extend({ toMatchImageSnapshot });
describe('dialog', () => {
vi.setConfig({ testTimeout: 100_000 });
let code: ISuite['code'];
let page: ISuite['page'];
let browser: ISuite['browser'];
let server: ISuite['server'];
let serverURL: ISuite['serverURL'];
beforeAll(async () => {
server = await startServer();
serverURL = getServerURL(server);
browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../../dist/rrweb.umd.cjs');
code = fs.readFileSync(bundlePath, 'utf8');
});
afterEach(async () => {
await page.close();
});
afterAll(async () => {
await server.close();
await browser.close();
});
beforeEach(async () => {
page = await browser.newPage();
page.on('console', (msg) => {
console.log(msg.text());
});
await fakeGoto(page, `${serverURL}/html/dialog.html`);
await page.evaluate(code);
await waitForRAF(page);
await hideMouseAnimation(page);
});
[
{
name: 'show the dialog when open attribute gets added',
time: showIncrementalAttributeTime,
},
{
name: 'should close dialog again when open attribute gets removed',
time: closeIncrementalAttributeTime,
},
{
name: 'should open dialog with showModal',
time: showModalIncrementalAttributeTime,
},
{
name: 'should switch between showModal and show',
time: switchBetweenShowModalAndShowIncrementalAttributeTime,
},
{
name: 'should switch between show and showModal',
time: switchBetweenShowAndShowModalIncrementalAttributeTime,
},
{
name: 'should open dialog with show in full snapshot',
time: showFullSnapshotTime,
},
{
name: 'should open dialog with showModal in full snapshot',
time: showModalFullSnapshotTime,
},
{
name: 'should add an opened dialog with showModal in incremental snapshot',
time: showModalIncrementalAddTime,
},
{
name: 'should add an opened dialog with showModal in incremental snapshot alternative',
time: [showModalFullSnapshotTime, showModalIncrementalAddTime],
},
].forEach(({ name, time }) => {
[true, false].forEach((useVirtualDom) => {
it(`${name} (virtual dom: ${useVirtualDom})`, async () => {
await page.evaluate(
`let events = ${JSON.stringify(dialogPlaybackEvents)}`,
);
await page.evaluate(`
const { Replayer } = rrweb;
window.replayer = new Replayer(events, { useVirtualDom: ${useVirtualDom} });
`);
const timeArray = Array.isArray(time) ? time : [time];
for (let i = 0; i < timeArray.length; i++) {
await page.evaluate(`
window.replayer.pause(${timeArray[i]});
`);
await waitForRAF(page);
}
const frameImage = await page!.screenshot({
fullPage: false,
});
const defaultImageFilePrefix =
'dialog-test-ts-test-replay-dialog-test-ts-dialog';
const kebabCaseName = name
.replace(/ /g, '-')
.replace(/showModal/g, 'show-modal');
const imageFileName = `${defaultImageFilePrefix}-${kebabCaseName}`;
expect(frameImage).toMatchImageSnapshot({
customSnapshotIdentifier: imageFileName,
failureThreshold: 0.05,
failureThresholdType: 'percent',
dumpDiffToConsole: true,
storeReceivedOnFailure: true,
});
});
});
});
it('closed dialogs show nothing', async () => {
await page.evaluate(`let events = ${JSON.stringify(dialogPlaybackEvents)}`);
await page.evaluate(`
const { Replayer } = rrweb;
window.replayer = new Replayer(events);
`);
await waitForRAF(page);
const frameImage = await page!.screenshot();
expect(frameImage).toMatchImageSnapshot({
failureThreshold: 0.05,
failureThresholdType: 'percent',
});
});
// TODO: implement me in the future
it.skip('should trigger showModal on multiple dialogs in a specific order');
});