@harness-fe/runtime 3.1.0 → 3.3.0
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/dist/capture.d.ts +50 -14
- package/dist/capture.js +198 -118
- package/dist/client.d.ts +7 -0
- package/dist/client.js +5 -1
- package/dist/commands.js +33 -0
- package/dist/recording.d.ts +16 -1
- package/dist/recording.js +15 -1
- package/package.json +3 -2
- package/src/capture.ts +221 -121
- package/src/client.ts +15 -1
- package/src/commands.ts +30 -0
- package/src/recording.test.ts +57 -0
- package/src/recording.ts +31 -1
- package/src/runtimeClient.e2e.test.ts +20 -2
- package/dist/fetchPatch.d.ts +0 -39
- package/dist/fetchPatch.js +0 -314
- package/dist/initiator.d.ts +0 -20
- package/dist/initiator.js +0 -34
- package/dist/storagePatch.d.ts +0 -23
- package/dist/storagePatch.js +0 -190
- package/dist/wsPatch.d.ts +0 -26
- package/dist/wsPatch.js +0 -172
- package/dist/xhrPatch.d.ts +0 -26
- package/dist/xhrPatch.js +0 -272
- package/src/fetchPatch.test.ts +0 -217
- package/src/fetchPatch.ts +0 -374
- package/src/initiator.ts +0 -40
- package/src/storagePatch.test.ts +0 -137
- package/src/storagePatch.ts +0 -213
- package/src/wsPatch.test.ts +0 -150
- package/src/wsPatch.ts +0 -198
- package/src/xhrPatch.test.ts +0 -191
- package/src/xhrPatch.ts +0 -317
package/src/capture.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* CaptureStore — thin adapter on top of `@harness-fe/sandbox`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Sandbox does the actual browser-API patching (fetch / xhr / ws / storage /
|
|
5
|
+
* navigation / console / errors / globals / indexeddb) and exposes a unified
|
|
6
|
+
* observer + interceptor surface. This module only:
|
|
7
|
+
* 1. Installs sandbox with the runtime's `onEvent` callback wired in.
|
|
8
|
+
* 2. Adapts each `SandboxEvent` into the harness-fe protocol shape
|
|
9
|
+
* (`NetworkEntry` / `WsEntry` / `StorageEntry` / `ConsoleEntry` /
|
|
10
|
+
* `ErrorEntry`) so the daemon's existing ingestion + tail tools keep
|
|
11
|
+
* working unchanged.
|
|
12
|
+
* 3. Mirrors events into bounded `RingBuffer`s so the runtime's
|
|
13
|
+
* `console.tail` / `network.tail` / `ws.tail` / `storage.tail` MCP tool
|
|
14
|
+
* handlers have something to read.
|
|
15
|
+
*
|
|
16
|
+
* Identity / interceptor / reentry safety all live in sandbox.
|
|
6
17
|
*/
|
|
7
18
|
|
|
8
19
|
import type {
|
|
@@ -11,18 +22,34 @@ import type {
|
|
|
11
22
|
NetworkEntry,
|
|
12
23
|
WsEntry,
|
|
13
24
|
StorageEntry,
|
|
25
|
+
NavigationEntry,
|
|
26
|
+
GlobalsEntry,
|
|
27
|
+
IndexedDbEntry,
|
|
14
28
|
} from '@harness-fe/protocol';
|
|
29
|
+
import {
|
|
30
|
+
installSandbox,
|
|
31
|
+
type SandboxEvent,
|
|
32
|
+
type SandboxHandle,
|
|
33
|
+
type FetchReqObservation,
|
|
34
|
+
type FetchResObservation,
|
|
35
|
+
type WsObservation,
|
|
36
|
+
type StorageObservation,
|
|
37
|
+
type ConsoleObservation,
|
|
38
|
+
type ErrorObservation,
|
|
39
|
+
type NavigationObservation,
|
|
40
|
+
type GlobalsObservation,
|
|
41
|
+
type IndexedDbObservation,
|
|
42
|
+
} from '@harness-fe/sandbox';
|
|
15
43
|
import { RingBuffer } from './buffer.js';
|
|
16
|
-
import { installFetchPatch } from './fetchPatch.js';
|
|
17
|
-
import { installXhrPatch } from './xhrPatch.js';
|
|
18
|
-
import { installWsPatch } from './wsPatch.js';
|
|
19
|
-
import { installStoragePatch } from './storagePatch.js';
|
|
20
44
|
|
|
21
45
|
const CONSOLE_CAP = 500;
|
|
22
46
|
const NETWORK_CAP = 200;
|
|
23
47
|
const ERROR_CAP = 200;
|
|
24
48
|
const WS_CAP = 200;
|
|
25
49
|
const STORAGE_CAP = 200;
|
|
50
|
+
const NAVIGATION_CAP = 100;
|
|
51
|
+
const GLOBALS_CAP = 200;
|
|
52
|
+
const INDEXEDDB_CAP = 200;
|
|
26
53
|
|
|
27
54
|
export class CaptureStore {
|
|
28
55
|
readonly console = new RingBuffer<ConsoleEntry>(CONSOLE_CAP);
|
|
@@ -30,126 +57,83 @@ export class CaptureStore {
|
|
|
30
57
|
readonly errors = new RingBuffer<ErrorEntry>(ERROR_CAP);
|
|
31
58
|
readonly ws = new RingBuffer<WsEntry>(WS_CAP);
|
|
32
59
|
readonly storage = new RingBuffer<StorageEntry>(STORAGE_CAP);
|
|
60
|
+
readonly navigation = new RingBuffer<NavigationEntry>(NAVIGATION_CAP);
|
|
61
|
+
readonly globals = new RingBuffer<GlobalsEntry>(GLOBALS_CAP);
|
|
62
|
+
readonly indexeddb = new RingBuffer<IndexedDbEntry>(INDEXEDDB_CAP);
|
|
33
63
|
|
|
34
|
-
private
|
|
35
|
-
private fetchDispose?: () => void;
|
|
36
|
-
private xhrDispose?: () => void;
|
|
37
|
-
private wsDispose?: () => void;
|
|
38
|
-
private storageDispose?: () => void;
|
|
64
|
+
private handle?: SandboxHandle;
|
|
39
65
|
|
|
40
66
|
install(
|
|
41
67
|
onEvent: (name: string, payload: unknown) => void,
|
|
42
68
|
opts: { daemonUrl?: string } = {},
|
|
43
69
|
): void {
|
|
44
|
-
if (this.
|
|
45
|
-
|
|
46
|
-
this.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
this.installStorage(onEvent);
|
|
51
|
-
this.installErrors(onEvent);
|
|
70
|
+
if (this.handle) return;
|
|
71
|
+
const selfUrls = opts.daemonUrl ? [opts.daemonUrl] : undefined;
|
|
72
|
+
this.handle = installSandbox({
|
|
73
|
+
selfUrls,
|
|
74
|
+
onEvent: (e) => this.adapt(e, onEvent),
|
|
75
|
+
});
|
|
52
76
|
}
|
|
53
77
|
|
|
54
78
|
dispose(): void {
|
|
55
|
-
this.
|
|
56
|
-
this.
|
|
57
|
-
this.xhrDispose?.();
|
|
58
|
-
this.xhrDispose = undefined;
|
|
59
|
-
this.wsDispose?.();
|
|
60
|
-
this.wsDispose = undefined;
|
|
61
|
-
this.storageDispose?.();
|
|
62
|
-
this.storageDispose = undefined;
|
|
63
|
-
this.installed = false;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
private installConsole(onEvent: (name: string, payload: unknown) => void): void {
|
|
67
|
-
const methods: Array<ConsoleEntry['level']> = ['log', 'info', 'warn', 'error', 'debug'];
|
|
68
|
-
for (const level of methods) {
|
|
69
|
-
const original = console[level].bind(console);
|
|
70
|
-
console[level] = (...args: unknown[]) => {
|
|
71
|
-
const entry: ConsoleEntry = {
|
|
72
|
-
ts: Date.now(),
|
|
73
|
-
level,
|
|
74
|
-
args: args.map(safeClone),
|
|
75
|
-
};
|
|
76
|
-
this.console.push(entry);
|
|
77
|
-
onEvent('console', entry);
|
|
78
|
-
original(...args);
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
private installFetch(onEvent: (name: string, payload: unknown) => void): void {
|
|
84
|
-
this.fetchDispose = installFetchPatch({
|
|
85
|
-
onEntry: (entry) => {
|
|
86
|
-
this.network.push(entry);
|
|
87
|
-
onEvent('network', entry);
|
|
88
|
-
},
|
|
89
|
-
});
|
|
79
|
+
this.handle?.dispose();
|
|
80
|
+
this.handle = undefined;
|
|
90
81
|
}
|
|
91
82
|
|
|
92
|
-
private
|
|
93
|
-
|
|
94
|
-
|
|
83
|
+
private adapt(e: SandboxEvent, onEvent: (name: string, payload: unknown) => void): void {
|
|
84
|
+
switch (e.source) {
|
|
85
|
+
case 'fetch':
|
|
86
|
+
case 'xhr': {
|
|
87
|
+
const entry = adaptFetchLike(e);
|
|
95
88
|
this.network.push(entry);
|
|
96
89
|
onEvent('network', entry);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
private installWs(onEvent: (name: string, payload: unknown) => void, daemonUrl?: string): void {
|
|
102
|
-
// Add the daemon URL itself to the denylist so our own bridge
|
|
103
|
-
// connection isn't intercepted (otherwise every event we send
|
|
104
|
-
// would emit a `ws send` that loops back into the outbox).
|
|
105
|
-
const extra: RegExp[] = [];
|
|
106
|
-
if (daemonUrl) {
|
|
107
|
-
const escaped = daemonUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
108
|
-
extra.push(new RegExp(`^${escaped}`));
|
|
109
|
-
}
|
|
110
|
-
this.wsDispose = installWsPatch({
|
|
111
|
-
denylist: extra.length > 0 ? [/\/__hfe\//, /sockjs-node/, ...extra] : undefined,
|
|
112
|
-
onEntry: (entry) => {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
case 'ws': {
|
|
93
|
+
const entry = adaptWs(e);
|
|
113
94
|
this.ws.push(entry);
|
|
114
95
|
onEvent('ws', entry);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
case 'storage': {
|
|
99
|
+
const entry = adaptStorage(e);
|
|
100
|
+
if (entry) {
|
|
101
|
+
this.storage.push(entry);
|
|
102
|
+
onEvent('storage', entry);
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
case 'console': {
|
|
107
|
+
const entry = adaptConsole(e);
|
|
108
|
+
this.console.push(entry);
|
|
109
|
+
onEvent('console', entry);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
case 'errors': {
|
|
113
|
+
const entry = adaptError(e);
|
|
114
|
+
this.errors.push(entry);
|
|
115
|
+
onEvent('error', entry);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
case 'navigation': {
|
|
119
|
+
const entry = adaptNavigation(e);
|
|
120
|
+
this.navigation.push(entry);
|
|
121
|
+
onEvent('navigation', entry);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
case 'globals': {
|
|
125
|
+
const entry = adaptGlobals(e);
|
|
126
|
+
this.globals.push(entry);
|
|
127
|
+
onEvent('globals', entry);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
case 'indexeddb': {
|
|
131
|
+
const entry = adaptIndexedDb(e);
|
|
132
|
+
this.indexeddb.push(entry);
|
|
133
|
+
onEvent('indexeddb', entry);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
153
137
|
}
|
|
154
138
|
}
|
|
155
139
|
|
|
@@ -159,14 +143,130 @@ export function getCaptureStore(): CaptureStore {
|
|
|
159
143
|
return captureStoreSingleton;
|
|
160
144
|
}
|
|
161
145
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
146
|
+
// ────────────────────────────────────────────────────────────────────
|
|
147
|
+
// SandboxEvent → harness-fe protocol entry adapters
|
|
148
|
+
// ────────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function adaptFetchLike(e: SandboxEvent & { source: 'fetch' | 'xhr' }): NetworkEntry {
|
|
151
|
+
const d = e.data as FetchReqObservation | FetchResObservation;
|
|
152
|
+
if (e.kind === 'req') {
|
|
153
|
+
const r = d as FetchReqObservation;
|
|
154
|
+
return {
|
|
155
|
+
ts: e.ts,
|
|
156
|
+
id: r.id,
|
|
157
|
+
phase: 'req',
|
|
158
|
+
method: r.method,
|
|
159
|
+
url: r.url,
|
|
160
|
+
requestHeaders: r.headers,
|
|
161
|
+
requestBody: r.body,
|
|
162
|
+
requestBodyTruncated: r.bodyTruncated || undefined,
|
|
163
|
+
initiator: e.initiator,
|
|
164
|
+
};
|
|
170
165
|
}
|
|
171
|
-
|
|
166
|
+
const r = d as FetchResObservation;
|
|
167
|
+
return {
|
|
168
|
+
ts: e.ts,
|
|
169
|
+
id: r.id,
|
|
170
|
+
phase: 'res',
|
|
171
|
+
method: r.method,
|
|
172
|
+
url: r.url,
|
|
173
|
+
status: r.status,
|
|
174
|
+
durationMs: r.durationMs,
|
|
175
|
+
responseHeaders: r.headers,
|
|
176
|
+
responseBody: r.body,
|
|
177
|
+
responseBodyTruncated: r.bodyTruncated || undefined,
|
|
178
|
+
error: r.error,
|
|
179
|
+
initiator: e.initiator,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function adaptWs(e: SandboxEvent & { source: 'ws' }): WsEntry {
|
|
184
|
+
const d = e.data as WsObservation;
|
|
185
|
+
return {
|
|
186
|
+
ts: e.ts,
|
|
187
|
+
id: d.id,
|
|
188
|
+
phase: d.phase,
|
|
189
|
+
url: d.url,
|
|
190
|
+
protocols: d.protocols,
|
|
191
|
+
payload: d.payload,
|
|
192
|
+
payloadTruncated: d.payloadTruncated,
|
|
193
|
+
code: d.code,
|
|
194
|
+
reason: d.reason,
|
|
195
|
+
wasClean: d.wasClean,
|
|
196
|
+
initiator: e.initiator,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function adaptStorage(e: SandboxEvent & { source: 'storage' }): StorageEntry | null {
|
|
201
|
+
const d = e.data as StorageObservation;
|
|
202
|
+
// Sandbox emits a 'get' op; harness-fe protocol storage only models set/remove/clear.
|
|
203
|
+
if (d.op === 'get') return null;
|
|
204
|
+
return {
|
|
205
|
+
ts: e.ts,
|
|
206
|
+
op: d.op,
|
|
207
|
+
which: d.which,
|
|
208
|
+
key: d.key,
|
|
209
|
+
value: d.value,
|
|
210
|
+
crossTab: d.crossTab,
|
|
211
|
+
initiator: e.initiator,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function adaptConsole(e: SandboxEvent & { source: 'console' }): ConsoleEntry {
|
|
216
|
+
const d = e.data as ConsoleObservation;
|
|
217
|
+
return {
|
|
218
|
+
ts: e.ts,
|
|
219
|
+
level: d.level,
|
|
220
|
+
args: d.args,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function adaptError(e: SandboxEvent & { source: 'errors' }): ErrorEntry {
|
|
225
|
+
const d = e.data as ErrorObservation;
|
|
226
|
+
return {
|
|
227
|
+
ts: e.ts,
|
|
228
|
+
message: d.message,
|
|
229
|
+
stack: d.stack,
|
|
230
|
+
source: d.source,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function adaptNavigation(e: SandboxEvent & { source: 'navigation' }): NavigationEntry {
|
|
235
|
+
const d = e.data as NavigationObservation;
|
|
236
|
+
return {
|
|
237
|
+
ts: e.ts,
|
|
238
|
+
kind: d.kind,
|
|
239
|
+
url: d.url,
|
|
240
|
+
state: d.state,
|
|
241
|
+
replace: d.replace,
|
|
242
|
+
initiator: e.initiator,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function adaptGlobals(e: SandboxEvent & { source: 'globals' }): GlobalsEntry {
|
|
247
|
+
const d = e.data as GlobalsObservation;
|
|
248
|
+
return {
|
|
249
|
+
ts: e.ts,
|
|
250
|
+
op: d.op,
|
|
251
|
+
key: d.key,
|
|
252
|
+
value: d.value,
|
|
253
|
+
previousValue: d.previousValue,
|
|
254
|
+
initiator: e.initiator,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function adaptIndexedDb(e: SandboxEvent & { source: 'indexeddb' }): IndexedDbEntry {
|
|
259
|
+
const d = e.data as IndexedDbObservation;
|
|
260
|
+
return {
|
|
261
|
+
ts: e.ts,
|
|
262
|
+
op: d.op,
|
|
263
|
+
db: d.db,
|
|
264
|
+
version: d.version,
|
|
265
|
+
store: d.store,
|
|
266
|
+
key: d.key,
|
|
267
|
+
value: d.value,
|
|
268
|
+
success: d.success,
|
|
269
|
+
error: d.error,
|
|
270
|
+
initiator: e.initiator,
|
|
271
|
+
};
|
|
172
272
|
}
|
package/src/client.ts
CHANGED
|
@@ -53,6 +53,13 @@ export interface ClientOptions {
|
|
|
53
53
|
* by visitorId). Propagated by HarnessScript via window.__HARNESS_FE__.userId.
|
|
54
54
|
*/
|
|
55
55
|
userId?: string;
|
|
56
|
+
/**
|
|
57
|
+
* How often (in ms) rrweb should emit a fresh FullSnapshot baseline.
|
|
58
|
+
* Defaults to 30 minutes. Set to 0 to disable periodic baselines (the
|
|
59
|
+
* recorder still emits one at start() and one per ws reconnect).
|
|
60
|
+
* See {@link RrwebRecorderOptions.checkoutEveryNms} for the trade-off.
|
|
61
|
+
*/
|
|
62
|
+
rrwebCheckoutEveryNms?: number;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
const TAB_ID_KEY = '__hfe_tab_id__';
|
|
@@ -137,7 +144,10 @@ export class RuntimeClient {
|
|
|
137
144
|
}
|
|
138
145
|
private pageLoadSent = false;
|
|
139
146
|
private readonly ctx: CommandContext = { capture: getCaptureStore() };
|
|
140
|
-
|
|
147
|
+
// Initialized in constructor (parameter property `opts` isn't readable at
|
|
148
|
+
// class-field-initializer time — field initializers run before parameter
|
|
149
|
+
// property assignment).
|
|
150
|
+
private readonly recorder: RrwebRecorder;
|
|
141
151
|
private reconnectAttempts = 0;
|
|
142
152
|
private closed = false;
|
|
143
153
|
private static readonly MAX_OUTBOX_FRAMES = 500;
|
|
@@ -159,6 +169,10 @@ export class RuntimeClient {
|
|
|
159
169
|
const inheritedVisitor = tryInheritVisitorFromParent();
|
|
160
170
|
this.visitorId = inheritedVisitor ?? getOrCreateVisitorId();
|
|
161
171
|
publishVisitorIdToWindow(this.visitorId);
|
|
172
|
+
this.recorder = new RrwebRecorder(
|
|
173
|
+
(chunk) => this.sendEvent(EVENT_NAME.RRWEB, chunk),
|
|
174
|
+
{ checkoutEveryNms: opts.rrwebCheckoutEveryNms },
|
|
175
|
+
);
|
|
162
176
|
}
|
|
163
177
|
|
|
164
178
|
|
package/src/commands.ts
CHANGED
|
@@ -423,6 +423,36 @@ export const commandHandlers: Record<string, CommandHandler> = {
|
|
|
423
423
|
return JSON.stringify({ op: e.op, which: e.which, key: e.key, value: e.value });
|
|
424
424
|
}) };
|
|
425
425
|
},
|
|
426
|
+
|
|
427
|
+
[COMMAND.NAVIGATION_TAIL]: async (raw, ctx) => {
|
|
428
|
+
const args = raw as TailArgs & { kind?: string };
|
|
429
|
+
const all = ctx.capture.navigation.tail(args.n ?? 20);
|
|
430
|
+
return { entries: filterTail(all, args, (e) => {
|
|
431
|
+
if (args.kind && e.kind !== args.kind) return undefined;
|
|
432
|
+
return JSON.stringify({ kind: e.kind, url: e.url, replace: e.replace });
|
|
433
|
+
}) };
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
[COMMAND.GLOBALS_TAIL]: async (raw, ctx) => {
|
|
437
|
+
const args = raw as TailArgs & { op?: string; key?: string };
|
|
438
|
+
const all = ctx.capture.globals.tail(args.n ?? 20);
|
|
439
|
+
return { entries: filterTail(all, args, (e) => {
|
|
440
|
+
if (args.op && e.op !== args.op) return undefined;
|
|
441
|
+
if (args.key && e.key !== args.key) return undefined;
|
|
442
|
+
return JSON.stringify({ op: e.op, key: e.key, value: e.value });
|
|
443
|
+
}) };
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
[COMMAND.INDEXEDDB_TAIL]: async (raw, ctx) => {
|
|
447
|
+
const args = raw as TailArgs & { op?: string; store?: string; db?: string };
|
|
448
|
+
const all = ctx.capture.indexeddb.tail(args.n ?? 20);
|
|
449
|
+
return { entries: filterTail(all, args, (e) => {
|
|
450
|
+
if (args.op && e.op !== args.op) return undefined;
|
|
451
|
+
if (args.store && e.store !== args.store) return undefined;
|
|
452
|
+
if (args.db && e.db !== args.db) return undefined;
|
|
453
|
+
return JSON.stringify({ op: e.op, store: e.store, key: e.key });
|
|
454
|
+
}) };
|
|
455
|
+
},
|
|
426
456
|
};
|
|
427
457
|
|
|
428
458
|
interface TailArgs {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// vi.mock is hoisted, so the spy refs need to be hoisted alongside it.
|
|
5
|
+
const { recordSpy, takeFullSnapshotSpy } = vi.hoisted(() => {
|
|
6
|
+
const takeFullSnapshotSpy = vi.fn();
|
|
7
|
+
const recordSpy = vi.fn(() => () => { /* stop noop */ });
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
|
+
(recordSpy as any).takeFullSnapshot = takeFullSnapshotSpy;
|
|
10
|
+
return { recordSpy, takeFullSnapshotSpy };
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
vi.mock('rrweb', () => ({
|
|
14
|
+
record: recordSpy,
|
|
15
|
+
EventType: { Custom: 5 },
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { RrwebRecorder } from './recording.js';
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
recordSpy.mockClear();
|
|
22
|
+
takeFullSnapshotSpy.mockClear();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('RrwebRecorder periodic baseline (checkoutEveryNms)', () => {
|
|
26
|
+
it('passes the default 30-minute interval to rrweb when no option supplied', () => {
|
|
27
|
+
const r = new RrwebRecorder(() => { /* noop */ });
|
|
28
|
+
r.start();
|
|
29
|
+
const call = recordSpy.mock.calls[0]?.[0] as { checkoutEveryNms?: number };
|
|
30
|
+
expect(call.checkoutEveryNms).toBe(30 * 60 * 1000);
|
|
31
|
+
r.stop();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('honors an explicit interval', () => {
|
|
35
|
+
const r = new RrwebRecorder(() => { /* noop */ }, { checkoutEveryNms: 60_000 });
|
|
36
|
+
r.start();
|
|
37
|
+
const call = recordSpy.mock.calls[0]?.[0] as { checkoutEveryNms?: number };
|
|
38
|
+
expect(call.checkoutEveryNms).toBe(60_000);
|
|
39
|
+
r.stop();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('disables periodic baselines when interval is 0', () => {
|
|
43
|
+
const r = new RrwebRecorder(() => { /* noop */ }, { checkoutEveryNms: 0 });
|
|
44
|
+
r.start();
|
|
45
|
+
const call = recordSpy.mock.calls[0]?.[0] as { checkoutEveryNms?: number };
|
|
46
|
+
expect(call.checkoutEveryNms).toBeUndefined();
|
|
47
|
+
r.stop();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('disables periodic baselines for negative intervals', () => {
|
|
51
|
+
const r = new RrwebRecorder(() => { /* noop */ }, { checkoutEveryNms: -1 });
|
|
52
|
+
r.start();
|
|
53
|
+
const call = recordSpy.mock.calls[0]?.[0] as { checkoutEveryNms?: number };
|
|
54
|
+
expect(call.checkoutEveryNms).toBeUndefined();
|
|
55
|
+
r.stop();
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/recording.ts
CHANGED
|
@@ -5,6 +5,29 @@ export { RRWEB_FULL_SNAPSHOT_TYPE, chunkHasFullSnapshot } from './rrweb-types.js
|
|
|
5
5
|
|
|
6
6
|
const FLUSH_MS = 5_000;
|
|
7
7
|
const MAX_EVENTS = 200;
|
|
8
|
+
// Default periodic-baseline cadence. Long-running sessions otherwise rely on
|
|
9
|
+
// a single FullSnapshot at start() + one per ws reconnect, which makes
|
|
10
|
+
// mid-session window replays expensive (rrweb has to roll forward all
|
|
11
|
+
// incremental events back to the original baseline) and leaves a window of
|
|
12
|
+
// vulnerability if the original baseline is ever evicted from the outbox.
|
|
13
|
+
// 30 min is a deliberate middle ground: ~16 baselines per 8h session at
|
|
14
|
+
// ~500KB each ≈ 8MB extra storage, which is acceptable for a dev tool.
|
|
15
|
+
const DEFAULT_CHECKOUT_EVERY_MS = 30 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
export interface RrwebRecorderOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Force rrweb to emit a fresh FullSnapshot every N milliseconds. Caps how
|
|
20
|
+
* stale the most recent baseline can be, so window replays mid-session
|
|
21
|
+
* don't have to roll forward from a baseline that's potentially hours old.
|
|
22
|
+
*
|
|
23
|
+
* Set to `0` (or a negative number) to disable periodic baselines and
|
|
24
|
+
* rely solely on the start() baseline + reconnect baselines. Useful for
|
|
25
|
+
* extremely bandwidth-constrained deployments.
|
|
26
|
+
*
|
|
27
|
+
* @default 30 * 60 * 1000 (30 minutes)
|
|
28
|
+
*/
|
|
29
|
+
checkoutEveryNms?: number;
|
|
30
|
+
}
|
|
8
31
|
|
|
9
32
|
export class RrwebRecorder {
|
|
10
33
|
private stopRecording?: () => void;
|
|
@@ -12,16 +35,23 @@ export class RrwebRecorder {
|
|
|
12
35
|
private chunkSeq = 0;
|
|
13
36
|
private buffer: unknown[] = [];
|
|
14
37
|
|
|
15
|
-
constructor(
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly onChunk: (chunk: RrwebChunkPayload) => void,
|
|
40
|
+
private readonly opts: RrwebRecorderOptions = {},
|
|
41
|
+
) {}
|
|
16
42
|
|
|
17
43
|
start(): void {
|
|
18
44
|
if (this.stopRecording) return;
|
|
45
|
+
const checkoutEveryNms = this.opts.checkoutEveryNms ?? DEFAULT_CHECKOUT_EVERY_MS;
|
|
46
|
+
// rrweb interprets `checkoutEveryNms` falsy / undefined as "off".
|
|
47
|
+
// Pass undefined when disabled so we get the native off-path.
|
|
19
48
|
this.stopRecording = record({
|
|
20
49
|
emit: (event: unknown) => this.push(event),
|
|
21
50
|
inlineImages: false,
|
|
22
51
|
recordCanvas: false,
|
|
23
52
|
collectFonts: false,
|
|
24
53
|
maskAllInputs: false,
|
|
54
|
+
checkoutEveryNms: checkoutEveryNms > 0 ? checkoutEveryNms : undefined,
|
|
25
55
|
});
|
|
26
56
|
this.flushTimer = window.setInterval(() => this.flush(), FLUSH_MS);
|
|
27
57
|
}
|
|
@@ -42,6 +42,20 @@ interface Env {
|
|
|
42
42
|
|
|
43
43
|
let env: Env | undefined;
|
|
44
44
|
|
|
45
|
+
async function rmDirWithRetry(dir: string, attempts = 5): Promise<void> {
|
|
46
|
+
for (let i = 0; i < attempts; i++) {
|
|
47
|
+
try {
|
|
48
|
+
rmSync(dir, { recursive: true, force: true });
|
|
49
|
+
return;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
52
|
+
if (code !== 'ENOTEMPTY' && code !== 'EBUSY' && code !== 'EPERM') throw err;
|
|
53
|
+
if (i === attempts - 1) throw err;
|
|
54
|
+
await new Promise((r) => setTimeout(r, 20 * (i + 1)));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
async function setup(): Promise<Env> {
|
|
46
60
|
const dir = mkdtempSync(join(tmpdir(), 'harness-rt-e2e-'));
|
|
47
61
|
const store = new JsonlStore(dir);
|
|
@@ -85,8 +99,12 @@ afterEach(async () => {
|
|
|
85
99
|
if (!env) return;
|
|
86
100
|
env.client.stop();
|
|
87
101
|
await env.bridge.stop();
|
|
88
|
-
|
|
89
|
-
|
|
102
|
+
// close() drains the async write queue — must await, else rmSync races
|
|
103
|
+
// file writes and the dir-recursive-rm trips ENOTEMPTY on Linux CI.
|
|
104
|
+
await env.store.close();
|
|
105
|
+
// Even after drain, Node's directory cache can lag by a tick on Linux —
|
|
106
|
+
// retry-with-backoff handles the residual race deterministically.
|
|
107
|
+
await rmDirWithRetry(env.dir);
|
|
90
108
|
// Reset capture singleton so subsequent tests get a clean install.
|
|
91
109
|
const cap = getCaptureStore();
|
|
92
110
|
cap.dispose();
|
package/dist/fetchPatch.d.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* fetch monkey-patch — captures URL/method/headers/body for request and
|
|
3
|
-
* response, including streaming SSE responses, without changing
|
|
4
|
-
* business-observable fetch behavior.
|
|
5
|
-
*
|
|
6
|
-
* Safety contract (do not weaken without updating the spec):
|
|
7
|
-
* 1. Identity-preserving: replacement is a named `fetch`, with
|
|
8
|
-
* defineProperty'd name/length/toString so library fingerprint checks
|
|
9
|
-
* still pass. Response / Request instances are NOT wrapped.
|
|
10
|
-
* 2. Error-isolated: capture failures are swallowed via `safeEmit`; they
|
|
11
|
-
* NEVER propagate to business code.
|
|
12
|
-
* 3. No timing or value change: the original Promise is returned to the
|
|
13
|
-
* caller unchanged. body capture reads `response.clone()` on a side
|
|
14
|
-
* branch — the business path retains an untouched stream.
|
|
15
|
-
* 4. Self-traffic guard: requests carrying `init.__hfeInternal === true`
|
|
16
|
-
* short-circuit to the original fetch. A URL denylist also skips HMR /
|
|
17
|
-
* dev-server traffic to prevent capture feedback loops.
|
|
18
|
-
* 5. Bounded memory: bodies are capped at BODY_CAP per request. SSE
|
|
19
|
-
* streams stop accumulating and `cancel()` the cloned reader once
|
|
20
|
-
* the cap is hit.
|
|
21
|
-
*
|
|
22
|
-
* The patch is idempotent (re-install is a no-op) and returns a dispose
|
|
23
|
-
* function that restores the original `window.fetch`.
|
|
24
|
-
*/
|
|
25
|
-
import type { NetworkEntry } from '@harness-fe/protocol';
|
|
26
|
-
export interface FetchPatchOptions {
|
|
27
|
-
/** Called once for each emitted record (request and response are separate calls). */
|
|
28
|
-
onEntry: (entry: NetworkEntry) => void;
|
|
29
|
-
/** Per-body byte cap. Default 256 KB. */
|
|
30
|
-
bodyCap?: number;
|
|
31
|
-
/** URL patterns to skip capture entirely. */
|
|
32
|
-
denylist?: RegExp[];
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* Install the fetch patch. Returns a dispose function that restores the
|
|
36
|
-
* original window.fetch. Safe to call multiple times (subsequent calls
|
|
37
|
-
* are no-ops while a patch is active).
|
|
38
|
-
*/
|
|
39
|
-
export declare function installFetchPatch(opts: FetchPatchOptions): () => void;
|