@harness-fe/runtime 3.0.1 → 3.1.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 +36 -1
- package/dist/capture.js +40 -1
- package/dist/client.js +2 -1
- package/dist/commands.js +145 -3
- package/dist/fetchPatch.js +3 -0
- package/dist/initiator.d.ts +20 -0
- package/dist/initiator.js +34 -0
- package/dist/storagePatch.d.ts +23 -0
- package/dist/storagePatch.js +190 -0
- package/dist/wsPatch.d.ts +26 -0
- package/dist/wsPatch.js +172 -0
- package/dist/xhrPatch.js +3 -0
- package/package.json +2 -2
- package/src/capture.ts +47 -1
- package/src/client.ts +5 -1
- package/src/commands.ts +163 -6
- package/src/commandsFilter.test.ts +167 -0
- package/src/commandsNetwork.e2e.test.ts +146 -0
- package/src/fetchPatch.test.ts +14 -0
- package/src/fetchPatch.ts +3 -0
- package/src/initiator.ts +40 -0
- package/src/runtimeClient.e2e.test.ts +246 -0
- package/src/storagePatch.test.ts +137 -0
- package/src/storagePatch.ts +213 -0
- package/src/wsPatch.test.ts +150 -0
- package/src/wsPatch.ts +198 -0
- package/src/xhrPatch.ts +3 -0
package/dist/wsPatch.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket monkey-patch — captures open / send / recv / close frames so
|
|
3
|
+
* agents can see long-lived push channels (IM, presence, sync, kick-out).
|
|
4
|
+
*
|
|
5
|
+
* Safety contract (mirrors fetchPatch):
|
|
6
|
+
* 1. Identity-preserving: replacement is a constructor with the same name,
|
|
7
|
+
* prototype, and CONNECTING/OPEN/CLOSING/CLOSED static fields. Existing
|
|
8
|
+
* `instanceof WebSocket` checks still pass because we extend the original.
|
|
9
|
+
* 2. Error-isolated: capture failures swallowed via safeEmit.
|
|
10
|
+
* 3. No timing or value change: pass-through to native WebSocket; we only
|
|
11
|
+
* observe events and call sites. The original Promise / data flow is
|
|
12
|
+
* untouched.
|
|
13
|
+
* 4. Bounded memory: frame payloads capped at BODY_CAP per send/recv. Binary
|
|
14
|
+
* frames record a `[binary Nb]` marker rather than the bytes.
|
|
15
|
+
*
|
|
16
|
+
* The patch is idempotent. Returns a dispose function that restores
|
|
17
|
+
* `window.WebSocket`.
|
|
18
|
+
*/
|
|
19
|
+
import { captureInitiator } from './initiator.js';
|
|
20
|
+
const DEFAULT_BODY_CAP = 256 * 1024;
|
|
21
|
+
const PATCHED_FLAG = '__hfeWsPatched';
|
|
22
|
+
const DEFAULT_DENYLIST = [/\/__hfe\//, /sockjs-node/];
|
|
23
|
+
export function installWsPatch(opts) {
|
|
24
|
+
if (typeof window === 'undefined' || typeof window.WebSocket !== 'function') {
|
|
25
|
+
return () => { };
|
|
26
|
+
}
|
|
27
|
+
const OriginalWS = window.WebSocket;
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
if (OriginalWS[PATCHED_FLAG])
|
|
30
|
+
return () => { };
|
|
31
|
+
const bodyCap = opts.bodyCap ?? DEFAULT_BODY_CAP;
|
|
32
|
+
const denylist = opts.denylist ?? DEFAULT_DENYLIST;
|
|
33
|
+
const emit = (entry) => {
|
|
34
|
+
try {
|
|
35
|
+
opts.onEntry(entry);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* swallow */
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const Patched = function PatchedWebSocket(url, protocols) {
|
|
42
|
+
const urlStr = typeof url === 'string' ? url : url.toString();
|
|
43
|
+
const ws = protocols !== undefined
|
|
44
|
+
? new OriginalWS(url, protocols)
|
|
45
|
+
: new OriginalWS(url);
|
|
46
|
+
if (denylist.some((re) => re.test(urlStr)))
|
|
47
|
+
return ws;
|
|
48
|
+
const id = generateId();
|
|
49
|
+
const protoList = Array.isArray(protocols)
|
|
50
|
+
? protocols
|
|
51
|
+
: typeof protocols === 'string'
|
|
52
|
+
? [protocols]
|
|
53
|
+
: undefined;
|
|
54
|
+
const openInitiator = captureInitiator();
|
|
55
|
+
emit({
|
|
56
|
+
ts: Date.now(),
|
|
57
|
+
id,
|
|
58
|
+
phase: 'open',
|
|
59
|
+
url: urlStr,
|
|
60
|
+
protocols: protoList,
|
|
61
|
+
initiator: openInitiator,
|
|
62
|
+
});
|
|
63
|
+
ws.addEventListener('message', (ev) => {
|
|
64
|
+
const { payload, truncated } = serializeFrame(ev.data, bodyCap);
|
|
65
|
+
emit({
|
|
66
|
+
ts: Date.now(),
|
|
67
|
+
id,
|
|
68
|
+
phase: 'recv',
|
|
69
|
+
url: urlStr,
|
|
70
|
+
payload,
|
|
71
|
+
payloadTruncated: truncated || undefined,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
ws.addEventListener('close', (ev) => {
|
|
75
|
+
emit({
|
|
76
|
+
ts: Date.now(),
|
|
77
|
+
id,
|
|
78
|
+
phase: 'close',
|
|
79
|
+
url: urlStr,
|
|
80
|
+
code: ev.code,
|
|
81
|
+
reason: ev.reason || undefined,
|
|
82
|
+
wasClean: ev.wasClean,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
// Wrap `send` on this instance so we record outgoing payloads + caller.
|
|
86
|
+
const origSend = ws.send.bind(ws);
|
|
87
|
+
ws.send = function patchedSend(data) {
|
|
88
|
+
const initiator = captureInitiator();
|
|
89
|
+
const { payload, truncated } = serializeFrame(data, bodyCap);
|
|
90
|
+
emit({
|
|
91
|
+
ts: Date.now(),
|
|
92
|
+
id,
|
|
93
|
+
phase: 'send',
|
|
94
|
+
url: urlStr,
|
|
95
|
+
payload,
|
|
96
|
+
payloadTruncated: truncated || undefined,
|
|
97
|
+
initiator,
|
|
98
|
+
});
|
|
99
|
+
return origSend(data);
|
|
100
|
+
};
|
|
101
|
+
return ws;
|
|
102
|
+
};
|
|
103
|
+
// Preserve constructor surface so library detection still works.
|
|
104
|
+
Patched.prototype = OriginalWS.prototype;
|
|
105
|
+
for (const key of ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']) {
|
|
106
|
+
try {
|
|
107
|
+
Object.defineProperty(Patched, key, {
|
|
108
|
+
value: OriginalWS[key],
|
|
109
|
+
writable: false,
|
|
110
|
+
configurable: true,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
/* readonly already — ignore */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
Object.defineProperty(Patched, 'name', { value: 'WebSocket' });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
/* ignore */
|
|
122
|
+
}
|
|
123
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
124
|
+
Patched[PATCHED_FLAG] = true;
|
|
125
|
+
window.WebSocket = Patched;
|
|
126
|
+
return () => {
|
|
127
|
+
if (window.WebSocket === Patched) {
|
|
128
|
+
window.WebSocket = OriginalWS;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function generateId() {
|
|
133
|
+
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
|
134
|
+
return crypto.randomUUID();
|
|
135
|
+
}
|
|
136
|
+
return `ws_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
137
|
+
}
|
|
138
|
+
function serializeFrame(data, cap) {
|
|
139
|
+
if (typeof data === 'string') {
|
|
140
|
+
if (data.length <= cap) {
|
|
141
|
+
// Try JSON for structured payloads.
|
|
142
|
+
const parsed = tryJson(data);
|
|
143
|
+
return { payload: parsed !== undefined ? parsed : data, truncated: false };
|
|
144
|
+
}
|
|
145
|
+
return { payload: data.slice(0, cap), truncated: true };
|
|
146
|
+
}
|
|
147
|
+
if (data instanceof ArrayBuffer) {
|
|
148
|
+
return { payload: `[binary ArrayBuffer ${data.byteLength}B]`, truncated: false };
|
|
149
|
+
}
|
|
150
|
+
if (typeof Blob !== 'undefined' && data instanceof Blob) {
|
|
151
|
+
return { payload: `[binary Blob ${data.size}B]`, truncated: false };
|
|
152
|
+
}
|
|
153
|
+
if (ArrayBuffer.isView(data)) {
|
|
154
|
+
const view = data;
|
|
155
|
+
return { payload: `[binary ${view.constructor.name} ${view.byteLength}B]`, truncated: false };
|
|
156
|
+
}
|
|
157
|
+
return { payload: undefined, truncated: false };
|
|
158
|
+
}
|
|
159
|
+
function tryJson(s) {
|
|
160
|
+
const trimmed = s.trim();
|
|
161
|
+
if (!trimmed)
|
|
162
|
+
return undefined;
|
|
163
|
+
const first = trimmed[0];
|
|
164
|
+
if (first !== '{' && first !== '[' && first !== '"')
|
|
165
|
+
return undefined;
|
|
166
|
+
try {
|
|
167
|
+
return JSON.parse(trimmed);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
package/dist/xhrPatch.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* (XHR has no init-style options bag like fetch)
|
|
18
18
|
* - Idempotent install + dispose() restores original prototype methods
|
|
19
19
|
*/
|
|
20
|
+
import { captureInitiator } from './initiator.js';
|
|
20
21
|
const DEFAULT_BODY_CAP = 256 * 1024;
|
|
21
22
|
const PATCHED_FLAG = '__hfeXhrPatched';
|
|
22
23
|
const META_KEY = Symbol.for('@harness-fe/xhr-meta');
|
|
@@ -73,6 +74,7 @@ export function installXhrPatch(opts) {
|
|
|
73
74
|
}
|
|
74
75
|
meta.startedAt = performance.now();
|
|
75
76
|
meta.startedTs = Date.now();
|
|
77
|
+
const initiator = captureInitiator();
|
|
76
78
|
// Emit req eagerly with headers; body added on second emit after
|
|
77
79
|
// serialization (mirrors fetchPatch behavior).
|
|
78
80
|
const reqRecord = {
|
|
@@ -82,6 +84,7 @@ export function installXhrPatch(opts) {
|
|
|
82
84
|
method: meta.method,
|
|
83
85
|
url: meta.url,
|
|
84
86
|
requestHeaders: redactHeaders(meta.headers),
|
|
87
|
+
initiator,
|
|
85
88
|
};
|
|
86
89
|
emit(reqRecord);
|
|
87
90
|
meta.reqEmitted = true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harness-fe/runtime",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "Browser-side SDK injected into the dev page. Connects to the MCP server via WebSocket and executes commands.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@zumer/snapdom": "^2.12.0",
|
|
32
32
|
"rrweb": "2.0.0-alpha.4",
|
|
33
|
-
"@harness-fe/protocol": "3.
|
|
33
|
+
"@harness-fe/protocol": "3.1.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"happy-dom": "^20.9.0",
|
package/src/capture.ts
CHANGED
|
@@ -9,30 +9,45 @@ import type {
|
|
|
9
9
|
ConsoleEntry,
|
|
10
10
|
ErrorEntry,
|
|
11
11
|
NetworkEntry,
|
|
12
|
+
WsEntry,
|
|
13
|
+
StorageEntry,
|
|
12
14
|
} from '@harness-fe/protocol';
|
|
13
15
|
import { RingBuffer } from './buffer.js';
|
|
14
16
|
import { installFetchPatch } from './fetchPatch.js';
|
|
15
17
|
import { installXhrPatch } from './xhrPatch.js';
|
|
18
|
+
import { installWsPatch } from './wsPatch.js';
|
|
19
|
+
import { installStoragePatch } from './storagePatch.js';
|
|
16
20
|
|
|
17
21
|
const CONSOLE_CAP = 500;
|
|
18
22
|
const NETWORK_CAP = 200;
|
|
19
23
|
const ERROR_CAP = 200;
|
|
24
|
+
const WS_CAP = 200;
|
|
25
|
+
const STORAGE_CAP = 200;
|
|
20
26
|
|
|
21
27
|
export class CaptureStore {
|
|
22
28
|
readonly console = new RingBuffer<ConsoleEntry>(CONSOLE_CAP);
|
|
23
29
|
readonly network = new RingBuffer<NetworkEntry>(NETWORK_CAP);
|
|
24
30
|
readonly errors = new RingBuffer<ErrorEntry>(ERROR_CAP);
|
|
31
|
+
readonly ws = new RingBuffer<WsEntry>(WS_CAP);
|
|
32
|
+
readonly storage = new RingBuffer<StorageEntry>(STORAGE_CAP);
|
|
25
33
|
|
|
26
34
|
private installed = false;
|
|
27
35
|
private fetchDispose?: () => void;
|
|
28
36
|
private xhrDispose?: () => void;
|
|
37
|
+
private wsDispose?: () => void;
|
|
38
|
+
private storageDispose?: () => void;
|
|
29
39
|
|
|
30
|
-
install(
|
|
40
|
+
install(
|
|
41
|
+
onEvent: (name: string, payload: unknown) => void,
|
|
42
|
+
opts: { daemonUrl?: string } = {},
|
|
43
|
+
): void {
|
|
31
44
|
if (this.installed) return;
|
|
32
45
|
this.installed = true;
|
|
33
46
|
this.installConsole(onEvent);
|
|
34
47
|
this.installFetch(onEvent);
|
|
35
48
|
this.installXhr(onEvent);
|
|
49
|
+
this.installWs(onEvent, opts.daemonUrl);
|
|
50
|
+
this.installStorage(onEvent);
|
|
36
51
|
this.installErrors(onEvent);
|
|
37
52
|
}
|
|
38
53
|
|
|
@@ -41,6 +56,10 @@ export class CaptureStore {
|
|
|
41
56
|
this.fetchDispose = undefined;
|
|
42
57
|
this.xhrDispose?.();
|
|
43
58
|
this.xhrDispose = undefined;
|
|
59
|
+
this.wsDispose?.();
|
|
60
|
+
this.wsDispose = undefined;
|
|
61
|
+
this.storageDispose?.();
|
|
62
|
+
this.storageDispose = undefined;
|
|
44
63
|
this.installed = false;
|
|
45
64
|
}
|
|
46
65
|
|
|
@@ -79,6 +98,33 @@ export class CaptureStore {
|
|
|
79
98
|
});
|
|
80
99
|
}
|
|
81
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) => {
|
|
113
|
+
this.ws.push(entry);
|
|
114
|
+
onEvent('ws', entry);
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private installStorage(onEvent: (name: string, payload: unknown) => void): void {
|
|
120
|
+
this.storageDispose = installStoragePatch({
|
|
121
|
+
onEntry: (entry) => {
|
|
122
|
+
this.storage.push(entry);
|
|
123
|
+
onEvent('storage', entry);
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
82
128
|
private installErrors(onEvent: (name: string, payload: unknown) => void): void {
|
|
83
129
|
if (typeof window === 'undefined') return;
|
|
84
130
|
window.addEventListener('error', (e: ErrorEvent) => {
|
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,178 @@ 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
|
+
}) };
|
|
320
425
|
},
|
|
321
426
|
};
|
|
322
427
|
|
|
428
|
+
interface TailArgs {
|
|
429
|
+
n?: number;
|
|
430
|
+
filter?: string;
|
|
431
|
+
match?: 'contains' | 'regex';
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Apply caller-supplied filtering to a tail() result. `pickHaystack` returns
|
|
436
|
+
* the string to match against (or `undefined` to drop the entry due to a
|
|
437
|
+
* type-specific narrow like `level` / `urlContains`). The shared `filter`
|
|
438
|
+
* string then runs as substring (default) or regex against the haystack.
|
|
439
|
+
*/
|
|
440
|
+
function safeRegex(source: string): RegExp | undefined {
|
|
441
|
+
try {
|
|
442
|
+
return new RegExp(source, 'i');
|
|
443
|
+
} catch {
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function filterTail<T>(
|
|
449
|
+
items: T[],
|
|
450
|
+
args: TailArgs,
|
|
451
|
+
pickHaystack: (item: T) => string | undefined,
|
|
452
|
+
): T[] {
|
|
453
|
+
const filter = args.filter?.trim();
|
|
454
|
+
const useRegex = args.match === 'regex';
|
|
455
|
+
let regex: RegExp | undefined;
|
|
456
|
+
if (filter && useRegex) {
|
|
457
|
+
try {
|
|
458
|
+
regex = new RegExp(filter, 'i');
|
|
459
|
+
} catch {
|
|
460
|
+
// Invalid regex: fall back to substring match rather than throwing.
|
|
461
|
+
regex = undefined;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const out: T[] = [];
|
|
465
|
+
for (const item of items) {
|
|
466
|
+
const haystack = pickHaystack(item);
|
|
467
|
+
if (haystack === undefined) continue;
|
|
468
|
+
if (filter) {
|
|
469
|
+
if (regex) {
|
|
470
|
+
if (!regex.test(haystack)) continue;
|
|
471
|
+
} else {
|
|
472
|
+
if (!haystack.toLowerCase().includes(filter.toLowerCase())) continue;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
out.push(item);
|
|
476
|
+
}
|
|
477
|
+
return out;
|
|
478
|
+
}
|
|
479
|
+
|
|
323
480
|
function truncate(s: string, n: number): string {
|
|
324
481
|
if (s.length <= n) return s;
|
|
325
482
|
return `${s.slice(0, n)}… (truncated, total ${s.length} chars)`;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { COMMAND, type ConsoleEntry, type NetworkEntry, type WsEntry, type ErrorEntry } from '@harness-fe/protocol';
|
|
4
|
+
import { commandHandlers } from './commands.js';
|
|
5
|
+
import type { CaptureStore } from './capture.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Construct a fake CaptureStore wide enough to drive the *_TAIL handlers.
|
|
9
|
+
* We seed the RingBuffers directly so we don't have to actually patch fetch /
|
|
10
|
+
* console / WebSocket in this test.
|
|
11
|
+
*/
|
|
12
|
+
function makeCapture(seed: {
|
|
13
|
+
console?: ConsoleEntry[];
|
|
14
|
+
network?: NetworkEntry[];
|
|
15
|
+
errors?: ErrorEntry[];
|
|
16
|
+
ws?: WsEntry[];
|
|
17
|
+
}): CaptureStore {
|
|
18
|
+
function ring<T>(items: T[]): { tail: (n: number) => T[] } {
|
|
19
|
+
return { tail: (n) => items.slice(-n) };
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
console: ring(seed.console ?? []),
|
|
23
|
+
network: ring(seed.network ?? []),
|
|
24
|
+
errors: ring(seed.errors ?? []),
|
|
25
|
+
ws: ring(seed.ws ?? []),
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
|
+
} as any;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('console.tail filtering', () => {
|
|
31
|
+
const seed: ConsoleEntry[] = [
|
|
32
|
+
{ ts: 1, level: 'log', args: ['boot complete'] },
|
|
33
|
+
{ ts: 2, level: 'warn', args: ['cache miss'] },
|
|
34
|
+
{ ts: 3, level: 'error', args: ['logout failed', { reason: 'token expired' }] },
|
|
35
|
+
{ ts: 4, level: 'log', args: ['heartbeat'] },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
it('filter substring (case-insensitive)', async () => {
|
|
39
|
+
const out = await commandHandlers[COMMAND.CONSOLE_TAIL](
|
|
40
|
+
{ n: 10, filter: 'LOGOUT' },
|
|
41
|
+
{ capture: makeCapture({ console: seed }) },
|
|
42
|
+
) as { entries: ConsoleEntry[] };
|
|
43
|
+
expect(out.entries.map((e) => e.level)).toEqual(['error']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('filter regex', async () => {
|
|
47
|
+
const out = await commandHandlers[COMMAND.CONSOLE_TAIL](
|
|
48
|
+
{ n: 10, filter: 'boot|heart', match: 'regex' },
|
|
49
|
+
{ capture: makeCapture({ console: seed }) },
|
|
50
|
+
) as { entries: ConsoleEntry[] };
|
|
51
|
+
expect(out.entries).toHaveLength(2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('level narrow', async () => {
|
|
55
|
+
const out = await commandHandlers[COMMAND.CONSOLE_TAIL](
|
|
56
|
+
{ n: 10, level: 'error' },
|
|
57
|
+
{ capture: makeCapture({ console: seed }) },
|
|
58
|
+
) as { entries: ConsoleEntry[] };
|
|
59
|
+
expect(out.entries).toHaveLength(1);
|
|
60
|
+
expect(out.entries[0].args[0]).toBe('logout failed');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('invalid regex falls back to substring', async () => {
|
|
64
|
+
const out = await commandHandlers[COMMAND.CONSOLE_TAIL](
|
|
65
|
+
{ n: 10, filter: '[bad', match: 'regex' },
|
|
66
|
+
{ capture: makeCapture({ console: seed }) },
|
|
67
|
+
) as { entries: ConsoleEntry[] };
|
|
68
|
+
// No entry literally contains "[bad" — fallback returns 0 matches.
|
|
69
|
+
expect(out.entries).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('network.tail filtering', () => {
|
|
74
|
+
const seed: NetworkEntry[] = [
|
|
75
|
+
{ ts: 1, id: 'r1', phase: 'req', method: 'GET', url: 'https://api.test/users' },
|
|
76
|
+
{ ts: 2, id: 'r1', phase: 'res', method: 'GET', url: 'https://api.test/users', status: 200 },
|
|
77
|
+
{ ts: 3, id: 'r2', phase: 'req', method: 'POST', url: 'https://api.test/logout' },
|
|
78
|
+
{ ts: 4, id: 'r2', phase: 'res', method: 'POST', url: 'https://api.test/logout', status: 401 },
|
|
79
|
+
{ ts: 5, id: 'r3', phase: 'req', method: 'GET', url: 'https://cdn.test/assets/app.js' },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
it('urlContains narrow', async () => {
|
|
83
|
+
const out = await commandHandlers[COMMAND.NETWORK_TAIL](
|
|
84
|
+
{ n: 10, urlContains: 'logout' },
|
|
85
|
+
{ capture: makeCapture({ network: seed }) },
|
|
86
|
+
) as { entries: NetworkEntry[] };
|
|
87
|
+
expect(out.entries.every((e) => e.url.includes('logout'))).toBe(true);
|
|
88
|
+
expect(out.entries).toHaveLength(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('method narrow (case-insensitive)', async () => {
|
|
92
|
+
const out = await commandHandlers[COMMAND.NETWORK_TAIL](
|
|
93
|
+
{ n: 10, method: 'post' },
|
|
94
|
+
{ capture: makeCapture({ network: seed }) },
|
|
95
|
+
) as { entries: NetworkEntry[] };
|
|
96
|
+
expect(out.entries.every((e) => e.method === 'POST')).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('statusCode narrow (drops req entries without status)', async () => {
|
|
100
|
+
const out = await commandHandlers[COMMAND.NETWORK_TAIL](
|
|
101
|
+
{ n: 10, statusCode: 401 },
|
|
102
|
+
{ capture: makeCapture({ network: seed }) },
|
|
103
|
+
) as { entries: NetworkEntry[] };
|
|
104
|
+
expect(out.entries).toHaveLength(1);
|
|
105
|
+
expect(out.entries[0].status).toBe(401);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('filter combined with narrow', async () => {
|
|
109
|
+
const out = await commandHandlers[COMMAND.NETWORK_TAIL](
|
|
110
|
+
{ n: 10, urlContains: 'api.test', filter: 'logout' },
|
|
111
|
+
{ capture: makeCapture({ network: seed }) },
|
|
112
|
+
) as { entries: NetworkEntry[] };
|
|
113
|
+
expect(out.entries.every((e) => e.url.includes('logout'))).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('ws.tail filtering', () => {
|
|
118
|
+
const seed: WsEntry[] = [
|
|
119
|
+
{ ts: 1, id: 'w1', phase: 'open', url: 'wss://chat.test/' },
|
|
120
|
+
{ ts: 2, id: 'w1', phase: 'send', url: 'wss://chat.test/', payload: { type: 'ping' } },
|
|
121
|
+
{ ts: 3, id: 'w1', phase: 'recv', url: 'wss://chat.test/', payload: { type: 'kick', reason: 'duplicate-login' } },
|
|
122
|
+
{ ts: 4, id: 'w1', phase: 'close', url: 'wss://chat.test/', code: 4001, reason: 'duplicate-login' },
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
it('phase narrow', async () => {
|
|
126
|
+
const out = await commandHandlers[COMMAND.WS_TAIL](
|
|
127
|
+
{ n: 10, phase: 'recv' },
|
|
128
|
+
{ capture: makeCapture({ ws: seed }) },
|
|
129
|
+
) as { entries: WsEntry[] };
|
|
130
|
+
expect(out.entries).toHaveLength(1);
|
|
131
|
+
expect(out.entries[0].phase).toBe('recv');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('filter against payload', async () => {
|
|
135
|
+
const out = await commandHandlers[COMMAND.WS_TAIL](
|
|
136
|
+
{ n: 10, filter: 'kick' },
|
|
137
|
+
{ capture: makeCapture({ ws: seed }) },
|
|
138
|
+
) as { entries: WsEntry[] };
|
|
139
|
+
expect(out.entries).toHaveLength(1);
|
|
140
|
+
expect(out.entries[0].phase).toBe('recv');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('filter against close reason', async () => {
|
|
144
|
+
const out = await commandHandlers[COMMAND.WS_TAIL](
|
|
145
|
+
{ n: 10, filter: 'duplicate-login', phase: 'close' },
|
|
146
|
+
{ capture: makeCapture({ ws: seed }) },
|
|
147
|
+
) as { entries: WsEntry[] };
|
|
148
|
+
expect(out.entries).toHaveLength(1);
|
|
149
|
+
expect(out.entries[0].code).toBe(4001);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('errors.tail filtering', () => {
|
|
154
|
+
const seed: ErrorEntry[] = [
|
|
155
|
+
{ ts: 1, message: 'Cannot read property foo of undefined', stack: 'at A.run', source: 'a.js:1:1' },
|
|
156
|
+
{ ts: 2, message: 'NetworkError', stack: 'at fetchX', source: 'net.js:9:9' },
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
it('filter by message substring', async () => {
|
|
160
|
+
const out = await commandHandlers[COMMAND.ERRORS_TAIL](
|
|
161
|
+
{ n: 10, filter: 'network' },
|
|
162
|
+
{ capture: makeCapture({ errors: seed }) },
|
|
163
|
+
) as { entries: ErrorEntry[] };
|
|
164
|
+
expect(out.entries).toHaveLength(1);
|
|
165
|
+
expect(out.entries[0].message).toBe('NetworkError');
|
|
166
|
+
});
|
|
167
|
+
});
|