@spotify-confidence/csr-recorder 0.17.15 → 0.17.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.17.17](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.16...csr-recorder-v0.17.17) (2026-09-07)
4
+
5
+
6
+ ### 🐛 Bug Fixes
7
+
8
+ * stop passive recording frames extending sessions ([#449](https://github.com/spotify/confidence-sdk-js/issues/449)) ([df763a1](https://github.com/spotify/confidence-sdk-js/commit/df763a10d6f16ce5b871530ca3fe8b5c153c16d7))
9
+
10
+
11
+ ### Dependencies
12
+
13
+ * The following workspace dependencies were updated
14
+ * dependencies
15
+ * @spotify-confidence/csr-common bumped to 0.18.9
16
+
17
+ ## [0.17.16](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.15...csr-recorder-v0.17.16) (2026-08-28)
18
+
19
+
20
+ ### 🐛 Bug Fixes
21
+
22
+ * replace blocked videos with labelled placeholders ([#444](https://github.com/spotify/confidence-sdk-js/issues/444)) ([d1385b4](https://github.com/spotify/confidence-sdk-js/commit/d1385b4b19e267ba12e1a51890f085bf4746fb16))
23
+
24
+
25
+ ### ✨ New Features
26
+
27
+ * record clipboard actions ([#445](https://github.com/spotify/confidence-sdk-js/issues/445)) ([7a4aed4](https://github.com/spotify/confidence-sdk-js/commit/7a4aed4bada3b38de513f50aad4224e54488bbbb))
28
+
29
+
30
+ ### Dependencies
31
+
32
+ * The following workspace dependencies were updated
33
+ * dependencies
34
+ * @spotify-confidence/csr-common bumped to 0.18.8
35
+
3
36
  ## [0.17.15](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.14...csr-recorder-v0.17.15) (2026-08-20)
4
37
 
5
38
 
package/dist/index.cjs CHANGED
@@ -2,7 +2,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let _spotify_confidence_csr_common = require("@spotify-confidence/csr-common");
3
3
  //#region src/types.ts
4
4
  const DEFAULT_MASK_SELECTORS = ["[data-csr-mask]"];
5
- const DEFAULT_BLOCK_SELECTORS = ["[data-csr-block]"];
5
+ const DEFAULT_BLOCK_SELECTORS = ["[data-csr-block]", "video"];
6
6
  let RecorderState = /* @__PURE__ */ function(RecorderState) {
7
7
  RecorderState["Idle"] = "idle";
8
8
  RecorderState["Recording"] = "recording";
@@ -105,7 +105,7 @@ var Recorder = class Recorder {
105
105
  if (typeof document !== "undefined") {
106
106
  this.visibilityHandler = () => {
107
107
  const data = {
108
- plugin: "csr:tabVisibility",
108
+ plugin: _spotify_confidence_csr_common.RecordingPluginName.TabVisibility,
109
109
  payload: { hidden: document.hidden }
110
110
  };
111
111
  this.onEvent({
@@ -127,7 +127,7 @@ var Recorder = class Recorder {
127
127
  }
128
128
  emitNetworkRequest(payload) {
129
129
  const data = {
130
- plugin: "csr:networkRequest",
130
+ plugin: _spotify_confidence_csr_common.RecordingPluginName.NetworkRequest,
131
131
  payload
132
132
  };
133
133
  this.onEvent({
@@ -235,7 +235,7 @@ var Recorder = class Recorder {
235
235
  const paramTo = this.parameterizeRoute(to);
236
236
  if (paramFrom === paramTo) return;
237
237
  const data = {
238
- plugin: "csr:routeChange",
238
+ plugin: _spotify_confidence_csr_common.RecordingPluginName.RouteChange,
239
239
  payload: {
240
240
  from: paramFrom,
241
241
  to: paramTo,
@@ -11125,6 +11125,63 @@ const ALL_CONSOLE_LEVELS = [
11125
11125
  "debug",
11126
11126
  "info"
11127
11127
  ];
11128
+ const BLOCKED_ELEMENT_ATTRIBUTE = "data-csr-blocked-element";
11129
+ function labelBlockedElement(node) {
11130
+ if (node.type === 2 && node.tagName !== void 0 && node.attributes !== void 0 && typeof node.attributes.rr_width === "string" && typeof node.attributes.rr_height === "string") {
11131
+ node.attributes[BLOCKED_ELEMENT_ATTRIBUTE] = node.tagName;
11132
+ node.tagName = "div";
11133
+ }
11134
+ node.childNodes?.forEach(labelBlockedElement);
11135
+ }
11136
+ /**
11137
+ * rrweb strips blocked elements down to their dimensions, but retains their
11138
+ * original tag name. Rebuild them as inert divs and keep the tag name as safe
11139
+ * metadata so players can render a useful placeholder label.
11140
+ */
11141
+ function blockedElementLabelsPlugin() {
11142
+ return {
11143
+ name: _spotify_confidence_csr_common.RecordingPluginName.BlockedElementLabels,
11144
+ options: {},
11145
+ observer: () => () => {},
11146
+ eventProcessor: (event) => {
11147
+ if (event.type === EventType.FullSnapshot) labelBlockedElement(event.data.node);
11148
+ else if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.Mutation) event.data.adds.forEach((add) => labelBlockedElement(add.node));
11149
+ return event;
11150
+ }
11151
+ };
11152
+ }
11153
+ /**
11154
+ * Record clipboard actions and their DOM target without reading clipboard
11155
+ * contents. The resulting rrweb Plugin events can explain otherwise
11156
+ * surprising input changes during analysis.
11157
+ */
11158
+ function clipboardActionsPlugin() {
11159
+ let getId;
11160
+ return {
11161
+ name: _spotify_confidence_csr_common.RecordingPluginName.Clipboard,
11162
+ options: {},
11163
+ getMirror: ({ nodeMirror }) => {
11164
+ getId = (node) => nodeMirror.getId(node);
11165
+ },
11166
+ observer: (callback, win) => {
11167
+ const handlers = [
11168
+ "copy",
11169
+ "cut",
11170
+ "paste"
11171
+ ].map((action) => {
11172
+ const handler = (event) => {
11173
+ callback({
11174
+ action,
11175
+ targetId: event.target instanceof win.Node ? getId?.(event.target) ?? -1 : -1
11176
+ });
11177
+ };
11178
+ win.document.addEventListener(action, handler, true);
11179
+ return () => win.document.removeEventListener(action, handler, true);
11180
+ });
11181
+ return () => handlers.forEach((remove) => remove());
11182
+ }
11183
+ };
11184
+ }
11128
11185
  /**
11129
11186
  * rrweb does not include modifier keys in mouse-interaction events. Capture
11130
11187
  * the native click first, then add its safe, non-text metadata to the rrweb
@@ -11133,7 +11190,7 @@ const ALL_CONSOLE_LEVELS = [
11133
11190
  function clickModifiersPlugin() {
11134
11191
  let pendingClick = null;
11135
11192
  return {
11136
- name: "csr/click-modifiers@1",
11193
+ name: _spotify_confidence_csr_common.RecordingPluginName.ClickModifiers,
11137
11194
  options: {},
11138
11195
  observer: (_callback, win) => {
11139
11196
  const onClick = (event) => {
@@ -11177,7 +11234,11 @@ var RrwebEngine = class {
11177
11234
  start(config, onEvent) {
11178
11235
  const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
11179
11236
  const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
11180
- const plugins = [clickModifiersPlugin()];
11237
+ const plugins = [
11238
+ clickModifiersPlugin(),
11239
+ clipboardActionsPlugin(),
11240
+ blockedElementLabelsPlugin()
11241
+ ];
11181
11242
  const { captureConsoleLogs } = config;
11182
11243
  if (captureConsoleLogs) {
11183
11244
  const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { RecordingEventType } from "@spotify-confidence/csr-common";
1
+ import { RecordingEventType, RecordingPluginName } from "@spotify-confidence/csr-common";
2
2
  //#region src/types.ts
3
3
  const DEFAULT_MASK_SELECTORS = ["[data-csr-mask]"];
4
- const DEFAULT_BLOCK_SELECTORS = ["[data-csr-block]"];
4
+ const DEFAULT_BLOCK_SELECTORS = ["[data-csr-block]", "video"];
5
5
  let RecorderState = /* @__PURE__ */ function(RecorderState) {
6
6
  RecorderState["Idle"] = "idle";
7
7
  RecorderState["Recording"] = "recording";
@@ -104,7 +104,7 @@ var Recorder = class Recorder {
104
104
  if (typeof document !== "undefined") {
105
105
  this.visibilityHandler = () => {
106
106
  const data = {
107
- plugin: "csr:tabVisibility",
107
+ plugin: RecordingPluginName.TabVisibility,
108
108
  payload: { hidden: document.hidden }
109
109
  };
110
110
  this.onEvent({
@@ -126,7 +126,7 @@ var Recorder = class Recorder {
126
126
  }
127
127
  emitNetworkRequest(payload) {
128
128
  const data = {
129
- plugin: "csr:networkRequest",
129
+ plugin: RecordingPluginName.NetworkRequest,
130
130
  payload
131
131
  };
132
132
  this.onEvent({
@@ -234,7 +234,7 @@ var Recorder = class Recorder {
234
234
  const paramTo = this.parameterizeRoute(to);
235
235
  if (paramFrom === paramTo) return;
236
236
  const data = {
237
- plugin: "csr:routeChange",
237
+ plugin: RecordingPluginName.RouteChange,
238
238
  payload: {
239
239
  from: paramFrom,
240
240
  to: paramTo,
@@ -11124,6 +11124,63 @@ const ALL_CONSOLE_LEVELS = [
11124
11124
  "debug",
11125
11125
  "info"
11126
11126
  ];
11127
+ const BLOCKED_ELEMENT_ATTRIBUTE = "data-csr-blocked-element";
11128
+ function labelBlockedElement(node) {
11129
+ if (node.type === 2 && node.tagName !== void 0 && node.attributes !== void 0 && typeof node.attributes.rr_width === "string" && typeof node.attributes.rr_height === "string") {
11130
+ node.attributes[BLOCKED_ELEMENT_ATTRIBUTE] = node.tagName;
11131
+ node.tagName = "div";
11132
+ }
11133
+ node.childNodes?.forEach(labelBlockedElement);
11134
+ }
11135
+ /**
11136
+ * rrweb strips blocked elements down to their dimensions, but retains their
11137
+ * original tag name. Rebuild them as inert divs and keep the tag name as safe
11138
+ * metadata so players can render a useful placeholder label.
11139
+ */
11140
+ function blockedElementLabelsPlugin() {
11141
+ return {
11142
+ name: RecordingPluginName.BlockedElementLabels,
11143
+ options: {},
11144
+ observer: () => () => {},
11145
+ eventProcessor: (event) => {
11146
+ if (event.type === EventType.FullSnapshot) labelBlockedElement(event.data.node);
11147
+ else if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.Mutation) event.data.adds.forEach((add) => labelBlockedElement(add.node));
11148
+ return event;
11149
+ }
11150
+ };
11151
+ }
11152
+ /**
11153
+ * Record clipboard actions and their DOM target without reading clipboard
11154
+ * contents. The resulting rrweb Plugin events can explain otherwise
11155
+ * surprising input changes during analysis.
11156
+ */
11157
+ function clipboardActionsPlugin() {
11158
+ let getId;
11159
+ return {
11160
+ name: RecordingPluginName.Clipboard,
11161
+ options: {},
11162
+ getMirror: ({ nodeMirror }) => {
11163
+ getId = (node) => nodeMirror.getId(node);
11164
+ },
11165
+ observer: (callback, win) => {
11166
+ const handlers = [
11167
+ "copy",
11168
+ "cut",
11169
+ "paste"
11170
+ ].map((action) => {
11171
+ const handler = (event) => {
11172
+ callback({
11173
+ action,
11174
+ targetId: event.target instanceof win.Node ? getId?.(event.target) ?? -1 : -1
11175
+ });
11176
+ };
11177
+ win.document.addEventListener(action, handler, true);
11178
+ return () => win.document.removeEventListener(action, handler, true);
11179
+ });
11180
+ return () => handlers.forEach((remove) => remove());
11181
+ }
11182
+ };
11183
+ }
11127
11184
  /**
11128
11185
  * rrweb does not include modifier keys in mouse-interaction events. Capture
11129
11186
  * the native click first, then add its safe, non-text metadata to the rrweb
@@ -11132,7 +11189,7 @@ const ALL_CONSOLE_LEVELS = [
11132
11189
  function clickModifiersPlugin() {
11133
11190
  let pendingClick = null;
11134
11191
  return {
11135
- name: "csr/click-modifiers@1",
11192
+ name: RecordingPluginName.ClickModifiers,
11136
11193
  options: {},
11137
11194
  observer: (_callback, win) => {
11138
11195
  const onClick = (event) => {
@@ -11176,7 +11233,11 @@ var RrwebEngine = class {
11176
11233
  start(config, onEvent) {
11177
11234
  const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
11178
11235
  const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
11179
- const plugins = [clickModifiersPlugin()];
11236
+ const plugins = [
11237
+ clickModifiersPlugin(),
11238
+ clipboardActionsPlugin(),
11239
+ blockedElementLabelsPlugin()
11240
+ ];
11180
11241
  const { captureConsoleLogs } = config;
11181
11242
  if (captureConsoleLogs) {
11182
11243
  const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spotify-confidence/csr-recorder",
3
3
  "license": "Apache-2.0",
4
- "version": "0.17.15",
4
+ "version": "0.17.17",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/spotify/confidence-sdk-js.git",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@rrweb/rrweb-plugin-console-record": "^2.0.1",
38
- "@spotify-confidence/csr-common": "0.18.7",
38
+ "@spotify-confidence/csr-common": "0.18.9",
39
39
  "rrweb": "^2.0.1"
40
40
  },
41
41
  "module": "./dist/index.js",
@@ -1,6 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+ import { RecordingPluginName } from '@spotify-confidence/csr-common';
4
5
  import { RrwebEngine } from './rrweb-engine';
5
6
 
6
7
  const recordSpy = vi.fn().mockReturnValue(() => {});
@@ -55,7 +56,56 @@ describe('RrwebEngine', () => {
55
56
 
56
57
  it('applies default blockSelector when blockSelectors is absent', () => {
57
58
  new RrwebEngine().start({}, () => {});
58
- expect(recordSpy.mock.calls[0][0].blockSelector).toBe('[data-csr-block]');
59
+ expect(recordSpy.mock.calls[0][0].blockSelector).toBe('[data-csr-block],video');
60
+ });
61
+
62
+ it('rebuilds blocked elements as labelled inert placeholders', () => {
63
+ new RrwebEngine().start({}, () => {});
64
+ const plugin = recordSpy.mock.calls[0][0].plugins.find(
65
+ ({ name }: { name: string }) => name === RecordingPluginName.BlockedElementLabels,
66
+ );
67
+ const event = {
68
+ type: 2,
69
+ timestamp: 1,
70
+ data: {
71
+ node: {
72
+ type: 0,
73
+ id: 1,
74
+ childNodes: [
75
+ {
76
+ type: 2,
77
+ id: 2,
78
+ tagName: 'video',
79
+ attributes: { rr_width: '640px', rr_height: '360px' },
80
+ childNodes: [],
81
+ },
82
+ ],
83
+ },
84
+ },
85
+ };
86
+
87
+ expect(plugin.eventProcessor(event)).toEqual({
88
+ ...event,
89
+ data: {
90
+ node: {
91
+ type: 0,
92
+ id: 1,
93
+ childNodes: [
94
+ {
95
+ type: 2,
96
+ id: 2,
97
+ tagName: 'div',
98
+ attributes: {
99
+ rr_width: '640px',
100
+ rr_height: '360px',
101
+ 'data-csr-blocked-element': 'video',
102
+ },
103
+ childNodes: [],
104
+ },
105
+ ],
106
+ },
107
+ },
108
+ });
59
109
  });
60
110
 
61
111
  it('throttles mousemove to 100ms and records only last input value', () => {
@@ -69,10 +119,45 @@ describe('RrwebEngine', () => {
69
119
  expect(recordSpy.mock.calls[0][0].slimDOMOptions).toBe('all');
70
120
  });
71
121
 
122
+ it('records copy, cut, and paste actions without reading clipboard contents', () => {
123
+ new RrwebEngine().start({}, () => {});
124
+ const plugin = recordSpy.mock.calls[0][0].plugins.find(
125
+ ({ name }: { name: string }) => name === RecordingPluginName.Clipboard,
126
+ );
127
+ const getId = vi.fn().mockReturnValue(42);
128
+ plugin.getMirror({ nodeMirror: { getId } });
129
+ const callback = vi.fn();
130
+ const removeObserver = plugin.observer(callback, window);
131
+ const input = document.createElement('input');
132
+ document.body.appendChild(input);
133
+
134
+ for (const action of ['copy', 'cut', 'paste']) {
135
+ const event = new Event(action, { bubbles: true });
136
+ Object.defineProperty(event, 'clipboardData', {
137
+ get: () => {
138
+ throw new Error('clipboard contents must not be read');
139
+ },
140
+ });
141
+ expect(() => input.dispatchEvent(event)).not.toThrow();
142
+ }
143
+
144
+ expect(callback.mock.calls.map(([payload]) => payload)).toEqual([
145
+ { action: 'copy', targetId: 42 },
146
+ { action: 'cut', targetId: 42 },
147
+ { action: 'paste', targetId: 42 },
148
+ ]);
149
+ expect(getId).toHaveBeenCalledTimes(3);
150
+
151
+ removeObserver();
152
+ input.dispatchEvent(new Event('paste', { bubbles: true }));
153
+ expect(callback).toHaveBeenCalledTimes(3);
154
+ input.remove();
155
+ });
156
+
72
157
  it('keeps native click modifiers through a browser microtask checkpoint', async () => {
73
158
  new RrwebEngine().start({}, () => {});
74
159
  const plugin = recordSpy.mock.calls[0][0].plugins.find(
75
- ({ name }: { name: string }) => name === 'csr/click-modifiers@1',
160
+ ({ name }: { name: string }) => name === RecordingPluginName.ClickModifiers,
76
161
  );
77
162
  const removeObserver = plugin.observer(() => {}, window);
78
163
 
@@ -118,7 +203,7 @@ describe('RrwebEngine', () => {
118
203
  it('does not add stale modifiers to a later click', async () => {
119
204
  new RrwebEngine().start({}, () => {});
120
205
  const plugin = recordSpy.mock.calls[0][0].plugins.find(
121
- ({ name }: { name: string }) => name === 'csr/click-modifiers@1',
206
+ ({ name }: { name: string }) => name === RecordingPluginName.ClickModifiers,
122
207
  );
123
208
  const removeObserver = plugin.observer(() => {}, window);
124
209
  document.dispatchEvent(new MouseEvent('click', { metaKey: true }));
@@ -1,4 +1,4 @@
1
- import { type ConsoleLogLevel, type RecordingEvent } from '@spotify-confidence/csr-common';
1
+ import { RecordingPluginName, type ConsoleLogLevel, type RecordingEvent } from '@spotify-confidence/csr-common';
2
2
  import { RecordingConfig, DEFAULT_MASK_SELECTORS, DEFAULT_BLOCK_SELECTORS } from '../types';
3
3
  import { RecordingEngine } from './index';
4
4
  import { EventType, IncrementalSource, MouseInteractions, record, takeFullSnapshot, type recordOptions } from 'rrweb';
@@ -10,6 +10,86 @@ type RrwebPlugin = NonNullable<recordOptions<RecordingEvent>['plugins']>[number]
10
10
 
11
11
  type ClickModifiers = Pick<MouseEvent, 'button' | 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>;
12
12
 
13
+ const BLOCKED_ELEMENT_ATTRIBUTE = 'data-csr-blocked-element';
14
+
15
+ type SerializedNode = {
16
+ type: number;
17
+ tagName?: string;
18
+ attributes?: Record<string, unknown>;
19
+ childNodes?: SerializedNode[];
20
+ };
21
+
22
+ function labelBlockedElement(node: SerializedNode): void {
23
+ const isBlockedElement =
24
+ node.type === 2 &&
25
+ node.tagName !== undefined &&
26
+ node.attributes !== undefined &&
27
+ typeof node.attributes.rr_width === 'string' &&
28
+ typeof node.attributes.rr_height === 'string';
29
+
30
+ if (isBlockedElement) {
31
+ node.attributes![BLOCKED_ELEMENT_ATTRIBUTE] = node.tagName;
32
+ node.tagName = 'div';
33
+ }
34
+
35
+ node.childNodes?.forEach(labelBlockedElement);
36
+ }
37
+
38
+ /**
39
+ * rrweb strips blocked elements down to their dimensions, but retains their
40
+ * original tag name. Rebuild them as inert divs and keep the tag name as safe
41
+ * metadata so players can render a useful placeholder label.
42
+ */
43
+ function blockedElementLabelsPlugin(): RrwebPlugin {
44
+ return {
45
+ name: RecordingPluginName.BlockedElementLabels,
46
+ options: {},
47
+ observer: () => () => {},
48
+ eventProcessor: event => {
49
+ if (event.type === EventType.FullSnapshot) {
50
+ labelBlockedElement(event.data.node as SerializedNode);
51
+ } else if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.Mutation) {
52
+ event.data.adds.forEach(add => labelBlockedElement(add.node as SerializedNode));
53
+ }
54
+
55
+ return event;
56
+ },
57
+ };
58
+ }
59
+
60
+ type ClipboardAction = 'copy' | 'cut' | 'paste';
61
+
62
+ /**
63
+ * Record clipboard actions and their DOM target without reading clipboard
64
+ * contents. The resulting rrweb Plugin events can explain otherwise
65
+ * surprising input changes during analysis.
66
+ */
67
+ function clipboardActionsPlugin(): RrwebPlugin {
68
+ let getId: ((node: Node) => number) | undefined;
69
+
70
+ return {
71
+ name: RecordingPluginName.Clipboard,
72
+ options: {},
73
+ getMirror: ({ nodeMirror }) => {
74
+ getId = node => nodeMirror.getId(node);
75
+ },
76
+ observer: (callback, win) => {
77
+ const actions: ClipboardAction[] = ['copy', 'cut', 'paste'];
78
+ const handlers = actions.map(action => {
79
+ const handler = (event: Event) => {
80
+ const targetId = event.target instanceof win.Node ? getId?.(event.target) ?? -1 : -1;
81
+ callback({ action, targetId });
82
+ };
83
+
84
+ win.document.addEventListener(action, handler, true);
85
+ return () => win.document.removeEventListener(action, handler, true);
86
+ });
87
+
88
+ return () => handlers.forEach(remove => remove());
89
+ },
90
+ };
91
+ }
92
+
13
93
  /**
14
94
  * rrweb does not include modifier keys in mouse-interaction events. Capture
15
95
  * the native click first, then add its safe, non-text metadata to the rrweb
@@ -19,7 +99,7 @@ function clickModifiersPlugin(): RrwebPlugin {
19
99
  let pendingClick: ClickModifiers | null = null;
20
100
 
21
101
  return {
22
- name: 'csr/click-modifiers@1',
102
+ name: RecordingPluginName.ClickModifiers,
23
103
  options: {},
24
104
  observer: (_callback, win) => {
25
105
  const onClick = (event: Event) => {
@@ -69,7 +149,7 @@ export class RrwebEngine implements RecordingEngine {
69
149
  const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
70
150
  const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
71
151
 
72
- const plugins: RrwebPlugin[] = [clickModifiersPlugin()];
152
+ const plugins: RrwebPlugin[] = [clickModifiersPlugin(), clipboardActionsPlugin(), blockedElementLabelsPlugin()];
73
153
  const { captureConsoleLogs } = config;
74
154
  if (captureConsoleLogs) {
75
155
  const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
- import { RecordingEvent, RecordingEventType, type RouteChangePluginData } from '@spotify-confidence/csr-common';
3
+ import { RecordingEvent, RecordingEventType, RecordingPluginName } from '@spotify-confidence/csr-common';
4
4
  import { Recorder } from './recorder';
5
5
  import { RecordingEngine } from './engine';
6
6
 
@@ -32,10 +32,11 @@ class MockEngine implements RecordingEngine {
32
32
  }
33
33
 
34
34
  function routeChangeEvents(onEvent: ReturnType<typeof vi.fn>) {
35
- return (onEvent.mock.calls as [RecordingEvent][])
36
- .map(([e]) => e)
37
- .filter(e => e.type === RecordingEventType.Plugin && (e.data as RouteChangePluginData).plugin === 'csr:routeChange')
38
- .map(e => (e.data as RouteChangePluginData).payload);
35
+ return onEvent.mock.calls.flatMap(([event]: [RecordingEvent]) =>
36
+ event.type === RecordingEventType.Plugin && event.data.plugin === RecordingPluginName.RouteChange
37
+ ? [event.data.payload]
38
+ : [],
39
+ );
39
40
  }
40
41
 
41
42
  describe('Recorder route change capture', () => {
@@ -1,9 +1,16 @@
1
1
  import { describe, it, expect, vi, afterEach } from 'vitest';
2
- import { RecordingEvent, RecordingEventType, type NetworkRequestPluginData } from '@spotify-confidence/csr-common';
2
+ import { RecordingEvent, RecordingEventType, RecordingPluginName } from '@spotify-confidence/csr-common';
3
3
  import { Recorder } from './recorder';
4
4
  import { RecordingEngine } from './engine';
5
5
  import { RecorderState } from './types';
6
6
 
7
+ const networkRequestEvents = (onEvent: ReturnType<typeof vi.fn>) =>
8
+ onEvent.mock.calls.flatMap(([event]: [RecordingEvent]) =>
9
+ event.type === RecordingEventType.Plugin && event.data.plugin === RecordingPluginName.NetworkRequest
10
+ ? [event.data]
11
+ : [],
12
+ );
13
+
7
14
  function makeEvent(timestamp: number): RecordingEvent {
8
15
  return { type: RecordingEventType.Meta, timestamp, data: {} };
9
16
  }
@@ -130,12 +137,10 @@ describe('Recorder network request capture', () => {
130
137
 
131
138
  await globalThis.fetch('https://api.example.com/data');
132
139
 
133
- const pluginEvents = onEvent.mock.calls
134
- .map(([e]: [RecordingEvent]) => e)
135
- .filter(e => e.type === RecordingEventType.Plugin);
136
- expect(pluginEvents).toHaveLength(1);
137
- const data = pluginEvents[0].data as NetworkRequestPluginData;
138
- expect(data.plugin).toBe('csr:networkRequest');
140
+ const events = networkRequestEvents(onEvent);
141
+ expect(events).toHaveLength(1);
142
+ const data = events[0];
143
+ expect(data.plugin).toBe(RecordingPluginName.NetworkRequest);
139
144
  expect(data.payload.initiator).toBe('fetch');
140
145
  expect(data.payload.method).toBe('GET');
141
146
  expect(data.payload.url).toBe('https://api.example.com/data');
@@ -156,11 +161,9 @@ describe('Recorder network request capture', () => {
156
161
 
157
162
  await globalThis.fetch('https://api.example.com/data').catch(() => {});
158
163
 
159
- const pluginEvents = onEvent.mock.calls
160
- .map(([e]: [RecordingEvent]) => e)
161
- .filter(e => e.type === RecordingEventType.Plugin);
162
- expect(pluginEvents).toHaveLength(1);
163
- const data = pluginEvents[0].data as NetworkRequestPluginData;
164
+ const events = networkRequestEvents(onEvent);
165
+ expect(events).toHaveLength(1);
166
+ const data = events[0];
164
167
  expect(data.payload.status).toBe(0);
165
168
 
166
169
  recorder.stop();
@@ -177,10 +180,7 @@ describe('Recorder network request capture', () => {
177
180
 
178
181
  await globalThis.fetch('https://api.example.com/data', { method: 'post' });
179
182
 
180
- const pluginEvents = onEvent.mock.calls
181
- .map(([e]: [RecordingEvent]) => e)
182
- .filter(e => e.type === RecordingEventType.Plugin);
183
- const data = pluginEvents[0].data as NetworkRequestPluginData;
183
+ const data = networkRequestEvents(onEvent)[0];
184
184
  expect(data.payload.method).toBe('POST');
185
185
 
186
186
  recorder.stop();
@@ -197,10 +197,7 @@ describe('Recorder network request capture', () => {
197
197
 
198
198
  await globalThis.fetch(new Request('https://api.example.com/data', { method: 'DELETE' }));
199
199
 
200
- const pluginEvents = onEvent.mock.calls
201
- .map(([e]: [RecordingEvent]) => e)
202
- .filter(e => e.type === RecordingEventType.Plugin);
203
- const data = pluginEvents[0].data as NetworkRequestPluginData;
200
+ const data = networkRequestEvents(onEvent)[0];
204
201
  expect(data.payload.method).toBe('DELETE');
205
202
  expect(data.payload.url).toBe('https://api.example.com/data');
206
203
 
@@ -228,7 +225,7 @@ describe('Recorder network request capture', () => {
228
225
  }),
229
226
  });
230
227
 
231
- const data = onEvent.mock.calls[0][0].data as NetworkRequestPluginData;
228
+ const data = networkRequestEvents(onEvent)[0];
232
229
  expect(data.payload.graphql).toEqual({ operationName: 'GetUser' });
233
230
  expect(JSON.stringify(data.payload)).not.toContain('private-user-id');
234
231
 
package/src/recorder.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  RecordingEvent,
3
3
  RecordingEventType,
4
+ RecordingPluginName,
4
5
  type TabVisibilityPluginData,
5
6
  type NetworkRequestPluginData,
6
7
  type RouteChangePluginData,
@@ -54,7 +55,7 @@ export class Recorder {
54
55
  if (typeof document !== 'undefined') {
55
56
  this.visibilityHandler = () => {
56
57
  const data: TabVisibilityPluginData = {
57
- plugin: 'csr:tabVisibility',
58
+ plugin: RecordingPluginName.TabVisibility,
58
59
  payload: { hidden: document.hidden },
59
60
  };
60
61
  this.onEvent({
@@ -86,7 +87,7 @@ export class Recorder {
86
87
 
87
88
  private emitNetworkRequest(payload: NetworkRequestPluginData['payload']): void {
88
89
  const data: NetworkRequestPluginData = {
89
- plugin: 'csr:networkRequest',
90
+ plugin: RecordingPluginName.NetworkRequest,
90
91
  payload,
91
92
  };
92
93
  this.onEvent({
@@ -216,7 +217,7 @@ export class Recorder {
216
217
  const paramTo = this.parameterizeRoute(to);
217
218
  if (paramFrom === paramTo) return;
218
219
  const data: RouteChangePluginData = {
219
- plugin: 'csr:routeChange',
220
+ plugin: RecordingPluginName.RouteChange,
220
221
  payload: { from: paramFrom, to: paramTo, trigger },
221
222
  };
222
223
  this.onEvent({
package/src/types.ts CHANGED
@@ -6,7 +6,7 @@ export interface RecorderOptions {
6
6
  }
7
7
 
8
8
  export const DEFAULT_MASK_SELECTORS: string[] = ['[data-csr-mask]'];
9
- export const DEFAULT_BLOCK_SELECTORS: string[] = ['[data-csr-block]'];
9
+ export const DEFAULT_BLOCK_SELECTORS: string[] = ['[data-csr-block]', 'video'];
10
10
 
11
11
  export interface RecordingConfig {
12
12
  /**