@spotify-confidence/csr-recorder 0.17.14 → 0.17.16
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 +26 -0
- package/dist/index.cjs +77 -2
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +77 -2
- package/package.json +2 -2
- package/src/engine/index.ts +1 -0
- package/src/engine/rrweb-engine.test.ts +97 -2
- package/src/engine/rrweb-engine.ts +86 -2
- package/src/recorder-bfcache.test.ts +54 -0
- package/src/recorder-routing.test.ts +2 -0
- package/src/recorder.test.ts +2 -0
- package/src/recorder.ts +14 -0
- package/src/types.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.17.16](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.15...csr-recorder-v0.17.16) (2026-08-28)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### 🐛 Bug Fixes
|
|
7
|
+
|
|
8
|
+
* 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))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ✨ New Features
|
|
12
|
+
|
|
13
|
+
* record clipboard actions ([#445](https://github.com/spotify/confidence-sdk-js/issues/445)) ([7a4aed4](https://github.com/spotify/confidence-sdk-js/commit/7a4aed4bada3b38de513f50aad4224e54488bbbb))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Dependencies
|
|
17
|
+
|
|
18
|
+
* The following workspace dependencies were updated
|
|
19
|
+
* dependencies
|
|
20
|
+
* @spotify-confidence/csr-common bumped to 0.18.8
|
|
21
|
+
|
|
22
|
+
## [0.17.15](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.14...csr-recorder-v0.17.15) (2026-08-20)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### 🐛 Bug Fixes
|
|
26
|
+
|
|
27
|
+
* **csr:** resnapshot pages restored from BFCache ([#439](https://github.com/spotify/confidence-sdk-js/issues/439)) ([5594d02](https://github.com/spotify/confidence-sdk-js/commit/5594d02b3b3141010f068e41ff0fff328115af4b))
|
|
28
|
+
|
|
3
29
|
## [0.17.14](https://github.com/spotify/confidence-sdk-js/compare/csr-recorder-v0.17.13...csr-recorder-v0.17.14) (2026-08-20)
|
|
4
30
|
|
|
5
31
|
|
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";
|
|
@@ -70,6 +70,7 @@ var Recorder = class Recorder {
|
|
|
70
70
|
onEvent;
|
|
71
71
|
state = "idle";
|
|
72
72
|
visibilityHandler = null;
|
|
73
|
+
pageShowHandler = null;
|
|
73
74
|
originalFetch = null;
|
|
74
75
|
originalXhrOpen = null;
|
|
75
76
|
originalXhrSend = null;
|
|
@@ -115,6 +116,12 @@ var Recorder = class Recorder {
|
|
|
115
116
|
};
|
|
116
117
|
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
117
118
|
}
|
|
119
|
+
if (typeof window !== "undefined") {
|
|
120
|
+
this.pageShowHandler = (event) => {
|
|
121
|
+
if (event.persisted) this.engine.takeFullSnapshot();
|
|
122
|
+
};
|
|
123
|
+
window.addEventListener("pageshow", this.pageShowHandler);
|
|
124
|
+
}
|
|
118
125
|
if (config?.captureNetworkRequests) this.patchNetwork();
|
|
119
126
|
if (config?.captureRouteChanges !== false) this.patchRouting();
|
|
120
127
|
}
|
|
@@ -301,6 +308,10 @@ var Recorder = class Recorder {
|
|
|
301
308
|
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
302
309
|
this.visibilityHandler = null;
|
|
303
310
|
}
|
|
311
|
+
if (this.pageShowHandler) {
|
|
312
|
+
window.removeEventListener("pageshow", this.pageShowHandler);
|
|
313
|
+
this.pageShowHandler = null;
|
|
314
|
+
}
|
|
304
315
|
this.restoreNetwork();
|
|
305
316
|
this.restoreRouting();
|
|
306
317
|
this.engine.stop();
|
|
@@ -11114,6 +11125,63 @@ const ALL_CONSOLE_LEVELS = [
|
|
|
11114
11125
|
"debug",
|
|
11115
11126
|
"info"
|
|
11116
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: "csr/blocked-element-labels@1",
|
|
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: "csr/clipboard@1",
|
|
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
|
+
}
|
|
11117
11185
|
/**
|
|
11118
11186
|
* rrweb does not include modifier keys in mouse-interaction events. Capture
|
|
11119
11187
|
* the native click first, then add its safe, non-text metadata to the rrweb
|
|
@@ -11166,7 +11234,11 @@ var RrwebEngine = class {
|
|
|
11166
11234
|
start(config, onEvent) {
|
|
11167
11235
|
const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
|
|
11168
11236
|
const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
|
|
11169
|
-
const plugins = [
|
|
11237
|
+
const plugins = [
|
|
11238
|
+
clickModifiersPlugin(),
|
|
11239
|
+
clipboardActionsPlugin(),
|
|
11240
|
+
blockedElementLabelsPlugin()
|
|
11241
|
+
];
|
|
11170
11242
|
const { captureConsoleLogs } = config;
|
|
11171
11243
|
if (captureConsoleLogs) {
|
|
11172
11244
|
const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
|
|
@@ -11188,6 +11260,9 @@ var RrwebEngine = class {
|
|
|
11188
11260
|
slimDOMOptions: "all"
|
|
11189
11261
|
}) ?? null;
|
|
11190
11262
|
}
|
|
11263
|
+
takeFullSnapshot() {
|
|
11264
|
+
takeFullSnapshot(true);
|
|
11265
|
+
}
|
|
11191
11266
|
stop() {
|
|
11192
11267
|
this.stopFn?.();
|
|
11193
11268
|
this.stopFn = null;
|
package/dist/index.d.cts
CHANGED
|
@@ -7,6 +7,7 @@ import { RecordingEvent } from "@spotify-confidence/csr-common";
|
|
|
7
7
|
*/
|
|
8
8
|
interface RecordingEngine {
|
|
9
9
|
start(config: RecordingConfig, onEvent: (event: RecordingEvent) => void): void;
|
|
10
|
+
takeFullSnapshot(): void;
|
|
10
11
|
stop(): void;
|
|
11
12
|
}
|
|
12
13
|
//#endregion
|
|
@@ -102,6 +103,7 @@ declare class Recorder {
|
|
|
102
103
|
private readonly onEvent;
|
|
103
104
|
private state;
|
|
104
105
|
private visibilityHandler;
|
|
106
|
+
private pageShowHandler;
|
|
105
107
|
private originalFetch;
|
|
106
108
|
private originalXhrOpen;
|
|
107
109
|
private originalXhrSend;
|
|
@@ -132,6 +134,7 @@ declare class Recorder {
|
|
|
132
134
|
declare class RrwebEngine implements RecordingEngine {
|
|
133
135
|
private stopFn;
|
|
134
136
|
start(config: RecordingConfig, onEvent: (event: RecordingEvent) => void): void;
|
|
137
|
+
takeFullSnapshot(): void;
|
|
135
138
|
stop(): void;
|
|
136
139
|
}
|
|
137
140
|
//#endregion
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { RecordingEvent } from "@spotify-confidence/csr-common";
|
|
|
7
7
|
*/
|
|
8
8
|
interface RecordingEngine {
|
|
9
9
|
start(config: RecordingConfig, onEvent: (event: RecordingEvent) => void): void;
|
|
10
|
+
takeFullSnapshot(): void;
|
|
10
11
|
stop(): void;
|
|
11
12
|
}
|
|
12
13
|
//#endregion
|
|
@@ -102,6 +103,7 @@ declare class Recorder {
|
|
|
102
103
|
private readonly onEvent;
|
|
103
104
|
private state;
|
|
104
105
|
private visibilityHandler;
|
|
106
|
+
private pageShowHandler;
|
|
105
107
|
private originalFetch;
|
|
106
108
|
private originalXhrOpen;
|
|
107
109
|
private originalXhrSend;
|
|
@@ -132,6 +134,7 @@ declare class Recorder {
|
|
|
132
134
|
declare class RrwebEngine implements RecordingEngine {
|
|
133
135
|
private stopFn;
|
|
134
136
|
start(config: RecordingConfig, onEvent: (event: RecordingEvent) => void): void;
|
|
137
|
+
takeFullSnapshot(): void;
|
|
135
138
|
stop(): void;
|
|
136
139
|
}
|
|
137
140
|
//#endregion
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { RecordingEventType } 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";
|
|
@@ -69,6 +69,7 @@ var Recorder = class Recorder {
|
|
|
69
69
|
onEvent;
|
|
70
70
|
state = "idle";
|
|
71
71
|
visibilityHandler = null;
|
|
72
|
+
pageShowHandler = null;
|
|
72
73
|
originalFetch = null;
|
|
73
74
|
originalXhrOpen = null;
|
|
74
75
|
originalXhrSend = null;
|
|
@@ -114,6 +115,12 @@ var Recorder = class Recorder {
|
|
|
114
115
|
};
|
|
115
116
|
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
116
117
|
}
|
|
118
|
+
if (typeof window !== "undefined") {
|
|
119
|
+
this.pageShowHandler = (event) => {
|
|
120
|
+
if (event.persisted) this.engine.takeFullSnapshot();
|
|
121
|
+
};
|
|
122
|
+
window.addEventListener("pageshow", this.pageShowHandler);
|
|
123
|
+
}
|
|
117
124
|
if (config?.captureNetworkRequests) this.patchNetwork();
|
|
118
125
|
if (config?.captureRouteChanges !== false) this.patchRouting();
|
|
119
126
|
}
|
|
@@ -300,6 +307,10 @@ var Recorder = class Recorder {
|
|
|
300
307
|
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
301
308
|
this.visibilityHandler = null;
|
|
302
309
|
}
|
|
310
|
+
if (this.pageShowHandler) {
|
|
311
|
+
window.removeEventListener("pageshow", this.pageShowHandler);
|
|
312
|
+
this.pageShowHandler = null;
|
|
313
|
+
}
|
|
303
314
|
this.restoreNetwork();
|
|
304
315
|
this.restoreRouting();
|
|
305
316
|
this.engine.stop();
|
|
@@ -11113,6 +11124,63 @@ const ALL_CONSOLE_LEVELS = [
|
|
|
11113
11124
|
"debug",
|
|
11114
11125
|
"info"
|
|
11115
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: "csr/blocked-element-labels@1",
|
|
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: "csr/clipboard@1",
|
|
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
|
+
}
|
|
11116
11184
|
/**
|
|
11117
11185
|
* rrweb does not include modifier keys in mouse-interaction events. Capture
|
|
11118
11186
|
* the native click first, then add its safe, non-text metadata to the rrweb
|
|
@@ -11165,7 +11233,11 @@ var RrwebEngine = class {
|
|
|
11165
11233
|
start(config, onEvent) {
|
|
11166
11234
|
const maskSelectors = config.maskSelectors ?? DEFAULT_MASK_SELECTORS;
|
|
11167
11235
|
const blockSelectors = config.blockSelectors ?? DEFAULT_BLOCK_SELECTORS;
|
|
11168
|
-
const plugins = [
|
|
11236
|
+
const plugins = [
|
|
11237
|
+
clickModifiersPlugin(),
|
|
11238
|
+
clipboardActionsPlugin(),
|
|
11239
|
+
blockedElementLabelsPlugin()
|
|
11240
|
+
];
|
|
11169
11241
|
const { captureConsoleLogs } = config;
|
|
11170
11242
|
if (captureConsoleLogs) {
|
|
11171
11243
|
const levels = captureConsoleLogs === true ? ALL_CONSOLE_LEVELS : captureConsoleLogs.levels;
|
|
@@ -11187,6 +11259,9 @@ var RrwebEngine = class {
|
|
|
11187
11259
|
slimDOMOptions: "all"
|
|
11188
11260
|
}) ?? null;
|
|
11189
11261
|
}
|
|
11262
|
+
takeFullSnapshot() {
|
|
11263
|
+
takeFullSnapshot(true);
|
|
11264
|
+
}
|
|
11190
11265
|
stop() {
|
|
11191
11266
|
this.stopFn?.();
|
|
11192
11267
|
this.stopFn = null;
|
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.
|
|
4
|
+
"version": "0.17.16",
|
|
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.
|
|
38
|
+
"@spotify-confidence/csr-common": "0.18.8",
|
|
39
39
|
"rrweb": "^2.0.1"
|
|
40
40
|
},
|
|
41
41
|
"module": "./dist/index.js",
|
package/src/engine/index.ts
CHANGED
|
@@ -4,14 +4,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
4
4
|
import { RrwebEngine } from './rrweb-engine';
|
|
5
5
|
|
|
6
6
|
const recordSpy = vi.fn().mockReturnValue(() => {});
|
|
7
|
+
const takeFullSnapshotSpy = vi.fn();
|
|
7
8
|
|
|
8
9
|
vi.mock('rrweb', async importOriginal => ({
|
|
9
10
|
...(await importOriginal<typeof import('rrweb')>()),
|
|
10
11
|
record: (opts: unknown) => recordSpy(opts),
|
|
12
|
+
takeFullSnapshot: (isCheckout: boolean) => takeFullSnapshotSpy(isCheckout),
|
|
11
13
|
}));
|
|
12
14
|
|
|
13
15
|
describe('RrwebEngine', () => {
|
|
14
|
-
beforeEach(() =>
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
recordSpy.mockClear();
|
|
18
|
+
takeFullSnapshotSpy.mockClear();
|
|
19
|
+
});
|
|
15
20
|
|
|
16
21
|
it('defaults maskAllInputs=true when maskInputs is omitted', () => {
|
|
17
22
|
new RrwebEngine().start({}, () => {});
|
|
@@ -50,7 +55,56 @@ describe('RrwebEngine', () => {
|
|
|
50
55
|
|
|
51
56
|
it('applies default blockSelector when blockSelectors is absent', () => {
|
|
52
57
|
new RrwebEngine().start({}, () => {});
|
|
53
|
-
expect(recordSpy.mock.calls[0][0].blockSelector).toBe('[data-csr-block]');
|
|
58
|
+
expect(recordSpy.mock.calls[0][0].blockSelector).toBe('[data-csr-block],video');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rebuilds blocked elements as labelled inert placeholders', () => {
|
|
62
|
+
new RrwebEngine().start({}, () => {});
|
|
63
|
+
const plugin = recordSpy.mock.calls[0][0].plugins.find(
|
|
64
|
+
({ name }: { name: string }) => name === 'csr/blocked-element-labels@1',
|
|
65
|
+
);
|
|
66
|
+
const event = {
|
|
67
|
+
type: 2,
|
|
68
|
+
timestamp: 1,
|
|
69
|
+
data: {
|
|
70
|
+
node: {
|
|
71
|
+
type: 0,
|
|
72
|
+
id: 1,
|
|
73
|
+
childNodes: [
|
|
74
|
+
{
|
|
75
|
+
type: 2,
|
|
76
|
+
id: 2,
|
|
77
|
+
tagName: 'video',
|
|
78
|
+
attributes: { rr_width: '640px', rr_height: '360px' },
|
|
79
|
+
childNodes: [],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
expect(plugin.eventProcessor(event)).toEqual({
|
|
87
|
+
...event,
|
|
88
|
+
data: {
|
|
89
|
+
node: {
|
|
90
|
+
type: 0,
|
|
91
|
+
id: 1,
|
|
92
|
+
childNodes: [
|
|
93
|
+
{
|
|
94
|
+
type: 2,
|
|
95
|
+
id: 2,
|
|
96
|
+
tagName: 'div',
|
|
97
|
+
attributes: {
|
|
98
|
+
rr_width: '640px',
|
|
99
|
+
rr_height: '360px',
|
|
100
|
+
'data-csr-blocked-element': 'video',
|
|
101
|
+
},
|
|
102
|
+
childNodes: [],
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
});
|
|
54
108
|
});
|
|
55
109
|
|
|
56
110
|
it('throttles mousemove to 100ms and records only last input value', () => {
|
|
@@ -64,6 +118,39 @@ describe('RrwebEngine', () => {
|
|
|
64
118
|
expect(recordSpy.mock.calls[0][0].slimDOMOptions).toBe('all');
|
|
65
119
|
});
|
|
66
120
|
|
|
121
|
+
it('records copy, cut, and paste actions without reading clipboard contents', () => {
|
|
122
|
+
new RrwebEngine().start({}, () => {});
|
|
123
|
+
const plugin = recordSpy.mock.calls[0][0].plugins.find(({ name }: { name: string }) => name === 'csr/clipboard@1');
|
|
124
|
+
const getId = vi.fn().mockReturnValue(42);
|
|
125
|
+
plugin.getMirror({ nodeMirror: { getId } });
|
|
126
|
+
const callback = vi.fn();
|
|
127
|
+
const removeObserver = plugin.observer(callback, window);
|
|
128
|
+
const input = document.createElement('input');
|
|
129
|
+
document.body.appendChild(input);
|
|
130
|
+
|
|
131
|
+
for (const action of ['copy', 'cut', 'paste']) {
|
|
132
|
+
const event = new Event(action, { bubbles: true });
|
|
133
|
+
Object.defineProperty(event, 'clipboardData', {
|
|
134
|
+
get: () => {
|
|
135
|
+
throw new Error('clipboard contents must not be read');
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
expect(() => input.dispatchEvent(event)).not.toThrow();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
expect(callback.mock.calls.map(([payload]) => payload)).toEqual([
|
|
142
|
+
{ action: 'copy', targetId: 42 },
|
|
143
|
+
{ action: 'cut', targetId: 42 },
|
|
144
|
+
{ action: 'paste', targetId: 42 },
|
|
145
|
+
]);
|
|
146
|
+
expect(getId).toHaveBeenCalledTimes(3);
|
|
147
|
+
|
|
148
|
+
removeObserver();
|
|
149
|
+
input.dispatchEvent(new Event('paste', { bubbles: true }));
|
|
150
|
+
expect(callback).toHaveBeenCalledTimes(3);
|
|
151
|
+
input.remove();
|
|
152
|
+
});
|
|
153
|
+
|
|
67
154
|
it('keeps native click modifiers through a browser microtask checkpoint', async () => {
|
|
68
155
|
new RrwebEngine().start({}, () => {});
|
|
69
156
|
const plugin = recordSpy.mock.calls[0][0].plugins.find(
|
|
@@ -128,4 +215,12 @@ describe('RrwebEngine', () => {
|
|
|
128
215
|
|
|
129
216
|
removeObserver();
|
|
130
217
|
});
|
|
218
|
+
|
|
219
|
+
it('takes a checkout snapshot when requested', () => {
|
|
220
|
+
const engine = new RrwebEngine();
|
|
221
|
+
|
|
222
|
+
engine.takeFullSnapshot();
|
|
223
|
+
|
|
224
|
+
expect(takeFullSnapshotSpy).toHaveBeenCalledWith(true);
|
|
225
|
+
});
|
|
131
226
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { 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
|
-
import { EventType, IncrementalSource, MouseInteractions, record, type recordOptions } from 'rrweb';
|
|
4
|
+
import { EventType, IncrementalSource, MouseInteractions, record, takeFullSnapshot, type recordOptions } from 'rrweb';
|
|
5
5
|
import { getRecordConsolePlugin } from '@rrweb/rrweb-plugin-console-record';
|
|
6
6
|
|
|
7
7
|
const ALL_CONSOLE_LEVELS: ConsoleLogLevel[] = ['log', 'warn', 'error', 'debug', 'info'];
|
|
@@ -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: 'csr/blocked-element-labels@1',
|
|
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: 'csr/clipboard@1',
|
|
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
|
|
@@ -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;
|
|
@@ -96,6 +176,10 @@ export class RrwebEngine implements RecordingEngine {
|
|
|
96
176
|
}) ?? null;
|
|
97
177
|
}
|
|
98
178
|
|
|
179
|
+
takeFullSnapshot(): void {
|
|
180
|
+
takeFullSnapshot(true);
|
|
181
|
+
}
|
|
182
|
+
|
|
99
183
|
stop(): void {
|
|
100
184
|
this.stopFn?.();
|
|
101
185
|
this.stopFn = null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { RecordingEvent } from '@spotify-confidence/csr-common';
|
|
4
|
+
import type { RecordingEngine } from './engine';
|
|
5
|
+
import { Recorder } from './recorder';
|
|
6
|
+
|
|
7
|
+
class MockEngine implements RecordingEngine {
|
|
8
|
+
takeFullSnapshot = vi.fn();
|
|
9
|
+
|
|
10
|
+
start(_config: unknown, _onEvent: (event: RecordingEvent) => void): void {}
|
|
11
|
+
|
|
12
|
+
stop(): void {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function dispatchPageShow(persisted: boolean): void {
|
|
16
|
+
const event = new Event('pageshow');
|
|
17
|
+
Object.defineProperty(event, 'persisted', { value: persisted });
|
|
18
|
+
window.dispatchEvent(event);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('Recorder BFCache restoration', () => {
|
|
22
|
+
it('takes a full snapshot when the page is restored from BFCache', () => {
|
|
23
|
+
const engine = new MockEngine();
|
|
24
|
+
const recorder = new Recorder({ engine, onEvent: vi.fn() });
|
|
25
|
+
recorder.start();
|
|
26
|
+
|
|
27
|
+
dispatchPageShow(true);
|
|
28
|
+
|
|
29
|
+
expect(engine.takeFullSnapshot).toHaveBeenCalledOnce();
|
|
30
|
+
recorder.stop();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('does not take another snapshot for a normal pageshow event', () => {
|
|
34
|
+
const engine = new MockEngine();
|
|
35
|
+
const recorder = new Recorder({ engine, onEvent: vi.fn() });
|
|
36
|
+
recorder.start();
|
|
37
|
+
|
|
38
|
+
dispatchPageShow(false);
|
|
39
|
+
|
|
40
|
+
expect(engine.takeFullSnapshot).not.toHaveBeenCalled();
|
|
41
|
+
recorder.stop();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('stops responding to pageshow events after recording stops', () => {
|
|
45
|
+
const engine = new MockEngine();
|
|
46
|
+
const recorder = new Recorder({ engine, onEvent: vi.fn() });
|
|
47
|
+
recorder.start();
|
|
48
|
+
recorder.stop();
|
|
49
|
+
|
|
50
|
+
dispatchPageShow(true);
|
|
51
|
+
|
|
52
|
+
expect(engine.takeFullSnapshot).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
});
|
package/src/recorder.test.ts
CHANGED
package/src/recorder.ts
CHANGED
|
@@ -16,6 +16,7 @@ export class Recorder {
|
|
|
16
16
|
private readonly onEvent: (event: RecordingEvent) => void;
|
|
17
17
|
private state: RecorderState = RecorderState.Idle;
|
|
18
18
|
private visibilityHandler: (() => void) | null = null;
|
|
19
|
+
private pageShowHandler: ((event: PageTransitionEvent) => void) | null = null;
|
|
19
20
|
private originalFetch: typeof globalThis.fetch | null = null;
|
|
20
21
|
private originalXhrOpen: typeof XMLHttpRequest.prototype.open | null = null;
|
|
21
22
|
private originalXhrSend: typeof XMLHttpRequest.prototype.send | null = null;
|
|
@@ -65,6 +66,15 @@ export class Recorder {
|
|
|
65
66
|
document.addEventListener('visibilitychange', this.visibilityHandler);
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
if (typeof window !== 'undefined') {
|
|
70
|
+
this.pageShowHandler = event => {
|
|
71
|
+
if (event.persisted) {
|
|
72
|
+
this.engine.takeFullSnapshot();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
window.addEventListener('pageshow', this.pageShowHandler);
|
|
76
|
+
}
|
|
77
|
+
|
|
68
78
|
if (config?.captureNetworkRequests) {
|
|
69
79
|
this.patchNetwork();
|
|
70
80
|
}
|
|
@@ -287,6 +297,10 @@ export class Recorder {
|
|
|
287
297
|
document.removeEventListener('visibilitychange', this.visibilityHandler);
|
|
288
298
|
this.visibilityHandler = null;
|
|
289
299
|
}
|
|
300
|
+
if (this.pageShowHandler) {
|
|
301
|
+
window.removeEventListener('pageshow', this.pageShowHandler);
|
|
302
|
+
this.pageShowHandler = null;
|
|
303
|
+
}
|
|
290
304
|
this.restoreNetwork();
|
|
291
305
|
this.restoreRouting();
|
|
292
306
|
this.engine.stop();
|
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
|
/**
|