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

@@ -1,5 +1,123 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`dialog integration tests > should capture open attribute for modal dialogs 1`] = `
"{
\\"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\\": \\"dialog\\",
\\"attributes\\": {
\\"open\\": \\"\\",
\\"rr_open_mode\\": \\"modal\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"I'm a dialog\\",
\\"id\\": 7
}
],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n\\\\n\\",
\\"id\\": 8
}
],
\\"id\\": 4
}
],
\\"id\\": 2
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 1
}"
`;
exports[`dialog integration tests > should capture open attribute for non modal dialogs 1`] = `
"{
\\"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\\": \\"dialog\\",
\\"attributes\\": {
\\"open\\": \\"\\",
\\"rr_open_mode\\": \\"non-modal\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"I'm a dialog\\",
\\"id\\": 7
}
],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n\\\\n\\",
\\"id\\": 8
}
],
\\"id\\": 4
}
],
\\"id\\": 2
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 1
}"
`;
exports[`iframe integration tests > snapshot async iframes 1`] = `
"{
\\"type\\": 0,
@@ -214,6 +332,12 @@ exports[`integration tests > [html file]: cors-style-sheet.html 1`] = `
<body></body></html>"
`;
exports[`integration tests > [html file]: dialog.html 1`] = `
"<html><head></head><body>
<dialog>I'm a dialog</dialog>
</body></html>"
`;
exports[`integration tests > [html file]: dynamic-stylesheet.html 1`] = `
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />

View File

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

View File

