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:
@@ -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
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')!;
|
||||
|
||||
Reference in New Issue
Block a user