diff --git a/.changeset/happy-carrots-hide.md b/.changeset/happy-carrots-hide.md new file mode 100644 index 0000000000..c5825b4024 --- /dev/null +++ b/.changeset/happy-carrots-hide.md @@ -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) diff --git a/.changeset/silly-knives-chew.md b/.changeset/silly-knives-chew.md new file mode 100644 index 0000000000..98acbbebf8 --- /dev/null +++ b/.changeset/silly-knives-chew.md @@ -0,0 +1,7 @@ +--- +"rrdom": minor +"rrweb": minor +"@rrweb/types": minor +--- + +Support top-layer components. Fixes #1381. diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 7fe6a4bd54..b65caf96cd 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -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 diff --git a/README.md b/README.md index 17e6b5591a..e73c2a8768 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/packages/rrdom/package.json b/packages/rrdom/package.json index 4595dd087e..451f346448 100644 --- a/packages/rrdom/package.json +++ b/packages/rrdom/package.json @@ -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", diff --git a/packages/rrdom/src/diff.ts b/packages/rrdom/src/diff.ts index 2727c6d312..10acb4f419 100644 --- a/packages/rrdom/src/diff.ts +++ b/packages/rrdom/src/diff.ts @@ -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); } diff --git a/packages/rrdom/src/document.ts b/packages/rrdom/src/document.ts index 70450123f1..f3f55aec1a 100644 --- a/packages/rrdom/src/document.ts +++ b/packages/rrdom/src/document.ts @@ -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; diff --git a/packages/rrdom/src/index.ts b/packages/rrdom/src/index.ts index 65ae197de8..577811766b 100644 --- a/packages/rrdom/src/index.ts +++ b/packages/rrdom/src/index.ts @@ -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: { diff --git a/packages/rrdom/test/diff/dialog.test.ts b/packages/rrdom/test/diff/dialog.test.ts new file mode 100644 index 0000000000..11a80e6ec5 --- /dev/null +++ b/packages/rrdom/test/diff/dialog.test.ts @@ -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(''); + // 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); + }); + }); +}); diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index ab1a5d4bcc..9b5e14ef65 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -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 } } diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 7ceea14096..da284ed963 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -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') { diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index e4538a827a..8117afa544 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -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; diff --git a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap index 0bb9c8f860..1a55cc7556 100644 --- a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap @@ -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`] = ` " `; +exports[`integration tests > [html file]: dialog.html 1`] = ` +" + I'm a dialog + " +`; + exports[`integration tests > [html file]: dynamic-stylesheet.html 1`] = ` " diff --git a/packages/rrweb-snapshot/test/html/dialog.html b/packages/rrweb-snapshot/test/html/dialog.html new file mode 100644 index 0000000000..2380b8fade --- /dev/null +++ b/packages/rrweb-snapshot/test/html/dialog.html @@ -0,0 +1,5 @@ + + + I'm a dialog + + diff --git a/packages/rrweb-snapshot/test/integration.test.ts b/packages/rrweb-snapshot/test/integration.test.ts index 23a19c1a29..0f122e5e55 100644 --- a/packages/rrweb-snapshot/test/integration.test.ts +++ b/packages/rrweb-snapshot/test/integration.test.ts @@ -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 { + 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']; diff --git a/packages/rrweb-snapshot/test/rebuild.test.ts b/packages/rrweb-snapshot/test/rebuild.test.ts index 2c4273f5f2..490b515f5b 100644 --- a/packages/rrweb-snapshot/test/rebuild.test.ts +++ b/packages/rrweb-snapshot/test/rebuild.test.ts @@ -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, diff --git a/packages/rrweb-snapshot/test/snapshot.test.ts b/packages/rrweb-snapshot/test/snapshot.test.ts index 7bf6141e44..c3ff607fd6 100644 --- a/packages/rrweb-snapshot/test/snapshot.test.ts +++ b/packages/rrweb-snapshot/test/snapshot.test.ts @@ -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')!; diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index a798441969..b7b9c88cef 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -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; diff --git a/packages/rrweb/src/replay/dialog/index.ts b/packages/rrweb/src/replay/dialog/index.ts new file mode 100644 index 0000000000..9fc57d4523 --- /dev/null +++ b/packages/rrweb/src/replay/dialog/index.ts @@ -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'); + } +} diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index 470436c851..4ac17df053 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -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(); 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(); 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.', diff --git a/packages/rrweb/test/events/dialog-playback.ts b/packages/rrweb/test/events/dialog-playback.ts new file mode 100644 index 0000000000..add86aca9d --- /dev/null +++ b/packages/rrweb/test/events/dialog-playback.ts @@ -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: '', 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: '', 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: '', 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; diff --git a/packages/rrweb/test/html/dialog.html b/packages/rrweb/test/html/dialog.html new file mode 100644 index 0000000000..2380b8fade --- /dev/null +++ b/packages/rrweb/test/html/dialog.html @@ -0,0 +1,5 @@ + + + I'm a dialog + + diff --git a/packages/rrweb/test/record/__snapshots__/dialog.test.ts.snap b/packages/rrweb/test/record/__snapshots__/dialog.test.ts.snap new file mode 100644 index 0000000000..03526f8c0f --- /dev/null +++ b/packages/rrweb/test/record/__snapshots__/dialog.test.ts.snap @@ -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\\": [] + } + } +]" +`; diff --git a/packages/rrweb/test/record/dialog.test.ts b/packages/rrweb/test/record/dialog.test.ts new file mode 100644 index 0000000000..ab6542b547 --- /dev/null +++ b/packages/rrweb/test/record/dialog.test.ts @@ -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, + ) => listenerHandler | undefined; + addCustomEvent(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 + }); +}); diff --git a/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-closed-dialogs-show-nothing-1-snap.png b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-closed-dialogs-show-nothing-1-snap.png new file mode 100644 index 0000000000..9fb34401f4 Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-closed-dialogs-show-nothing-1-snap.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-add-an-opened-dialog-with-show-modal-in-incremental-snapshot-alternative.png b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-add-an-opened-dialog-with-show-modal-in-incremental-snapshot-alternative.png new file mode 100644 index 0000000000..f328c34b5b Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-add-an-opened-dialog-with-show-modal-in-incremental-snapshot-alternative.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-add-an-opened-dialog-with-show-modal-in-incremental-snapshot.png b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-add-an-opened-dialog-with-show-modal-in-incremental-snapshot.png new file mode 100644 index 0000000000..f328c34b5b Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-add-an-opened-dialog-with-show-modal-in-incremental-snapshot.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-close-dialog-again-when-open-attribute-gets-removed.png b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-close-dialog-again-when-open-attribute-gets-removed.png new file mode 100644 index 0000000000..9fb34401f4 Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-close-dialog-again-when-open-attribute-gets-removed.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-open-dialog-with-show-in-full-snapshot.png b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-open-dialog-with-show-in-full-snapshot.png new file mode 100644 index 0000000000..790b97b6a4 Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-open-dialog-with-show-in-full-snapshot.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-open-dialog-with-show-modal-in-full-snapshot.png b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-open-dialog-with-show-modal-in-full-snapshot.png new file mode 100644 index 0000000000..679ab53ab6 Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-open-dialog-with-show-modal-in-full-snapshot.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-open-dialog-with-show-modal.png b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-open-dialog-with-show-modal.png new file mode 100644 index 0000000000..5fe6c04ee5 Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-open-dialog-with-show-modal.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-switch-between-show-and-show-modal.png b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-switch-between-show-and-show-modal.png new file mode 100644 index 0000000000..5fe6c04ee5 Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-switch-between-show-and-show-modal.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-switch-between-show-modal-and-show.png b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-switch-between-show-modal-and-show.png new file mode 100644 index 0000000000..a3faffd85a Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-should-switch-between-show-modal-and-show.png differ diff --git a/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-show-the-dialog-when-open-attribute-gets-added.png b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-show-the-dialog-when-open-attribute-gets-added.png new file mode 100644 index 0000000000..a3faffd85a Binary files /dev/null and b/packages/rrweb/test/replay/__image_snapshots__/dialog-test-ts-test-replay-dialog-test-ts-dialog-show-the-dialog-when-open-attribute-gets-added.png differ diff --git a/packages/rrweb/test/replay/dialog.test.ts b/packages/rrweb/test/replay/dialog.test.ts new file mode 100644 index 0000000000..7fc1ab99f2 --- /dev/null +++ b/packages/rrweb/test/replay/dialog.test.ts @@ -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'); +}); diff --git a/packages/web-extension/vite.config.ts b/packages/web-extension/vite.config.ts index 0fd0602706..2ca58feab9 100644 --- a/packages/web-extension/vite.config.ts +++ b/packages/web-extension/vite.config.ts @@ -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: [ diff --git a/turbo.json b/turbo.json index 79eafc60fb..d9f6b25a11 100644 --- a/turbo.json +++ b/turbo.json @@ -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"], diff --git a/yarn.lock b/yarn.lock index 726055fc22..cc14e9d9c4 100644 --- a/yarn.lock +++ b/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"