@harness-fe/runtime 3.0.1 → 3.2.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 +82 -11
- package/dist/capture.js +204 -85
- package/dist/client.js +2 -1
- package/dist/commands.js +178 -3
- package/package.json +3 -2
- package/src/capture.ts +233 -87
- package/src/client.ts +5 -1
- package/src/commands.ts +193 -6
- package/src/commandsFilter.test.ts +167 -0
- package/src/commandsNetwork.e2e.test.ts +146 -0
- package/src/runtimeClient.e2e.test.ts +264 -0
- package/dist/fetchPatch.d.ts +0 -39
- package/dist/fetchPatch.js +0 -311
- package/dist/xhrPatch.d.ts +0 -26
- package/dist/xhrPatch.js +0 -269
- package/src/fetchPatch.test.ts +0 -203
- package/src/fetchPatch.ts +0 -371
- package/src/xhrPatch.test.ts +0 -191
- package/src/xhrPatch.ts +0 -314
package/src/capture.ts
CHANGED
|
@@ -1,110 +1,140 @@
|
|
|
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 {
|
|
9
20
|
ConsoleEntry,
|
|
10
21
|
ErrorEntry,
|
|
11
22
|
NetworkEntry,
|
|
23
|
+
WsEntry,
|
|
24
|
+
StorageEntry,
|
|
25
|
+
NavigationEntry,
|
|
26
|
+
GlobalsEntry,
|
|
27
|
+
IndexedDbEntry,
|
|
12
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';
|
|
13
43
|
import { RingBuffer } from './buffer.js';
|
|
14
|
-
import { installFetchPatch } from './fetchPatch.js';
|
|
15
|
-
import { installXhrPatch } from './xhrPatch.js';
|
|
16
44
|
|
|
17
45
|
const CONSOLE_CAP = 500;
|
|
18
46
|
const NETWORK_CAP = 200;
|
|
19
47
|
const ERROR_CAP = 200;
|
|
48
|
+
const WS_CAP = 200;
|
|
49
|
+
const STORAGE_CAP = 200;
|
|
50
|
+
const NAVIGATION_CAP = 100;
|
|
51
|
+
const GLOBALS_CAP = 200;
|
|
52
|
+
const INDEXEDDB_CAP = 200;
|
|
20
53
|
|
|
21
54
|
export class CaptureStore {
|
|
22
55
|
readonly console = new RingBuffer<ConsoleEntry>(CONSOLE_CAP);
|
|
23
56
|
readonly network = new RingBuffer<NetworkEntry>(NETWORK_CAP);
|
|
24
57
|
readonly errors = new RingBuffer<ErrorEntry>(ERROR_CAP);
|
|
58
|
+
readonly ws = new RingBuffer<WsEntry>(WS_CAP);
|
|
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);
|
|
63
|
+
|
|
64
|
+
private handle?: SandboxHandle;
|
|
25
65
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
this.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
this.installErrors(onEvent);
|
|
66
|
+
install(
|
|
67
|
+
onEvent: (name: string, payload: unknown) => void,
|
|
68
|
+
opts: { daemonUrl?: string } = {},
|
|
69
|
+
): void {
|
|
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
|
+
});
|
|
37
76
|
}
|
|
38
77
|
|
|
39
78
|
dispose(): void {
|
|
40
|
-
this.
|
|
41
|
-
this.
|
|
42
|
-
this.xhrDispose?.();
|
|
43
|
-
this.xhrDispose = undefined;
|
|
44
|
-
this.installed = false;
|
|
79
|
+
this.handle?.dispose();
|
|
80
|
+
this.handle = undefined;
|
|
45
81
|
}
|
|
46
82
|
|
|
47
|
-
private
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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);
|
|
88
|
+
this.network.push(entry);
|
|
89
|
+
onEvent('network', entry);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
case 'ws': {
|
|
93
|
+
const entry = adaptWs(e);
|
|
94
|
+
this.ws.push(entry);
|
|
95
|
+
onEvent('ws', entry);
|
|
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);
|
|
57
108
|
this.console.push(entry);
|
|
58
109
|
onEvent('console', entry);
|
|
59
|
-
|
|
60
|
-
}
|
|
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
|
+
}
|
|
61
136
|
}
|
|
62
137
|
}
|
|
63
|
-
|
|
64
|
-
private installFetch(onEvent: (name: string, payload: unknown) => void): void {
|
|
65
|
-
this.fetchDispose = installFetchPatch({
|
|
66
|
-
onEntry: (entry) => {
|
|
67
|
-
this.network.push(entry);
|
|
68
|
-
onEvent('network', entry);
|
|
69
|
-
},
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
private installXhr(onEvent: (name: string, payload: unknown) => void): void {
|
|
74
|
-
this.xhrDispose = installXhrPatch({
|
|
75
|
-
onEntry: (entry) => {
|
|
76
|
-
this.network.push(entry);
|
|
77
|
-
onEvent('network', entry);
|
|
78
|
-
},
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
private installErrors(onEvent: (name: string, payload: unknown) => void): void {
|
|
83
|
-
if (typeof window === 'undefined') return;
|
|
84
|
-
window.addEventListener('error', (e: ErrorEvent) => {
|
|
85
|
-
const entry: ErrorEntry = {
|
|
86
|
-
ts: Date.now(),
|
|
87
|
-
message: e.message,
|
|
88
|
-
stack: e.error?.stack,
|
|
89
|
-
source: e.filename ? `${e.filename}:${e.lineno}:${e.colno}` : undefined,
|
|
90
|
-
};
|
|
91
|
-
this.errors.push(entry);
|
|
92
|
-
onEvent('error', entry);
|
|
93
|
-
});
|
|
94
|
-
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
|
|
95
|
-
const reason: unknown = e.reason;
|
|
96
|
-
const message =
|
|
97
|
-
reason instanceof Error ? reason.message : String(reason ?? 'unhandled rejection');
|
|
98
|
-
const stack = reason instanceof Error ? reason.stack : undefined;
|
|
99
|
-
const entry: ErrorEntry = {
|
|
100
|
-
ts: Date.now(),
|
|
101
|
-
message: `Unhandled: ${message}`,
|
|
102
|
-
stack,
|
|
103
|
-
};
|
|
104
|
-
this.errors.push(entry);
|
|
105
|
-
onEvent('error', entry);
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
138
|
}
|
|
109
139
|
|
|
110
140
|
let captureStoreSingleton: CaptureStore | undefined;
|
|
@@ -113,14 +143,130 @@ export function getCaptureStore(): CaptureStore {
|
|
|
113
143
|
return captureStoreSingleton;
|
|
114
144
|
}
|
|
115
145
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
+
};
|
|
124
165
|
}
|
|
125
|
-
|
|
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
|
+
};
|
|
126
272
|
}
|
package/src/client.ts
CHANGED
|
@@ -163,7 +163,11 @@ export class RuntimeClient {
|
|
|
163
163
|
|
|
164
164
|
|
|
165
165
|
start(): void {
|
|
166
|
-
this.
|
|
166
|
+
const daemonUrl = this.opts.mcpUrl ?? `ws://127.0.0.1:${DEFAULT_WS_PORT}`;
|
|
167
|
+
this.ctx.capture.install(
|
|
168
|
+
(name, payload) => this.sendEvent(name, payload),
|
|
169
|
+
{ daemonUrl },
|
|
170
|
+
);
|
|
167
171
|
this.recorder.start();
|
|
168
172
|
this.connect();
|
|
169
173
|
}
|
package/src/commands.ts
CHANGED
|
@@ -305,21 +305,208 @@ export const commandHandlers: Record<string, CommandHandler> = {
|
|
|
305
305
|
},
|
|
306
306
|
|
|
307
307
|
[COMMAND.CONSOLE_TAIL]: async (raw, ctx) => {
|
|
308
|
-
const args = raw as {
|
|
309
|
-
|
|
308
|
+
const args = raw as TailArgs & { level?: string };
|
|
309
|
+
const all = ctx.capture.console.tail(args.n ?? 20);
|
|
310
|
+
return { entries: filterTail(all, args, (e) => {
|
|
311
|
+
if (args.level && e.level !== args.level) return undefined;
|
|
312
|
+
return JSON.stringify({ level: e.level, args: e.args });
|
|
313
|
+
}) };
|
|
310
314
|
},
|
|
311
315
|
|
|
312
316
|
[COMMAND.NETWORK_TAIL]: async (raw, ctx) => {
|
|
313
|
-
const args = raw as
|
|
314
|
-
|
|
317
|
+
const args = raw as TailArgs & {
|
|
318
|
+
urlContains?: string;
|
|
319
|
+
method?: string;
|
|
320
|
+
statusCode?: number;
|
|
321
|
+
};
|
|
322
|
+
const all = ctx.capture.network.tail(args.n ?? 20);
|
|
323
|
+
return { entries: filterTail(all, args, (e) => {
|
|
324
|
+
if (args.urlContains && !e.url.includes(args.urlContains)) return undefined;
|
|
325
|
+
if (args.method && e.method.toUpperCase() !== args.method.toUpperCase()) return undefined;
|
|
326
|
+
if (args.statusCode !== undefined && e.status !== args.statusCode) return undefined;
|
|
327
|
+
return JSON.stringify({ url: e.url, method: e.method, requestBody: e.requestBody, responseBody: e.responseBody });
|
|
328
|
+
}) };
|
|
315
329
|
},
|
|
316
330
|
|
|
317
331
|
[COMMAND.ERRORS_TAIL]: async (raw, ctx) => {
|
|
318
|
-
const args = raw as
|
|
319
|
-
|
|
332
|
+
const args = raw as TailArgs;
|
|
333
|
+
const all = ctx.capture.errors.tail(args.n ?? 20);
|
|
334
|
+
return { entries: filterTail(all, args, (e) =>
|
|
335
|
+
JSON.stringify({ message: e.message, stack: e.stack, source: e.source }),
|
|
336
|
+
) };
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
[COMMAND.WS_TAIL]: async (raw, ctx) => {
|
|
340
|
+
const args = raw as TailArgs & { phase?: string };
|
|
341
|
+
const all = ctx.capture.ws.tail(args.n ?? 20);
|
|
342
|
+
return { entries: filterTail(all, args, (e) => {
|
|
343
|
+
if (args.phase && e.phase !== args.phase) return undefined;
|
|
344
|
+
return JSON.stringify({ url: e.url, payload: e.payload, reason: e.reason });
|
|
345
|
+
}) };
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
[COMMAND.NETWORK_WAIT_FOR]: async (raw, ctx) => {
|
|
349
|
+
const args = raw as {
|
|
350
|
+
urlContains?: string;
|
|
351
|
+
urlRegex?: string;
|
|
352
|
+
method?: string;
|
|
353
|
+
statusCode?: number;
|
|
354
|
+
timeoutMs?: number;
|
|
355
|
+
};
|
|
356
|
+
const timeoutMs = args.timeoutMs ?? 10_000;
|
|
357
|
+
const deadline = Date.now() + timeoutMs;
|
|
358
|
+
const regex = args.urlRegex ? safeRegex(args.urlRegex) : undefined;
|
|
359
|
+
// Anchor on the existing buffer head so we only consider new requests
|
|
360
|
+
// (otherwise an old matching entry would resolve immediately).
|
|
361
|
+
const baselineLen = ctx.capture.network.size();
|
|
362
|
+
while (Date.now() < deadline) {
|
|
363
|
+
const all = ctx.capture.network.tail(500);
|
|
364
|
+
const newOnes = all.slice(Math.max(0, all.length - (ctx.capture.network.size() - baselineLen)));
|
|
365
|
+
for (const e of newOnes) {
|
|
366
|
+
if (args.urlContains && !e.url.includes(args.urlContains)) continue;
|
|
367
|
+
if (regex && !regex.test(e.url)) continue;
|
|
368
|
+
if (args.method && e.method.toUpperCase() !== args.method.toUpperCase()) continue;
|
|
369
|
+
if (args.statusCode !== undefined && e.status !== args.statusCode) continue;
|
|
370
|
+
return { ok: true, entry: e, after: Date.now() };
|
|
371
|
+
}
|
|
372
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
373
|
+
}
|
|
374
|
+
throw new Error(`network.wait_for: no matching request within ${timeoutMs}ms`);
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
[COMMAND.NETWORK_WAIT_FOR_IDLE]: async (raw, ctx) => {
|
|
378
|
+
const args = raw as { idleMs?: number; timeoutMs?: number };
|
|
379
|
+
const idleMs = args.idleMs ?? 500;
|
|
380
|
+
const timeoutMs = args.timeoutMs ?? 10_000;
|
|
381
|
+
const deadline = Date.now() + timeoutMs;
|
|
382
|
+
let lastSize = ctx.capture.network.size();
|
|
383
|
+
let stableSince = Date.now();
|
|
384
|
+
while (Date.now() < deadline) {
|
|
385
|
+
const currentSize = ctx.capture.network.size();
|
|
386
|
+
if (currentSize !== lastSize) {
|
|
387
|
+
lastSize = currentSize;
|
|
388
|
+
stableSince = Date.now();
|
|
389
|
+
} else if (Date.now() - stableSince >= idleMs) {
|
|
390
|
+
return { ok: true, idleFor: Date.now() - stableSince, after: Date.now() };
|
|
391
|
+
}
|
|
392
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
393
|
+
}
|
|
394
|
+
throw new Error(`network.wait_for_idle: never quiet for ${idleMs}ms within ${timeoutMs}ms`);
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
[COMMAND.NETWORK_GET]: async (raw, ctx) => {
|
|
398
|
+
const args = raw as { reqId: string };
|
|
399
|
+
// Return both req + res entries for this id (one or both may exist).
|
|
400
|
+
const all = ctx.capture.network.tail(200);
|
|
401
|
+
const matches = all.filter((e) => e.id === args.reqId);
|
|
402
|
+
return { entries: matches, found: matches.length > 0 };
|
|
403
|
+
},
|
|
404
|
+
|
|
405
|
+
[COMMAND.WS_GET]: async (raw, ctx) => {
|
|
406
|
+
const args = raw as { wsId: string };
|
|
407
|
+
const all = ctx.capture.ws.tail(200);
|
|
408
|
+
const matches = all.filter((e) => e.id === args.wsId);
|
|
409
|
+
return { entries: matches, found: matches.length > 0 };
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
[COMMAND.STORAGE_TAIL]: async (raw, ctx) => {
|
|
413
|
+
const args = raw as TailArgs & {
|
|
414
|
+
which?: string;
|
|
415
|
+
op?: string;
|
|
416
|
+
key?: string;
|
|
417
|
+
};
|
|
418
|
+
const all = ctx.capture.storage.tail(args.n ?? 20);
|
|
419
|
+
return { entries: filterTail(all, args, (e) => {
|
|
420
|
+
if (args.which && e.which !== args.which) return undefined;
|
|
421
|
+
if (args.op && e.op !== args.op) return undefined;
|
|
422
|
+
if (args.key && e.key !== args.key) return undefined;
|
|
423
|
+
return JSON.stringify({ op: e.op, which: e.which, key: e.key, value: e.value });
|
|
424
|
+
}) };
|
|
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
|
+
}) };
|
|
320
455
|
},
|
|
321
456
|
};
|
|
322
457
|
|
|
458
|
+
interface TailArgs {
|
|
459
|
+
n?: number;
|
|
460
|
+
filter?: string;
|
|
461
|
+
match?: 'contains' | 'regex';
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Apply caller-supplied filtering to a tail() result. `pickHaystack` returns
|
|
466
|
+
* the string to match against (or `undefined` to drop the entry due to a
|
|
467
|
+
* type-specific narrow like `level` / `urlContains`). The shared `filter`
|
|
468
|
+
* string then runs as substring (default) or regex against the haystack.
|
|
469
|
+
*/
|
|
470
|
+
function safeRegex(source: string): RegExp | undefined {
|
|
471
|
+
try {
|
|
472
|
+
return new RegExp(source, 'i');
|
|
473
|
+
} catch {
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function filterTail<T>(
|
|
479
|
+
items: T[],
|
|
480
|
+
args: TailArgs,
|
|
481
|
+
pickHaystack: (item: T) => string | undefined,
|
|
482
|
+
): T[] {
|
|
483
|
+
const filter = args.filter?.trim();
|
|
484
|
+
const useRegex = args.match === 'regex';
|
|
485
|
+
let regex: RegExp | undefined;
|
|
486
|
+
if (filter && useRegex) {
|
|
487
|
+
try {
|
|
488
|
+
regex = new RegExp(filter, 'i');
|
|
489
|
+
} catch {
|
|
490
|
+
// Invalid regex: fall back to substring match rather than throwing.
|
|
491
|
+
regex = undefined;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const out: T[] = [];
|
|
495
|
+
for (const item of items) {
|
|
496
|
+
const haystack = pickHaystack(item);
|
|
497
|
+
if (haystack === undefined) continue;
|
|
498
|
+
if (filter) {
|
|
499
|
+
if (regex) {
|
|
500
|
+
if (!regex.test(haystack)) continue;
|
|
501
|
+
} else {
|
|
502
|
+
if (!haystack.toLowerCase().includes(filter.toLowerCase())) continue;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
out.push(item);
|
|
506
|
+
}
|
|
507
|
+
return out;
|
|
508
|
+
}
|
|
509
|
+
|
|
323
510
|
function truncate(s: string, n: number): string {
|
|
324
511
|
if (s.length <= n) return s;
|
|
325
512
|
return `${s.slice(0, n)}… (truncated, total ${s.length} chars)`;
|