diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 40c932e9b1..5c88c75d79 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -6,6 +6,7 @@ import { MaskInputOptions, SlimDOMOptions, DataURLOptions, + DialogAttributes, MaskTextFn, MaskInputFn, KeepIframeSrcFn, @@ -704,6 +705,16 @@ function serializeElementNode( delete attributes.selected; } } + + if ( + tagName === 'dialog' && + (n as HTMLDialogElement).open && + n.matches('dialog:modal') + ) { + (attributes as DialogAttributes).rr_open = 'modal'; + delete attributes.open; // prevent default `show()` behavior which blocks `showModal()` from working + } + // 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 1abfe4d6c0..3613b4cd8b 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -103,6 +103,15 @@ export type mediaAttributes = { rr_mediaVolume?: number; }; +export type DialogAttributes = + // | { + // open: ''; + // } + { + rr_open: 'modal'; + // rr_open_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 f43a3724b3..0c16d5898c 100644 --- a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap @@ -1,5 +1,121 @@ // 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\\": { + \\"rr_open\\": \\"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\\": \\"\\" + }, + \\"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 +330,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 8f4476ecb9..ac8b9b1f66 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 c71e1e8510..69dc7bf9fc 100644 --- a/packages/rrweb-snapshot/test/rebuild.test.ts +++ b/packages/rrweb-snapshot/test/rebuild.test.ts @@ -3,7 +3,8 @@ */ import * as fs from 'fs'; import * as path from 'path'; -import { describe, it, beforeEach, expect } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; + import { adaptCssForReplay, buildNodeWithSN, @@ -52,6 +53,31 @@ describe('rebuild', function () { }); }); + // this doesn't really test anything, maybe remove it? + // contemplate if rrweb-snapshot should trigger .showModal() on dialog elements on rebuild... + describe('rr_open', function () { + it('should call `show` on non-modal dialog', function () { + const node = buildNodeWithSN( + { + id: 1, + tagName: 'dialog', + type: NodeType.Element, + attributes: { + open: '', + }, + childNodes: [], + }, + { + doc: document, + mirror, + hackCss: false, + cache, + }, + ) as HTMLDialogElement; + expect(node?.open).toBe(true); + }); + }); + describe('shadowDom', function () { it('rebuild shadowRoot without siblings', function () { const node = buildNodeWithSN( diff --git a/packages/rrweb-snapshot/test/snapshot.test.ts b/packages/rrweb-snapshot/test/snapshot.test.ts index de1d79eb6d..8493791329 100644 --- a/packages/rrweb-snapshot/test/snapshot.test.ts +++ b/packages/rrweb-snapshot/test/snapshot.test.ts @@ -2,16 +2,32 @@ * @vitest-environment jsdom */ import { JSDOM } from 'jsdom'; -import { describe, it, expect } from 'vitest'; -import { +import { describe, expect, it } from 'vitest'; + +import snapshot, { + _isBlockedElement, absoluteToStylesheet, serializeNodeWithId, - _isBlockedElement, } from '../src/snapshot'; -import snapshot from '../src/snapshot'; -import { serializedNodeWithId, elementNode } from '../src/types'; +import { elementNode, serializedNodeWithId } from '../src/types'; import { Mirror } 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'; @@ -139,22 +155,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')!; @@ -184,23 +184,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')!; @@ -222,23 +205,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/test/events/dialog-playback.ts b/packages/rrweb/test/events/dialog-playback.ts new file mode 100644 index 0000000000..0d2bb53768 --- /dev/null +++ b/packages/rrweb/test/events/dialog-playback.ts @@ -0,0 +1,131 @@ +import { eventWithTime, IncrementalSource } from '@rrweb/types'; + +const events: eventWithTime[] = [ + { type: 0, data: {}, timestamp: 1900000001 }, + { type: 1, data: {}, timestamp: 1900000132 }, + { + type: 4, + data: { + href: 'http://127.0.0.1:5500/test/html/dialog.html', + width: 1600, + height: 900, + }, + timestamp: 1900000132, + }, + { + 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: '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: 1900000136, + }, + { + type: 3, + data: { + source: IncrementalSource.Mutation, + adds: [], + removes: [], + attributes: [ + { + id: 27, + attributes: { rr_open: 'modal' }, + }, + ], + }, + timestamp: 1900001500, + }, +]; + +export default events; diff --git a/packages/rrweb/test/replay/dialog.test.ts b/packages/rrweb/test/replay/dialog.test.ts new file mode 100644 index 0000000000..acc2e972aa --- /dev/null +++ b/packages/rrweb/test/replay/dialog.test.ts @@ -0,0 +1,251 @@ +import * as fs from 'fs'; +import { toMatchImageSnapshot } from 'jest-image-snapshot'; +import * as path from 'path'; +import { vi } from 'vitest'; + +import dialogPlaybackEvents from '../events/dialog-playback'; +import { + fakeGoto, + getServerURL, + hideMouseAnimation, + ISuite, + launchPuppeteer, + startServer, + waitForRAF, +} from '../utils'; + +// expect.extend({ toMatchImageSnapshot }); + +// TODO: test the following: +// == on record == +// - dialog open +// - dialog close +// - dialog open and close (switching from modal to non modal and vise versa) +// - multiple dialogs open, recording order +// == on playback == +// - dialog open +// - dialog close +// - dialog open and close (switching from modal to non modal and vise versa) +// - multiple dialogs open, playback order +// == on rrdom == +// - that the modal modes are recorded... + +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(); + + await fakeGoto(page, `${serverURL}/html/dialog.html`); + await page.evaluate(code); + await waitForRAF(page); + await hideMouseAnimation(page); + }); + + it('will seek to the correct moment', async () => { + await page.evaluate(`let events = ${JSON.stringify(dialogPlaybackEvents)}`); + await page.evaluate(` + const { Replayer } = rrweb; + window.replayer = new Replayer(events); + `); + await waitForRAF(page); + + // await page.waitForTimeout(50_000); + + // const frameImage = await page!.screenshot(); + // expect(frameImage).toMatchImageSnapshot({ + // failureThreshold: 0.05, + // failureThresholdType: 'percent', + // }); + }); + + // it('will seek to the correct moment without media interaction events', async () => { + // await page.evaluate(` + // let events = ${JSON.stringify(videoPlaybackOnFullSnapshotEvents)}; + // const { Replayer } = rrweb; + // window.replayer = new Replayer(events); + // window.replayer.pause(6500); + // `); + + // await page.waitForNetworkIdle(); + // await waitForRAF(page); + + // const frameImage = await page!.screenshot(); + // await waitForRAF(page); + // expect(frameImage).toMatchImageSnapshot({ + // failureThreshold: 0.05, + // failureThresholdType: 'percent', + // }); + // }); + + // it("will be paused when the player wasn't started yet", async () => { + // await page.evaluate(` + // let events = ${JSON.stringify(videoPlaybackEvents)}; + // const { Replayer } = rrweb; + // window.replayer = new Replayer(events); + // `); + // await page.waitForNetworkIdle(); + // await waitForRAF(page); + + // const frameImage = await page!.screenshot(); + + // await waitForRAF(page); + // expect(frameImage).toMatchImageSnapshot({ + // failureThreshold: 0.05, + // failureThresholdType: 'percent', + // }); + // }); + + // it('will play from the correct moment', async () => { + // await page.evaluate(`let events = ${JSON.stringify(videoPlaybackEvents)}`); + // await page.evaluate(` + // const { Replayer } = rrweb; + // window.replayer = new Replayer(events, { + // UNSAFE_replayCanvas: true, + // }); + // `); + // await waitForRAF(page); + // await page.evaluate(` + // window.replayer.play(6500); + // `); + // await page.waitForNetworkIdle(); + // await waitForRAF(page); + + // const frameImage = await page!.screenshot(); + // await waitForRAF(page); + // expect(frameImage).toMatchImageSnapshot({ + // failureThreshold: 0.05, + // failureThresholdType: 'percent', + // }); + + // // TODO: check to see if video is same as basic replay + // }); + + // it('should play from the start', async () => { + // await page.evaluate(`let events = ${JSON.stringify(videoPlaybackEvents)}`); + // await page.evaluate(` + // const { Replayer } = rrweb; + // window.replayer = new Replayer(events); + // window.replayer.play(); + // `); + // await waitForRAF(page); + // await page.waitForNetworkIdle(); + // await waitForRAF(page); + + // const isPlaying = await page.evaluate(` + // !document.querySelector('iframe').contentDocument.querySelector('video').paused && + // document.querySelector('iframe').contentDocument.querySelector('video').currentTime !== 0 && + // !document.querySelector('iframe').contentDocument.querySelector('video').ended; + // `); + // expect(isPlaying).toBe(true); + // }); + + // it('should play from the start without media events', async () => { + // await page.evaluate( + // `let events = ${JSON.stringify(videoPlaybackOnFullSnapshotEvents)}`, + // ); + // await page.evaluate(` + // const { Replayer } = rrweb; + // window.replayer = new Replayer(events); + // window.replayer.play(); + // `); + // await waitForRAF(page); + // await page.waitForNetworkIdle(); + // await waitForRAF(page); + + // const isPlaying = await page.evaluate(` + // !document.querySelector('iframe').contentDocument.querySelector('video').paused && + // document.querySelector('iframe').contentDocument.querySelector('video').currentTime !== 0 && + // !document.querySelector('iframe').contentDocument.querySelector('video').ended; + // `); + // expect(isPlaying).toBe(true); + // }); + + // it('should report the correct time for looping videos that have passed their total time', async () => { + // await page.evaluate( + // `let events = ${JSON.stringify(videoPlaybackOnFullSnapshotEvents)}`, + // ); + // await page.evaluate(` + // const { Replayer } = rrweb; + // window.replayer = new Replayer(events); + // `); + // await waitForRAF(page); + // await page.waitForNetworkIdle(); + // await waitForRAF(page); + // await page.evaluate(` + // window.replayer.pause(25000); // 5 seconds after the video started a new loop + // `); + // await waitForRAF(page); + + // const time = await page.evaluate(` + // document.querySelector('iframe').contentDocument.querySelector('video').currentTime; + // `); + // expect(time).toBeCloseTo(5, 0); + // }); + + // it('should set the correct time on loading videos', async () => { + // await page.evaluate( + // `let events = ${JSON.stringify(videoPlaybackOnFullSnapshotEvents)}`, + // ); + // await page.evaluate(` + // const { Replayer } = rrweb; + // window.replayer = new Replayer(events); + // window.replayer.pause(25000); // 5 seconds after the video started a new loop + // `); + // await waitForRAF(page); + // await page.waitForNetworkIdle(); + // await waitForRAF(page); + + // const time = await page.evaluate(` + // document.querySelector('iframe').contentDocument.querySelector('video').currentTime; + // `); + // expect(time).toBeCloseTo(5, 0); + // }); + + // it('should set the correct playbackRate on faster playback', async () => { + // page.on('console', (msg) => { + // console.log(msg.text()); + // }); + // await page.evaluate( + // `let events = ${JSON.stringify(videoPlaybackOnFullSnapshotEvents)}`, + // ); + // await page.evaluate(` + // const { Replayer } = rrweb; + // window.replayer = new Replayer(events, { + // speed: 8, + // }); + // window.replayer.play(); + // `); + // await waitForRAF(page); + // await page.waitForNetworkIdle(); + // await waitForRAF(page); + + // const time = await page.evaluate(` + // document.querySelector('iframe').contentDocument.querySelector('video').playbackRate; + // `); + // expect(time).toBe(8); + // }); +});