@@ -1,10 +1,20 @@
import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
import * as url from 'url';
import * as path from 'path';
import * as puppeteer from 'puppeteer';
import { vi, assert, describe, it, beforeAll, afterAll, expect } from 'vitest';
import { waitForRAF, getServerURL } from './utils';
import * as url from 'url';
import {
afterAll,
assert,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { getServerURL, waitForRAF } from './utils';
const htmlFolder = path.join(__dirname, 'html');
const htmls = fs.readdirSync(htmlFolder).map((filePath) => {
@@ -60,6 +70,15 @@ function sanitizeSnapshot(snapshot: string): string {
return snapshot.replace(/localhost:[0-9]+/g, 'localhost:3030');
}
async function snapshot(page: puppeteer.Page, code: string): Promise<string> {
await waitForRAF(page);
const result = (await page.evaluate(`${code}
const snapshot = rrwebSnapshot.snapshot(document);
JSON.stringify(snapshot, null, 2);
`)) as string;
return result;
}
function assertSnapshot(snapshot: string): void {
expect(sanitizeSnapshot(snapshot)).toMatchSnapshot();
}
@@ -68,6 +87,7 @@ interface ISuite {
server: http.Server;
serverURL: string;
browser: puppeteer.Browser;
page: puppeteer.Page;
code: string;
}
@@ -431,6 +451,53 @@ describe('iframe integration tests', function (this: ISuite) {
});
});
describe('dialog integration tests', function (this: ISuite) {
vi.setConfig({ testTimeout: 30_000 });
let server: ISuite['server'];
let serverURL: ISuite['serverURL'];
let browser: ISuite['browser'];
let code: ISuite['code'];
let page: ISuite['page'];
beforeAll(async () => {
server = await startServer();
serverURL = getServerURL(server);
browser = await puppeteer.launch({
// headless: false,
});
code = fs.readFileSync(
path.resolve(__dirname, '../dist/rrweb-snapshot.umd.cjs'),
'utf-8',
);
});
beforeEach(async () => {
page = await browser.newPage();
page.on('console', (msg) => console.log(msg.text()));
await page.goto(`${serverURL}/html/dialog.html`, {
waitUntil: 'load',
});
});
afterAll(async () => {
await browser.close();
await server.close();
});
it('should capture open attribute for non modal dialogs', async () => {
page.evaluate('document.querySelector("dialog").show()');
const snapshotResult = await snapshot(page, code);
assertSnapshot(snapshotResult);
});
it('should capture open attribute for modal dialogs', async () => {
await page.evaluate('document.querySelector("dialog").showModal()');
const snapshotResult = await snapshot(page, code);
assertSnapshot(snapshotResult);
});
});
describe('shadow DOM integration tests', function (this: ISuite) {
vi.setConfig({ testTimeout: 30_000 });
let server: ISuite['server'];

View File

@@ -3,7 +3,7 @@
*/
import * as fs from 'fs';
import * as path from 'path';
import { describe, it, beforeEach, expect as _expect } from 'vitest';
import { beforeEach, describe, expect as _expect, it } from 'vitest';
import {
adaptCssForReplay,
buildNodeWithSN,

View File

@@ -2,12 +2,31 @@
* @vitest-environment jsdom
*/
import { JSDOM } from 'jsdom';
import { describe, it, expect } from 'vitest';
import { serializeNodeWithId, _isBlockedElement } from '../src/snapshot';
import snapshot from '../src/snapshot';
import { serializedNodeWithId, elementNode } from '../src/types';
import { describe, expect, it } from 'vitest';
import snapshot, {
_isBlockedElement,
serializeNodeWithId,
} from '../src/snapshot';
import { elementNode, serializedNodeWithId } from '../src/types';
import { Mirror, absolutifyURLs } from '../src/utils';
const serializeNode = (node: Node): serializedNodeWithId | null => {
return serializeNodeWithId(node, {
doc: document,
mirror: new Mirror(),
blockClass: 'blockblock',
blockSelector: null,
maskTextClass: 'maskmask',
maskTextSelector: null,
skipChild: false,
inlineStylesheet: true,
maskTextFn: undefined,
maskInputFn: undefined,
slimDOMOptions: {},
});
};
describe('absolute url to stylesheet', () => {
const href = 'http://localhost/css/style.css';
@@ -135,22 +154,6 @@ describe('isBlockedElement()', () => {
});
describe('style elements', () => {
const serializeNode = (node: Node): serializedNodeWithId | null => {
return serializeNodeWithId(node, {
doc: document,
mirror: new Mirror(),
blockClass: 'blockblock',
blockSelector: null,
maskTextClass: 'maskmask',
maskTextSelector: null,
skipChild: false,
inlineStylesheet: true,
maskTextFn: undefined,
maskInputFn: undefined,
slimDOMOptions: {},
});
};
const render = (html: string): HTMLStyleElement => {
document.write(html);
return document.querySelector('style')!;
@@ -180,23 +183,6 @@ describe('style elements', () => {
});
describe('scrollTop/scrollLeft', () => {
const serializeNode = (node: Node): serializedNodeWithId | null => {
return serializeNodeWithId(node, {
doc: document,
mirror: new Mirror(),
blockClass: 'blockblock',
blockSelector: null,
maskTextClass: 'maskmask',
maskTextSelector: null,
skipChild: false,
inlineStylesheet: true,
maskTextFn: undefined,
maskInputFn: undefined,
slimDOMOptions: {},
newlyAddedElement: false,
});
};
const render = (html: string): HTMLDivElement => {
document.write(html);
return document.querySelector('div')!;
@@ -218,23 +204,6 @@ describe('scrollTop/scrollLeft', () => {
});
describe('form', () => {
const serializeNode = (node: Node): serializedNodeWithId | null => {
return serializeNodeWithId(node, {
doc: document,
mirror: new Mirror(),
blockClass: 'blockblock',
blockSelector: null,
maskTextClass: 'maskmask',
maskTextSelector: null,
skipChild: false,
inlineStylesheet: true,
maskTextFn: undefined,
maskInputFn: undefined,
slimDOMOptions: {},
newlyAddedElement: false,
});
};
const render = (html: string): HTMLTextAreaElement => {
document.write(html);
return document.querySelector('textarea')!;