diff --git a/.changeset/popup-plugin.md b/.changeset/popup-plugin.md new file mode 100644 index 0000000000..8c45e96847 --- /dev/null +++ b/.changeset/popup-plugin.md @@ -0,0 +1,6 @@ +--- +'@rrweb/rrweb-plugin-popup-record': minor +'@rrweb/rrweb-plugin-popup-replay': minor +--- + +Add popup plugin pair that records native `window.alert` / `window.confirm` / `window.prompt` popups (message, prompt default value, and user response) and surfaces them during replay via an `onPopup` callback. diff --git a/docs/recipes/popup.md b/docs/recipes/popup.md new file mode 100644 index 0000000000..7fb64bcd26 --- /dev/null +++ b/docs/recipes/popup.md @@ -0,0 +1,104 @@ +# Popup Recorder and Replayer + +Native browser popups — `window.alert`, `window.confirm`, and `window.prompt` — are rendered as browser chrome outside the DOM, so rrweb does not capture them by default. This plugin records them (the message, a `prompt`'s default value, and the user's response) and lets you surface them during replay. + +### Enable Recording Popups + +You can enable using the default options like this: + +```js +import { record } from '@rrweb/record'; +import { getRecordPopupPlugin } from '@rrweb/rrweb-plugin-popup-record'; + +record({ + emit: function emit(event) { + events.push(event); + }, + // to use default record options + plugins: [getRecordPopupPlugin()], +}); +``` + +Each captured popup is emitted as a Plugin event with the following payload: + +```ts +type PopupData = { + kind: 'alert' | 'confirm' | 'prompt'; + message: string; + defaultValue?: string; // prompt's second argument, when provided + returnValue?: boolean | string | null; // confirm → boolean, prompt → string | null; omitted for alert +}; +``` + +Because native popups are synchronous and blocking, the plugin calls through to the real popup first (so the page behaves exactly as before) and records the user's response once they dismiss it. + +You can also customize the behavior like this: + +```js +import { record } from '@rrweb/record'; +import { getRecordPopupPlugin } from '@rrweb/rrweb-plugin-popup-record'; + +record({ + emit: function emit(event) { + events.push(event); + }, + // customized record options + plugins: [ + getRecordPopupPlugin({ + // only hook confirm and prompt, ignore alert + popupKinds: ['confirm', 'prompt'], + // redact sensitive content before it is recorded + maskPopupData: (data) => { + return { ...data, message: maskTextFn(data.message) }; + }, + }), + ], +}); +``` + +Use `maskPopupData` to redact anything sensitive, **including the user's response**. Return a modified copy to mask the `message`, or drop `returnValue` entirely to avoid recording what the user typed into a `prompt` or answered in a `confirm`: + +```js +getRecordPopupPlugin({ + // record that a popup happened, but never store the user's response + maskPopupData: ({ returnValue, ...rest }) => rest, +}); +``` + +All options are described below: + +| key | default | description | +| ------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| popupKinds | `['alert', 'confirm', 'prompt']` | Which native popups to hook. Override it to record only a subset. | +| maskPopupData | `(data) => data` | Transform the payload before it is emitted, e.g. to mask the `message` or drop the `returnValue` to hide the user's response. | + +## Replay Popups + +A native popup is a blocking browser dialog, so re-showing a real one during replay would freeze the player. Instead it is up to you to decide how to best replay your popup events using the `onPopup` callback (e.g. render a custom overlay, log a line, or add a timeline marker). + +```js +import rrweb from 'rrweb'; +import { getReplayPopupPlugin } from '@rrweb/rrweb-plugin-popup-replay'; + +const replayer = new rrweb.Replayer(events, { + plugins: [ + getReplayPopupPlugin({ + onPopup: (data) => { + const { kind, message, returnValue } = data; + console.log(`${kind}: ${message} -> ${returnValue}`); + }, + }), + ], +}); +replayer.play(); +``` + +Description of the replay option is as follows: + +| key | default | description | +| ------- | ----------- | ------------------------------------------------------------------ | +| onPopup | `undefined` | Called for every recorded popup as it is replayed, with `PopupData`. | + +## Technical Implementation + +This implementation records native popups by patching [`window.alert`](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert), [`window.confirm`](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm), and [`window.prompt`](https://developer.mozilla.org/en-US/docs/Web/API/Window/prompt). Since these functions are synchronous and blocking, the patched wrapper calls through to the original popup first and captures its return value (the user's response) before emitting the event. On teardown the original functions are restored. diff --git a/packages/plugins/rrweb-plugin-popup-record/README.md b/packages/plugins/rrweb-plugin-popup-record/README.md new file mode 100644 index 0000000000..331f397803 --- /dev/null +++ b/packages/plugins/rrweb-plugin-popup-record/README.md @@ -0,0 +1,212 @@ +# @rrweb/rrweb-plugin-popup-record + +Please refer to the [popup recipe](../../../docs/recipes/popup.md) on how to use this plugin. +See the [guide](../../../guide.md) for more info on rrweb. + +## Sponsors + +[Become a sponsor](https://opencollective.com/rrweb#sponsor) and get your logo on our README on Github with a link to your site. + +### Gold Sponsors 🥇 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Silver Sponsors 🥈 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Bronze Sponsors 🥉 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Backers + + + +## Core Team Members + + + + + + + + +
+ + +
Yuyz0112 +

