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>
5
.changeset/happy-carrots-hide.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"rrweb-snapshot": minor
|
||||
---
|
||||
|
||||
Record dialog's modal status for replay in rrweb. (Currently triggering `dialog.showModal()` is not supported in rrweb-snapshot's rebuild)
|
||||
7
.changeset/silly-knives-chew.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"rrdom": minor
|
||||
"rrweb": minor
|
||||
"@rrweb/types": minor
|
||||
---
|
||||
|
||||
Support top-layer <dialog> components. Fixes #1381.
|
||||
2
.github/workflows/ci-cd.yml
vendored
@@ -38,5 +38,5 @@ jobs:
|
||||
if: failure()
|
||||
with:
|
||||
name: image-diff
|
||||
path: packages/rrweb/test/*/__image_snapshots__/__diff_output__/*.png
|
||||
path: packages/**/__image_snapshots__/__diff_output__/*.png
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -62,7 +62,7 @@ Since we want the record and replay sides to share a strongly typed data structu
|
||||
|
||||
1. Fork this repository.
|
||||
2. Run `yarn install` in the root to install required dependencies for all sub-packages (note: `npm install` is _not_ recommended).
|
||||
3. Run `yarn dev` in the root to get auto-building for all the sub-packages whenever you modify anything.
|
||||
3. Run `yarn build:all` to build all packages and get a stable base, then `yarn dev` in the root to get auto-building for all the sub-packages whenever you modify anything.
|
||||
4. Navigate to one of the sub-packages (in the `packages` folder) where you'd like to make a change.
|
||||
5. Patch the code and run `yarn test` to run the tests, make sure they pass before you commit anything. Add test cases in order to avoid future regression.
|
||||
6. If tests are failing, but the change in output is desirable, run `yarn test:update` and carefully commit the changes in test output.
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^5.23.0",
|
||||
"@typescript-eslint/parser": "^5.23.0",
|
||||
"eslint": "^8.15.0",
|
||||
"happy-dom": "^14.12.0",
|
||||
"puppeteer": "^17.1.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.3.1",
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
} from './document';
|
||||
import type {
|
||||
RRCanvasElement,
|
||||
RRDialogElement,
|
||||
RRElement,
|
||||
RRIFrameElement,
|
||||
RRMediaElement,
|
||||
@@ -285,6 +286,29 @@ function diffAfterUpdatingChildren(
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'DIALOG': {
|
||||
const dialog = oldElement as HTMLDialogElement;
|
||||
const rrDialog = newRRElement as unknown as RRDialogElement;
|
||||
const wasOpen = dialog.open;
|
||||
const wasModal = dialog.matches('dialog:modal');
|
||||
const shouldBeOpen = rrDialog.open;
|
||||
const shouldBeModal = rrDialog.isModal;
|
||||
|
||||
const modalChanged = wasModal !== shouldBeModal;
|
||||
const openChanged = wasOpen !== shouldBeOpen;
|
||||
|
||||
if (modalChanged || (wasOpen && openChanged)) dialog.close();
|
||||
if (shouldBeOpen && (openChanged || modalChanged)) {
|
||||
try {
|
||||
if (shouldBeModal) dialog.showModal();
|
||||
else dialog.show();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -335,7 +359,6 @@ function diffProps(
|
||||
|
||||
for (const { name } of Array.from(oldAttributes))
|
||||
if (!(name in newAttributes)) oldTree.removeAttribute(name);
|
||||
|
||||
newTree.scrollLeft && (oldTree.scrollLeft = newTree.scrollLeft);
|
||||
newTree.scrollTop && (oldTree.scrollTop = newTree.scrollTop);
|
||||
}
|
||||
|
||||
@@ -474,7 +474,8 @@ export class BaseRRElement extends BaseRRNode implements IRRElement {
|
||||
}
|
||||
|
||||
public getAttribute(name: string): string | null {
|
||||
return this.attributes[name] || null;
|
||||
if (this.attributes[name] === undefined) return null;
|
||||
return this.attributes[name];
|
||||
}
|
||||
|
||||
public setAttribute(name: string, attribute: string) {
|
||||
@@ -547,6 +548,30 @@ export class BaseRRMediaElement extends BaseRRElement {
|
||||
}
|
||||
}
|
||||
|
||||
export class BaseRRDialogElement extends BaseRRElement {
|
||||
public readonly tagName = 'DIALOG' as const;
|
||||
public readonly nodeName = 'DIALOG' as const;
|
||||
|
||||
get isModal() {
|
||||
return this.getAttribute('rr_open_mode') === 'modal';
|
||||
}
|
||||
get open() {
|
||||
return this.getAttribute('open') !== null;
|
||||
}
|
||||
public close() {
|
||||
this.removeAttribute('open');
|
||||
this.removeAttribute('rr_open_mode');
|
||||
}
|
||||
public show() {
|
||||
this.setAttribute('open', '');
|
||||
this.setAttribute('rr_open_mode', 'non-modal');
|
||||
}
|
||||
public showModal() {
|
||||
this.setAttribute('open', '');
|
||||
this.setAttribute('rr_open_mode', 'modal');
|
||||
}
|
||||
}
|
||||
|
||||
export class BaseRRText extends BaseRRNode implements IRRText {
|
||||
public readonly nodeType: number = NodeType.TEXT_NODE;
|
||||
public readonly nodeName = '#text' as const;
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
type IRRDocumentType,
|
||||
type IRRText,
|
||||
type IRRComment,
|
||||
BaseRRDialogElement,
|
||||
} from './document';
|
||||
|
||||
export class RRDocument extends BaseRRDocument {
|
||||
@@ -104,6 +105,9 @@ export class RRDocument extends BaseRRDocument {
|
||||
case 'STYLE':
|
||||
element = new RRStyleElement(upperTagName);
|
||||
break;
|
||||
case 'DIALOG':
|
||||
element = new RRDialogElement(upperTagName);
|
||||
break;
|
||||
default:
|
||||
element = new RRElement(upperTagName);
|
||||
break;
|
||||
@@ -151,6 +155,8 @@ export class RRElement extends BaseRRElement {
|
||||
|
||||
export class RRMediaElement extends BaseRRMediaElement {}
|
||||
|
||||
export class RRDialogElement extends BaseRRDialogElement {}
|
||||
|
||||
export class RRCanvasElement extends RRElement implements IRRElement {
|
||||
public rr_dataURL: string | null = null;
|
||||
public canvasMutations: {
|
||||
|
||||
112
packages/rrdom/test/diff/dialog.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { vi, MockInstance } from 'vitest';
|
||||
import {
|
||||
NodeType as RRNodeType,
|
||||
createMirror,
|
||||
Mirror as NodeMirror,
|
||||
serializedNodeWithId,
|
||||
} from 'rrweb-snapshot';
|
||||
import { RRDocument } from '../../src';
|
||||
import { diff, ReplayerHandler } from '../../src/diff';
|
||||
|
||||
describe('diff algorithm for rrdom', () => {
|
||||
let mirror: NodeMirror;
|
||||
let replayer: ReplayerHandler;
|
||||
let warn: MockInstance;
|
||||
let elementSn: serializedNodeWithId;
|
||||
let elementSn2: serializedNodeWithId;
|
||||
|
||||
beforeEach(() => {
|
||||
mirror = createMirror();
|
||||
replayer = {
|
||||
mirror,
|
||||
applyCanvas: () => {},
|
||||
applyInput: () => {},
|
||||
applyScroll: () => {},
|
||||
applyStyleSheetMutation: () => {},
|
||||
afterAppend: () => {},
|
||||
};
|
||||
document.write('<!DOCTYPE html><html><head></head><body></body></html>');
|
||||
// Mock the original console.warn function to make the test fail once console.warn is called.
|
||||
warn = vi.spyOn(console, 'warn');
|
||||
|
||||
elementSn = {
|
||||
type: RRNodeType.Element,
|
||||
tagName: 'DIALOG',
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
id: 1,
|
||||
};
|
||||
|
||||
elementSn2 = {
|
||||
...elementSn,
|
||||
attributes: {},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Check that warn was not called (fail on warning)
|
||||
expect(warn).not.toBeCalled();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
describe('diff dialog elements', () => {
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
it('should trigger `showModal` on rr_open_mode:modal attributes', () => {
|
||||
const tagName = 'DIALOG';
|
||||
const node = document.createElement(tagName) as HTMLDialogElement;
|
||||
vi.spyOn(node, 'matches').mockReturnValue(false); // matches is used to check if the dialog was opened with showModal
|
||||
const showModalFn = vi.spyOn(node, 'showModal');
|
||||
|
||||
const rrDocument = new RRDocument();
|
||||
const rrNode = rrDocument.createElement(tagName);
|
||||
rrNode.attributes = { rr_open_mode: 'modal', open: '' };
|
||||
|
||||
mirror.add(node, elementSn);
|
||||
rrDocument.mirror.add(rrNode, elementSn);
|
||||
diff(node, rrNode, replayer);
|
||||
|
||||
expect(showModalFn).toBeCalled();
|
||||
});
|
||||
|
||||
it('should trigger `close` on rr_open_mode removed', () => {
|
||||
const tagName = 'DIALOG';
|
||||
const node = document.createElement(tagName) as HTMLDialogElement;
|
||||
node.showModal();
|
||||
vi.spyOn(node, 'matches').mockReturnValue(true); // matches is used to check if the dialog was opened with showModal
|
||||
const closeFn = vi.spyOn(node, 'close');
|
||||
|
||||
const rrDocument = new RRDocument();
|
||||
const rrNode = rrDocument.createElement(tagName);
|
||||
rrNode.attributes = {};
|
||||
|
||||
mirror.add(node, elementSn);
|
||||
rrDocument.mirror.add(rrNode, elementSn);
|
||||
diff(node, rrNode, replayer);
|
||||
|
||||
expect(closeFn).toBeCalled();
|
||||
});
|
||||
|
||||
it('should not trigger `close` on rr_open_mode is kept', () => {
|
||||
const tagName = 'DIALOG';
|
||||
const node = document.createElement(tagName) as HTMLDialogElement;
|
||||
vi.spyOn(node, 'matches').mockReturnValue(true); // matches is used to check if the dialog was opened with showModal
|
||||
node.setAttribute('rr_open_mode', 'modal');
|
||||
node.setAttribute('open', '');
|
||||
const closeFn = vi.spyOn(node, 'close');
|
||||
|
||||
const rrDocument = new RRDocument();
|
||||
const rrNode = rrDocument.createElement(tagName);
|
||||
rrNode.attributes = { rr_open_mode: 'modal', open: '' };
|
||||
|
||||
mirror.add(node, elementSn);
|
||||
rrDocument.mirror.add(rrNode, elementSn);
|
||||
diff(node, rrNode, replayer);
|
||||
|
||||
expect(closeFn).not.toBeCalled();
|
||||
expect(node.open).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -287,6 +287,11 @@ function buildNode(
|
||||
(node as HTMLMediaElement).loop = value;
|
||||
} else if (name === 'rr_mediaVolume' && typeof value === 'number') {
|
||||
(node as HTMLMediaElement).volume = value;
|
||||
} else if (name === 'rr_open_mode') {
|
||||
(node as HTMLDialogElement).setAttribute(
|
||||
'rr_open_mode',
|
||||
value as string,
|
||||
); // keep this attribute for rrweb to trigger showModal
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type MaskInputOptions,
|
||||
type SlimDOMOptions,
|
||||
type DataURLOptions,
|
||||
type DialogAttributes,
|
||||
type MaskTextFn,
|
||||
type MaskInputFn,
|
||||
type KeepIframeSrcFn,
|
||||
@@ -652,6 +653,16 @@ function serializeElementNode(
|
||||
delete attributes.selected;
|
||||
}
|
||||
}
|
||||
|
||||
if (tagName === 'dialog' && (n as HTMLDialogElement).open) {
|
||||
// register what type of dialog is this
|
||||
// `modal` or `non-modal`
|
||||
// this is used to trigger `showModal()` or `show()` on replay (outside of rrweb-snapshot, in rrweb)
|
||||
(attributes as DialogAttributes).rr_open_mode = n.matches('dialog:modal')
|
||||
? 'modal'
|
||||
: 'non-modal';
|
||||
}
|
||||
|
||||
// canvas image data
|
||||
if (tagName === 'canvas' && recordCanvas) {
|
||||
if ((n as ICanvas).__context === '2d') {
|
||||
|
||||
@@ -103,6 +103,23 @@ export type mediaAttributes = {
|
||||
rr_mediaVolume?: number;
|
||||
};
|
||||
|
||||
export type DialogAttributes = {
|
||||
open: string;
|
||||
/**
|
||||
* Represents the dialog's open mode.
|
||||
* `modal` means the dialog is opened with `showModal()`.
|
||||
* `non-modal` means the dialog is opened with `show()` or
|
||||
* by adding an `open` attribute.
|
||||
*/
|
||||
rr_open_mode: 'modal' | 'non-modal';
|
||||
/**
|
||||
* Currently unimplemented, but in future can be used to:
|
||||
* Represents the order of which of the dialog was opened.
|
||||
* This is useful for replaying the dialog `.showModal()` in the correct order.
|
||||
*/
|
||||
// rr_open_mode_index?: number;
|
||||
};
|
||||
|
||||
// @deprecated
|
||||
export interface INode extends Node {
|
||||
__sn: serializedNodeWithId;
|
||||
|
||||
@@ -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\\" />
|
||||
|
||||
5
packages/rrweb-snapshot/test/html/dialog.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<html>
|
||||
<body>
|
||||
<dialog>I'm a dialog</dialog>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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'];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')!;
|
||||
|
||||
@@ -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;
|
||||
|
||||
67
packages/rrweb/src/replay/dialog/index.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
458
packages/rrweb/test/events/dialog-playback.ts
Normal 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;
|
||||
5
packages/rrweb/test/html/dialog.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<html>
|
||||
<body>
|
||||
<dialog>I'm a dialog</dialog>
|
||||
</body>
|
||||
</html>
|
||||
487
packages/rrweb/test/record/__snapshots__/dialog.test.ts.snap
Normal 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\\": []
|
||||
}
|
||||
}
|
||||
]"
|
||||
`;
|
||||
229
packages/rrweb/test/record/dialog.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
159
packages/rrweb/test/replay/dialog.test.ts
Normal 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');
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import * as path from 'path';
|
||||
import type { PackageJson } from 'type-fest';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
const emptyOutDir = !process.argv.includes('--watch');
|
||||
|
||||
function useSpecialFormat(
|
||||
entriesToUse: string[],
|
||||
format: LibraryFormats,
|
||||
@@ -46,7 +48,7 @@ export default defineConfig({
|
||||
'dist',
|
||||
process.env.TARGET_BROWSER as string,
|
||||
),
|
||||
emptyOutDir: true,
|
||||
emptyOutDir,
|
||||
},
|
||||
// Add the webExtension plugin
|
||||
plugins: [
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"vite.config.defaults.ts",
|
||||
"tsconfig.json"
|
||||
],
|
||||
"globalPassThroughEnv": ["PUPPETEER_HEADLESS"],
|
||||
"tasks": {
|
||||
"prepublish": {
|
||||
"dependsOn": ["^prepublish", "//#references:update"],
|
||||
@@ -20,17 +21,14 @@
|
||||
]
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["^prepublish"],
|
||||
"passThroughEnv": ["PUPPETEER_HEADLESS"]
|
||||
"dependsOn": ["^prepublish"]
|
||||
},
|
||||
"test:watch": {
|
||||
"persistent": true,
|
||||
"passThroughEnv": ["PUPPETEER_HEADLESS"],
|
||||
"cache": false
|
||||
},
|
||||
"test:update": {
|
||||
"dependsOn": ["^prepublish"],
|
||||
"passThroughEnv": ["PUPPETEER_HEADLESS"]
|
||||
"dependsOn": ["^prepublish"]
|
||||
},
|
||||
"dev": {
|
||||
"dependsOn": ["prepublish", "^prepublish"],
|
||||
|
||||
19
yarn.lock
@@ -5793,6 +5793,15 @@ growly@^1.3.0:
|
||||
resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081"
|
||||
integrity sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==
|
||||
|
||||
happy-dom@^14.12.0:
|
||||
version "14.12.0"
|
||||
resolved "https://registry.yarnpkg.com/happy-dom/-/happy-dom-14.12.0.tgz#40c748578c6ebfb707e6ae69179d6c541d8f63b3"
|
||||
integrity sha512-dHcnlGFY2o2CdxfuYpqwSrBrpj/Kuzv4u4f3TU5yHW1GL24dKij4pv1BRjXnXc3uWo8qsCbToF9weaDsm/He8A==
|
||||
dependencies:
|
||||
entities "^4.5.0"
|
||||
webidl-conversions "^7.0.0"
|
||||
whatwg-mimetype "^3.0.0"
|
||||
|
||||
hard-rejection@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883"
|
||||
@@ -10427,6 +10436,11 @@ webidl-conversions@^6.1.0:
|
||||
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514"
|
||||
integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==
|
||||
|
||||
webidl-conversions@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"
|
||||
integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
|
||||
|
||||
whatwg-encoding@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0"
|
||||
@@ -10439,6 +10453,11 @@ whatwg-mimetype@^2.3.0:
|
||||
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf"
|
||||
integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==
|
||||
|
||||
whatwg-mimetype@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7"
|
||||
integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==
|
||||
|
||||
whatwg-url@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
|
||||
|
||||