+
+
+ + +
Yun Feng +

+
+
+ + +
eoghanmurray +

+
+
+ + +
Juice10 +
open for rrweb consulting +
+
+ +## Who's using rrweb? + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + SmartX + + + + PostHog + + + + + + + + Smart screen recording for SaaS + +
+ + Sentry + + + + Pendo + + + + Mixpanel + + + + Datadog + +
+ + Amplitude + + + + New Relic + + + + The first ever UX automation tool + + + + Remote Access & Co-Browsing + +
+ + The open source, fullstack Monitoring Platform. + + + + Comprehensive data analytics platform that empowers businesses to gain valuable insights and make data-driven decisions. + + + + Intercept, Modify, Record & Replay HTTP Requests. + + + + In-app bug reporting & customer feedback platform. + +
+ + Self-hosted website analytics with heatmaps and session recordings. + + + + Interactive product demos for small marketing teams + +
diff --git a/packages/plugins/rrweb-plugin-popup-record/package.json b/packages/plugins/rrweb-plugin-popup-record/package.json new file mode 100644 index 0000000000..d10f64127a --- /dev/null +++ b/packages/plugins/rrweb-plugin-popup-record/package.json @@ -0,0 +1,63 @@ +{ + "name": "@rrweb/rrweb-plugin-popup-record", + "version": "2.1.0", + "description": "", + "type": "module", + "main": "./dist/rrweb-plugin-popup-record.umd.cjs", + "module": "./dist/rrweb-plugin-popup-record.js", + "unpkg": "./dist/rrweb-plugin-popup-record.umd.cjs", + "jsdelivr": "./umd/rrweb-plugin-popup-record.js", + "typings": "dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/rrweb-plugin-popup-record.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/rrweb-plugin-popup-record.umd.cjs" + } + } + }, + "files": [ + "umd", + "dist", + "package.json" + ], + "scripts": { + "dev": "vite build --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "build": "yarn turbo run prepublish", + "check-types": "tsc -noEmit", + "prepublish": "tsc -noEmit && vite build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rrweb-io/rrweb.git" + }, + "keywords": [ + "rrweb" + ], + "author": "rrweb Core Team ", + "license": "MIT", + "bugs": { + "url": "https://github.com/rrweb-io/rrweb/issues" + }, + "homepage": "https://github.com/rrweb-io/rrweb#readme", + "devDependencies": { + "@rrweb/types": "^2.1.0", + "@rrweb/utils": "^2.1.0", + "rrweb": "^2.1.0", + "typescript": "^5.4.5", + "vite": "^6.0.1", + "vite-plugin-dts": "^3.9.1", + "vitest": "^1.4.0" + }, + "peerDependencies": { + "rrweb": "^2.1.0", + "@rrweb/types": "^2.1.0", + "@rrweb/utils": "^2.1.0" + } +} diff --git a/packages/plugins/rrweb-plugin-popup-record/src/index.ts b/packages/plugins/rrweb-plugin-popup-record/src/index.ts new file mode 100644 index 0000000000..0cdd992732 --- /dev/null +++ b/packages/plugins/rrweb-plugin-popup-record/src/index.ts @@ -0,0 +1,86 @@ +import type { listenerHandler, RecordPlugin, IWindow } from '@rrweb/types'; +import { patch } from '@rrweb/utils'; + +export const PLUGIN_NAME = 'rrweb/popup@1'; + +export type PopupKind = 'alert' | 'confirm' | 'prompt'; + +export type PopupData = { + kind: PopupKind; + message: string; + defaultValue?: string; + returnValue?: boolean | string | null; +}; + +export type PopupRecordOptions = { + /** + * Which native popups to hook. Defaults to all three. + */ + popupKinds?: PopupKind[]; + /** + * Redaction hook applied to the payload immediately before it is emitted. + * Return a modified copy to redact sensitive content — e.g. mask the + * `message`, or drop `returnValue` to avoid recording what the user typed + * into a `prompt` or answered in a `confirm`. + */ + maskPopupData?: (data: PopupData) => PopupData; +}; + +type PopupCallback = (data: PopupData) => void; + +const ALL_KINDS: PopupKind[] = ['alert', 'confirm', 'prompt']; + +function initPopupObserver( + cb: PopupCallback, + win: IWindow, + options?: PopupRecordOptions, +): listenerHandler { + const kinds = options?.popupKinds ?? ALL_KINDS; + const handlers: listenerHandler[] = []; + + for (const kind of kinds) { + handlers.push( + patch( + win, + kind, + (original) => { + const originalFn = original as (...args: unknown[]) => unknown; + return (...args: unknown[]) => { + // Call through to the real (blocking) popup first so we can + // capture the user's response for confirm / prompt. + const returnValue = originalFn.apply(win, args); + + let data: PopupData = { + kind, + message: String(args[0] ?? ''), + }; + if (kind === 'prompt' && args[1] != null) { + data.defaultValue = String(args[1]); + } + if (kind !== 'alert') { + data.returnValue = returnValue as boolean | string | null; + } + if (options?.maskPopupData) { + data = options.maskPopupData(data); + } + cb(data); + + return returnValue; + }; + }, + ), + ); + } + + return () => { + handlers.forEach((h) => h()); + }; +} + +export const getRecordPopupPlugin: ( + options?: PopupRecordOptions, +) => RecordPlugin = (options) => ({ + name: PLUGIN_NAME, + observer: initPopupObserver as RecordPlugin['observer'], + options: options, +}); diff --git a/packages/plugins/rrweb-plugin-popup-record/test/index.test.ts b/packages/plugins/rrweb-plugin-popup-record/test/index.test.ts new file mode 100644 index 0000000000..0188dff1f4 --- /dev/null +++ b/packages/plugins/rrweb-plugin-popup-record/test/index.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRecordPopupPlugin, PLUGIN_NAME } from '../src'; +import type { PopupData } from '../src'; + +type MockWindow = { + alert: (message?: unknown) => void; + confirm: (message?: unknown) => boolean; + prompt: (message?: unknown, defaultValue?: unknown) => string | null; +}; + +function createMockWindow(overrides: Partial = {}): MockWindow { + return { + alert: () => undefined, + confirm: () => true, + prompt: () => null, + ...overrides, + }; +} + +function run( + win: MockWindow, + options: Parameters[0] = undefined, +) { + const events: PopupData[] = []; + const plugin = getRecordPopupPlugin(options); + const cleanup = plugin.observer!( + (data: PopupData) => events.push(data), + win as never, + plugin.options, + ); + return { events, cleanup }; +} + +describe('rrweb-plugin-popup-record', () => { + it('exposes the expected plugin name', () => { + expect(PLUGIN_NAME).toBe('rrweb/popup@1'); + expect(getRecordPopupPlugin().name).toBe('rrweb/popup@1'); + }); + + it('records an alert with its message and no returnValue', () => { + const win = createMockWindow(); + const { events, cleanup } = run(win); + + win.alert('hello'); + cleanup(); + + expect(events).toEqual([{ kind: 'alert', message: 'hello' }]); + }); + + it('records a confirm with its boolean returnValue', () => { + const win = createMockWindow({ confirm: () => false }); + const { events, cleanup } = run(win); + + const result = win.confirm('are you sure?'); + cleanup(); + + expect(result).toBe(false); + expect(events).toEqual([ + { kind: 'confirm', message: 'are you sure?', returnValue: false }, + ]); + }); + + it('records a prompt with its default value and typed returnValue', () => { + const win = createMockWindow({ prompt: () => 'Neo' }); + const { events, cleanup } = run(win); + + const result = win.prompt('your name?', 'anon'); + cleanup(); + + expect(result).toBe('Neo'); + expect(events).toEqual([ + { + kind: 'prompt', + message: 'your name?', + defaultValue: 'anon', + returnValue: 'Neo', + }, + ]); + }); + + it('records a null returnValue when the user cancels a prompt', () => { + const win = createMockWindow({ prompt: () => null }); + const { events, cleanup } = run(win); + + win.prompt('your name?'); + cleanup(); + + expect(events).toEqual([ + { kind: 'prompt', message: 'your name?', returnValue: null }, + ]); + }); + + it('applies maskPopupData before emitting', () => { + const win = createMockWindow({ prompt: () => 'secret' }); + const { events, cleanup } = run(win, { + maskPopupData: (data) => ({ ...data, message: '***', returnValue: '***' }), + }); + + win.prompt('ssn?', '000'); + cleanup(); + + expect(events).toEqual([ + { + kind: 'prompt', + message: '***', + defaultValue: '000', + returnValue: '***', + }, + ]); + }); + + it('lets maskPopupData drop the return value', () => { + const win = createMockWindow({ prompt: () => 'secret' }); + const { events, cleanup } = run(win, { + maskPopupData: ({ returnValue, ...rest }) => rest, + }); + + win.prompt('name?'); + cleanup(); + + expect(events).toEqual([{ kind: 'prompt', message: 'name?' }]); + }); + + it('only patches the kinds listed in the popupKinds option', () => { + const win = createMockWindow({ confirm: () => true }); + const { events, cleanup } = run(win, { popupKinds: ['confirm'] }); + + win.alert('ignored'); + win.confirm('kept'); + win.prompt('ignored'); + cleanup(); + + expect(events).toEqual([ + { kind: 'confirm', message: 'kept', returnValue: true }, + ]); + }); + + it('restores the original popup functions on teardown', () => { + const originalAlert = vi.fn(); + const win = createMockWindow({ alert: originalAlert }); + const { cleanup } = run(win); + + expect(win.alert).not.toBe(originalAlert); + cleanup(); + expect(win.alert).toBe(originalAlert); + }); + + it('coerces a non-string message to a string', () => { + const win = createMockWindow(); + const { events, cleanup } = run(win); + + win.alert(42); + cleanup(); + + expect(events).toEqual([{ kind: 'alert', message: '42' }]); + }); +}); diff --git a/packages/plugins/rrweb-plugin-popup-record/tsconfig.json b/packages/plugins/rrweb-plugin-popup-record/tsconfig.json new file mode 100644 index 0000000000..b70866e1ab --- /dev/null +++ b/packages/plugins/rrweb-plugin-popup-record/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "exclude": ["vite.config.ts", "vitest.config.ts", "test"], + "compilerOptions": { + "rootDir": "src", + "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + }, + "references": [ + { + "path": "../../types" + }, + { + "path": "../../utils" + } + ] +} diff --git a/packages/plugins/rrweb-plugin-popup-record/vite.config.ts b/packages/plugins/rrweb-plugin-popup-record/vite.config.ts new file mode 100644 index 0000000000..28f38d4855 --- /dev/null +++ b/packages/plugins/rrweb-plugin-popup-record/vite.config.ts @@ -0,0 +1,3 @@ +import config from '../../../vite.config.default'; + +export default config('src/index.ts', 'rrwebPluginPopupRecord'); diff --git a/packages/plugins/rrweb-plugin-popup-record/vitest.config.ts b/packages/plugins/rrweb-plugin-popup-record/vitest.config.ts new file mode 100644 index 0000000000..0ea1013a23 --- /dev/null +++ b/packages/plugins/rrweb-plugin-popup-record/vitest.config.ts @@ -0,0 +1,15 @@ +/// +import { defineProject, mergeConfig } from 'vitest/config'; +import { resolve } from 'node:path'; +import configShared from '../../../vitest.config.ts'; + +export default mergeConfig( + configShared, + defineProject({ + resolve: { + alias: { + '@rrweb/utils': resolve(__dirname, '../../utils/src'), + }, + }, + }), +); diff --git a/packages/plugins/rrweb-plugin-popup-replay/README.md b/packages/plugins/rrweb-plugin-popup-replay/README.md new file mode 100644 index 0000000000..9989dac76b --- /dev/null +++ b/packages/plugins/rrweb-plugin-popup-replay/README.md @@ -0,0 +1,212 @@ +# @rrweb/rrweb-plugin-popup-replay + +Please refer to the [popup recipe](../../../docs/recipes/popup.md) on how to use this plugin. +See the [guide](../../../guide.md) for more info on rrweb. + +## Sponsors + +[Become a sponsor](https://opencollective.com/rrweb#sponsor) and get your logo on our README on Github with a link to your site. + +### Gold Sponsors 🥇 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Silver Sponsors 🥈 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Bronze Sponsors 🥉 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Backers + + + +## Core Team Members + + + + + + + + +
+ + +
Yuyz0112 +

+
+
+ + +
Yun Feng +

+
+
+ + +
eoghanmurray +

+
+
+ + +
Juice10 +
open for rrweb consulting +
+
+ +## Who's using rrweb? + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + SmartX + + + + PostHog + + + + + + + + Smart screen recording for SaaS + +
+ + Sentry + + + + Pendo + + + + Mixpanel + + + + Datadog + +
+ + Amplitude + + + + New Relic + + + + The first ever UX automation tool + + + + Remote Access & Co-Browsing + +
+ + The open source, fullstack Monitoring Platform. + + + + Comprehensive data analytics platform that empowers businesses to gain valuable insights and make data-driven decisions. + + + + Intercept, Modify, Record & Replay HTTP Requests. + + + + In-app bug reporting & customer feedback platform. + +
+ + Self-hosted website analytics with heatmaps and session recordings. + + + + Interactive product demos for small marketing teams + +
diff --git a/packages/plugins/rrweb-plugin-popup-replay/package.json b/packages/plugins/rrweb-plugin-popup-replay/package.json new file mode 100644 index 0000000000..4891befbb3 --- /dev/null +++ b/packages/plugins/rrweb-plugin-popup-replay/package.json @@ -0,0 +1,62 @@ +{ + "name": "@rrweb/rrweb-plugin-popup-replay", + "version": "2.1.0", + "description": "", + "type": "module", + "main": "./dist/rrweb-plugin-popup-replay.umd.cjs", + "module": "./dist/rrweb-plugin-popup-replay.js", + "unpkg": "./dist/rrweb-plugin-popup-replay.umd.cjs", + "jsdelivr": "./umd/rrweb-plugin-popup-replay.js", + "typings": "dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/rrweb-plugin-popup-replay.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/rrweb-plugin-popup-replay.umd.cjs" + } + } + }, + "files": [ + "umd", + "dist", + "package.json" + ], + "scripts": { + "dev": "vite build --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "build": "yarn turbo run prepublish", + "check-types": "tsc -noEmit", + "prepublish": "tsc -noEmit && vite build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rrweb-io/rrweb.git" + }, + "keywords": [ + "rrweb" + ], + "author": "rrweb Core Team ", + "license": "MIT", + "bugs": { + "url": "https://github.com/rrweb-io/rrweb/issues" + }, + "homepage": "https://github.com/rrweb-io/rrweb#readme", + "devDependencies": { + "@rrweb/rrweb-plugin-popup-record": "^2.1.0", + "@rrweb/types": "^2.1.0", + "rrweb": "^2.1.0", + "typescript": "^5.4.5", + "vite": "^6.0.1", + "vite-plugin-dts": "^3.9.1", + "vitest": "^1.4.0" + }, + "peerDependencies": { + "rrweb": "^2.1.0", + "@rrweb/types": "^2.1.0" + } +} diff --git a/packages/plugins/rrweb-plugin-popup-replay/src/index.ts b/packages/plugins/rrweb-plugin-popup-replay/src/index.ts new file mode 100644 index 0000000000..2422a05737 --- /dev/null +++ b/packages/plugins/rrweb-plugin-popup-replay/src/index.ts @@ -0,0 +1,33 @@ +import type { eventWithTime } from '@rrweb/types'; +import { EventType } from '@rrweb/types'; +import { PLUGIN_NAME } from '@rrweb/rrweb-plugin-popup-record'; +import type { PopupData } from '@rrweb/rrweb-plugin-popup-record'; + +export type { PopupData, PopupKind } from '@rrweb/rrweb-plugin-popup-record'; + +type ReplayPlugin = { + handler?: (event: eventWithTime, isSync: boolean, context: unknown) => void; +}; + +export type OnPopupData = (data: PopupData) => void; + +export type PopupReplayOptions = { + onPopup: OnPopupData; +}; + +export const getReplayPopupPlugin: ( + options: PopupReplayOptions, +) => ReplayPlugin = (options) => { + return { + handler(event: eventWithTime) { + if ( + event.type === EventType.Plugin && + event.data.plugin === PLUGIN_NAME + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const popupData = event.data.payload as PopupData; + options.onPopup(popupData); + } + }, + }; +}; diff --git a/packages/plugins/rrweb-plugin-popup-replay/test/index.test.ts b/packages/plugins/rrweb-plugin-popup-replay/test/index.test.ts new file mode 100644 index 0000000000..f5e491c4ac --- /dev/null +++ b/packages/plugins/rrweb-plugin-popup-replay/test/index.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; +import { EventType } from '@rrweb/types'; +import type { eventWithTime } from '@rrweb/types'; +import { PLUGIN_NAME } from '@rrweb/rrweb-plugin-popup-record'; +import type { PopupData } from '@rrweb/rrweb-plugin-popup-record'; +import { getReplayPopupPlugin } from '../src'; + +function pluginEvent(plugin: string, payload: unknown): eventWithTime { + return { + type: EventType.Plugin, + data: { plugin, payload }, + timestamp: 0, + } as eventWithTime; +} + +describe('rrweb-plugin-popup-replay', () => { + it('forwards a matching popup plugin event to onPopup', () => { + const onPopup = vi.fn(); + const plugin = getReplayPopupPlugin({ onPopup }); + const payload: PopupData = { + kind: 'confirm', + message: 'sure?', + returnValue: true, + }; + + plugin.handler!(pluginEvent(PLUGIN_NAME, payload), false, {} as never); + + expect(onPopup).toHaveBeenCalledTimes(1); + expect(onPopup).toHaveBeenCalledWith(payload); + }); + + it('ignores plugin events from other plugins', () => { + const onPopup = vi.fn(); + const plugin = getReplayPopupPlugin({ onPopup }); + + plugin.handler!( + pluginEvent('rrweb/network@1', { some: 'data' }), + false, + {} as never, + ); + + expect(onPopup).not.toHaveBeenCalled(); + }); + + it('ignores non-plugin events', () => { + const onPopup = vi.fn(); + const plugin = getReplayPopupPlugin({ onPopup }); + + plugin.handler!( + { type: EventType.Meta, data: {}, timestamp: 0 } as eventWithTime, + false, + {} as never, + ); + + expect(onPopup).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/rrweb-plugin-popup-replay/tsconfig.json b/packages/plugins/rrweb-plugin-popup-replay/tsconfig.json new file mode 100644 index 0000000000..294aa7b2f1 --- /dev/null +++ b/packages/plugins/rrweb-plugin-popup-replay/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "exclude": ["vite.config.ts", "vitest.config.ts", "test"], + "compilerOptions": { + "rootDir": "src", + "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + }, + "references": [ + { + "path": "../../types" + }, + { + "path": "../rrweb-plugin-popup-record" + } + ] +} diff --git a/packages/plugins/rrweb-plugin-popup-replay/vite.config.ts b/packages/plugins/rrweb-plugin-popup-replay/vite.config.ts new file mode 100644 index 0000000000..8f9e4a1860 --- /dev/null +++ b/packages/plugins/rrweb-plugin-popup-replay/vite.config.ts @@ -0,0 +1,3 @@ +import config from '../../../vite.config.default'; + +export default config('src/index.ts', 'rrwebPluginPopupReplay'); diff --git a/packages/plugins/rrweb-plugin-popup-replay/vitest.config.ts b/packages/plugins/rrweb-plugin-popup-replay/vitest.config.ts new file mode 100644 index 0000000000..143a557ea0 --- /dev/null +++ b/packages/plugins/rrweb-plugin-popup-replay/vitest.config.ts @@ -0,0 +1,20 @@ +/// +import { defineProject, mergeConfig } from 'vitest/config'; +import { resolve } from 'node:path'; +import configShared from '../../../vitest.config.ts'; + +export default mergeConfig( + configShared, + defineProject({ + resolve: { + alias: { + '@rrweb/rrweb-plugin-popup-record': resolve( + __dirname, + '../rrweb-plugin-popup-record/src', + ), + '@rrweb/types': resolve(__dirname, '../../types/src'), + '@rrweb/utils': resolve(__dirname, '../../utils/src'), + }, + }, + }), +);