@harness-fe/runtime 3.0.1
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/LICENSE +21 -0
- package/README.md +48 -0
- package/dist/buffer.d.ts +13 -0
- package/dist/buffer.js +26 -0
- package/dist/capture.d.ts +47 -0
- package/dist/capture.js +112 -0
- package/dist/client.d.ts +82 -0
- package/dist/client.js +364 -0
- package/dist/commands.d.ts +10 -0
- package/dist/commands.js +304 -0
- package/dist/dashboardUrl.d.ts +18 -0
- package/dist/dashboardUrl.js +20 -0
- package/dist/fetchPatch.d.ts +39 -0
- package/dist/fetchPatch.js +311 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +23 -0
- package/dist/outbox.d.ts +37 -0
- package/dist/outbox.js +80 -0
- package/dist/overlay.d.ts +68 -0
- package/dist/overlay.js +1946 -0
- package/dist/parent-inherit.d.ts +25 -0
- package/dist/parent-inherit.js +43 -0
- package/dist/recording.d.ts +27 -0
- package/dist/recording.js +86 -0
- package/dist/rrweb-types.d.ts +13 -0
- package/dist/rrweb-types.js +20 -0
- package/dist/selectors.d.ts +14 -0
- package/dist/selectors.js +91 -0
- package/dist/snapshot.d.ts +12 -0
- package/dist/snapshot.js +111 -0
- package/dist/visitor.d.ts +28 -0
- package/dist/visitor.js +107 -0
- package/dist/xhrPatch.d.ts +26 -0
- package/dist/xhrPatch.js +269 -0
- package/package.json +50 -0
- package/src/buffer.test.ts +26 -0
- package/src/buffer.ts +29 -0
- package/src/capture.ts +126 -0
- package/src/client.test.ts +89 -0
- package/src/client.ts +423 -0
- package/src/commands.test.ts +128 -0
- package/src/commands.ts +335 -0
- package/src/dashboardUrl.test.ts +59 -0
- package/src/dashboardUrl.ts +36 -0
- package/src/fetchPatch.test.ts +203 -0
- package/src/fetchPatch.ts +371 -0
- package/src/index.ts +32 -0
- package/src/outbox.test.ts +115 -0
- package/src/outbox.ts +84 -0
- package/src/overlay.test.ts +319 -0
- package/src/overlay.ts +2070 -0
- package/src/parent-inherit.ts +54 -0
- package/src/recording.ts +88 -0
- package/src/rrweb-types.test.ts +40 -0
- package/src/rrweb-types.ts +24 -0
- package/src/selectors.test.ts +50 -0
- package/src/selectors.ts +103 -0
- package/src/snapshot.ts +112 -0
- package/src/visitor.ts +116 -0
- package/src/xhrPatch.test.ts +191 -0
- package/src/xhrPatch.ts +314 -0
package/src/commands.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in command handlers run in the page. Each receives parsed args and
|
|
3
|
+
* returns a serializable result that gets shipped back in a ResponseFrame.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
COMMAND,
|
|
8
|
+
type ClickArgs,
|
|
9
|
+
type EvaluateArgs,
|
|
10
|
+
type NavigateArgs,
|
|
11
|
+
type ReloadArgs,
|
|
12
|
+
type ScreenshotArgs,
|
|
13
|
+
type ScrollArgs,
|
|
14
|
+
type SetHtmlArgs,
|
|
15
|
+
type SetStyleArgs,
|
|
16
|
+
type Selector,
|
|
17
|
+
type TypeArgs,
|
|
18
|
+
type WaitForArgs,
|
|
19
|
+
} from '@harness-fe/protocol';
|
|
20
|
+
import { snapdom } from '@zumer/snapdom';
|
|
21
|
+
import { resolveSelector } from './selectors.js';
|
|
22
|
+
import type { CaptureStore } from './capture.js';
|
|
23
|
+
|
|
24
|
+
export interface CommandContext {
|
|
25
|
+
capture: CaptureStore;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type CommandHandler = (args: unknown, ctx: CommandContext) => Promise<unknown>;
|
|
29
|
+
|
|
30
|
+
const HTML_TRUNCATE = 4000;
|
|
31
|
+
|
|
32
|
+
function describeNoMatch(selector: Selector): string {
|
|
33
|
+
const fields = Object.entries(selector)
|
|
34
|
+
.filter(([, v]) => v !== undefined)
|
|
35
|
+
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
|
36
|
+
.join(' ');
|
|
37
|
+
return `no element matched selector: ${fields}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const commandHandlers: Record<string, CommandHandler> = {
|
|
41
|
+
[COMMAND.PAGE_CLICK]: async (raw) => {
|
|
42
|
+
const args = raw as ClickArgs;
|
|
43
|
+
const result = resolveSelector(args.selector);
|
|
44
|
+
if (!result.element) throw new Error(describeNoMatch(args.selector));
|
|
45
|
+
const target = result.element as HTMLElement;
|
|
46
|
+
|
|
47
|
+
// When the resolved element is not itself an <a>, walk up to find the
|
|
48
|
+
// nearest anchor ancestor. This handles the common case where a text
|
|
49
|
+
// selector matches a child <span> inside a React Router <Link>, which
|
|
50
|
+
// would otherwise fire a click that bypasses the router's onClick handler.
|
|
51
|
+
let clickTarget: HTMLElement = target;
|
|
52
|
+
if (target.tagName !== 'A') {
|
|
53
|
+
const anchor = target.closest('a');
|
|
54
|
+
if (anchor) clickTarget = anchor as HTMLElement;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Dispatch a proper MouseEvent instead of calling .click() so that
|
|
58
|
+
// framework routers (React Router, Vue Router) receive a bubbling event
|
|
59
|
+
// with the correct button/modifier state they check before navigating.
|
|
60
|
+
clickTarget.dispatchEvent(
|
|
61
|
+
new MouseEvent('click', {
|
|
62
|
+
bubbles: true,
|
|
63
|
+
cancelable: true,
|
|
64
|
+
view: window,
|
|
65
|
+
button: args.button === 'right' ? 2 : args.button === 'middle' ? 1 : 0,
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
return { via: result.via, tag: clickTarget.tagName.toLowerCase() };
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
[COMMAND.PAGE_TYPE]: async (raw) => {
|
|
72
|
+
const args = raw as TypeArgs;
|
|
73
|
+
const result = resolveSelector(args.selector);
|
|
74
|
+
if (!result.element) throw new Error(describeNoMatch(args.selector));
|
|
75
|
+
const target = result.element as HTMLInputElement | HTMLTextAreaElement;
|
|
76
|
+
if (typeof target.value !== 'string') {
|
|
77
|
+
throw new Error('page.type: target element does not support .value');
|
|
78
|
+
}
|
|
79
|
+
// React (and Vue's controlled inputs) install setters/trackers on
|
|
80
|
+
// input.value. Setting `.value = '...'` directly bypasses them, so
|
|
81
|
+
// their state never updates. Use the native prototype setter so the
|
|
82
|
+
// framework's tracker registers the change, then dispatch a bubbling
|
|
83
|
+
// 'input' + 'change' event.
|
|
84
|
+
const proto =
|
|
85
|
+
target instanceof HTMLInputElement
|
|
86
|
+
? HTMLInputElement.prototype
|
|
87
|
+
: HTMLTextAreaElement.prototype;
|
|
88
|
+
const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
89
|
+
const next = args.clear !== false ? args.value : target.value + args.value;
|
|
90
|
+
if (nativeSetter) nativeSetter.call(target, next);
|
|
91
|
+
else target.value = next;
|
|
92
|
+
target.dispatchEvent(new Event('input', { bubbles: true }));
|
|
93
|
+
target.dispatchEvent(new Event('change', { bubbles: true }));
|
|
94
|
+
return { via: result.via, value: target.value };
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
[COMMAND.PAGE_EVALUATE]: async (raw) => {
|
|
98
|
+
const args = raw as EvaluateArgs;
|
|
99
|
+
// eslint-disable-next-line no-new-func
|
|
100
|
+
const fn = new Function(`return (async () => { return (${args.expr}); })();`) as () => Promise<unknown>;
|
|
101
|
+
const value = await fn();
|
|
102
|
+
return { value: safeJson(value) };
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
[COMMAND.PAGE_WAIT_FOR]: async (raw) => {
|
|
106
|
+
const args = raw as WaitForArgs;
|
|
107
|
+
const timeoutMs = args.timeoutMs ?? 10_000;
|
|
108
|
+
const deadline = Date.now() + timeoutMs;
|
|
109
|
+
const isBuiltin = args.predicate === 'network.idle' || args.predicate === 'dom.ready';
|
|
110
|
+
// eslint-disable-next-line no-new-func
|
|
111
|
+
const probe = !isBuiltin
|
|
112
|
+
? (new Function(`return Boolean(${args.predicate})`) as () => boolean)
|
|
113
|
+
: undefined;
|
|
114
|
+
|
|
115
|
+
while (Date.now() < deadline) {
|
|
116
|
+
if (args.predicate === 'dom.ready' && document.readyState === 'complete') {
|
|
117
|
+
return { ok: true, after: Date.now() };
|
|
118
|
+
}
|
|
119
|
+
if (args.predicate === 'network.idle') {
|
|
120
|
+
// Crude heuristic — we don't have a real idle tracker yet.
|
|
121
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
122
|
+
return { ok: true, after: Date.now() };
|
|
123
|
+
}
|
|
124
|
+
if (probe && probe()) return { ok: true, after: Date.now() };
|
|
125
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`page.wait_for: predicate "${args.predicate}" did not become truthy in ${timeoutMs}ms`);
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
[COMMAND.PAGE_SCREENSHOT]: async (raw) => {
|
|
131
|
+
const args = raw as ScreenshotArgs;
|
|
132
|
+
const format = args.format ?? 'webp';
|
|
133
|
+
const maxWidth = args.maxWidth ?? 1280;
|
|
134
|
+
// Default to opaque white so transparent pages don't render a blank
|
|
135
|
+
// screenshot. Callers can pass `null` to opt back into transparency.
|
|
136
|
+
// JPEG has no alpha channel so the field is effectively always set.
|
|
137
|
+
const backgroundColor =
|
|
138
|
+
args.backgroundColor === null
|
|
139
|
+
? undefined
|
|
140
|
+
: (args.backgroundColor ?? (format === 'jpeg' ? '#fff' : '#ffffff'));
|
|
141
|
+
|
|
142
|
+
let target: Element;
|
|
143
|
+
let via = 'document';
|
|
144
|
+
if (args.selector) {
|
|
145
|
+
const result = resolveSelector(args.selector);
|
|
146
|
+
if (!result.element) throw new Error(describeNoMatch(args.selector));
|
|
147
|
+
target = result.element;
|
|
148
|
+
via = result.via;
|
|
149
|
+
} else {
|
|
150
|
+
target = document.documentElement;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const rect = target.getBoundingClientRect();
|
|
154
|
+
const naturalWidth = Math.max(1, Math.round(rect.width || target.clientWidth || window.innerWidth));
|
|
155
|
+
const width = naturalWidth > maxWidth ? maxWidth : naturalWidth;
|
|
156
|
+
|
|
157
|
+
// Hide our own overlay during capture so the screenshot reflects the
|
|
158
|
+
// real page state. Without this, the floating "H" FAB and any open
|
|
159
|
+
// info card would always end up in the corner of every shot.
|
|
160
|
+
const overlayHost = document.getElementById('__harness_fe_overlay__') as HTMLElement | null;
|
|
161
|
+
const prevVisibility = overlayHost?.style.visibility ?? '';
|
|
162
|
+
if (overlayHost) overlayHost.style.visibility = 'hidden';
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const result = await snapdom(target as HTMLElement, {
|
|
166
|
+
fast: true,
|
|
167
|
+
width,
|
|
168
|
+
backgroundColor,
|
|
169
|
+
});
|
|
170
|
+
const canvas = await result.toCanvas();
|
|
171
|
+
const mime = format === 'jpeg' ? 'image/jpeg' : `image/${format}`;
|
|
172
|
+
const quality = format === 'png' ? undefined : 0.85;
|
|
173
|
+
const dataUrl = canvas.toDataURL(mime, quality);
|
|
174
|
+
return {
|
|
175
|
+
via,
|
|
176
|
+
format,
|
|
177
|
+
width: canvas.width,
|
|
178
|
+
height: canvas.height,
|
|
179
|
+
dataUrl,
|
|
180
|
+
};
|
|
181
|
+
} finally {
|
|
182
|
+
if (overlayHost) overlayHost.style.visibility = prevVisibility;
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
[COMMAND.PAGE_DOM_QUERY]: async (raw) => {
|
|
187
|
+
const args = raw as { selector: Selector; limit?: number };
|
|
188
|
+
const limit = args.limit ?? 5;
|
|
189
|
+
const matches: Array<{ html: string; tag: string; via: string }> = [];
|
|
190
|
+
// Try each selector field independently — we want all matches up to limit.
|
|
191
|
+
if (args.selector.css) {
|
|
192
|
+
const list = document.querySelectorAll(args.selector.css);
|
|
193
|
+
for (let i = 0; i < list.length && matches.length < limit; i++) {
|
|
194
|
+
matches.push({
|
|
195
|
+
html: truncate((list[i] as Element).outerHTML, HTML_TRUNCATE),
|
|
196
|
+
tag: (list[i] as Element).tagName.toLowerCase(),
|
|
197
|
+
via: 'css',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (matches.length < limit) {
|
|
202
|
+
const result = resolveSelector(args.selector);
|
|
203
|
+
if (result.element) {
|
|
204
|
+
matches.push({
|
|
205
|
+
html: truncate(result.element.outerHTML, HTML_TRUNCATE),
|
|
206
|
+
tag: result.element.tagName.toLowerCase(),
|
|
207
|
+
via: result.via,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { matches };
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
[COMMAND.PAGE_SCROLL]: async (raw) => {
|
|
215
|
+
const args = raw as ScrollArgs;
|
|
216
|
+
const behavior = args.behavior ?? 'smooth';
|
|
217
|
+
if (args.selector) {
|
|
218
|
+
const result = resolveSelector(args.selector);
|
|
219
|
+
if (!result.element) throw new Error(describeNoMatch(args.selector));
|
|
220
|
+
(result.element as HTMLElement).scrollIntoView({ behavior, block: 'center' });
|
|
221
|
+
return { via: result.via, scrolledIntoView: true };
|
|
222
|
+
}
|
|
223
|
+
window.scrollTo({ top: args.y ?? 0, left: args.x ?? 0, behavior });
|
|
224
|
+
return { scrollX: window.scrollX, scrollY: window.scrollY };
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
[COMMAND.PAGE_NAVIGATE]: async (raw) => {
|
|
228
|
+
const args = raw as NavigateArgs;
|
|
229
|
+
const method = args.method ?? 'href';
|
|
230
|
+
const before = location.href;
|
|
231
|
+
if (method === 'href') {
|
|
232
|
+
location.href = args.url;
|
|
233
|
+
return { method, from: before, to: args.url };
|
|
234
|
+
}
|
|
235
|
+
if (method === 'push') {
|
|
236
|
+
history.pushState({}, '', args.url);
|
|
237
|
+
} else {
|
|
238
|
+
history.replaceState({}, '', args.url);
|
|
239
|
+
}
|
|
240
|
+
// Notify SPA routers that listen on popstate
|
|
241
|
+
window.dispatchEvent(new PopStateEvent('popstate', { state: history.state }));
|
|
242
|
+
return { method, from: before, to: location.href };
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
[COMMAND.PAGE_RELOAD]: async (raw) => {
|
|
246
|
+
const args = raw as ReloadArgs;
|
|
247
|
+
if (args.hard) {
|
|
248
|
+
// Hard reload — bypass cache
|
|
249
|
+
location.reload();
|
|
250
|
+
} else {
|
|
251
|
+
location.reload();
|
|
252
|
+
}
|
|
253
|
+
return { reloading: true };
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
[COMMAND.PAGE_SET_HTML]: async (raw) => {
|
|
257
|
+
const args = raw as SetHtmlArgs;
|
|
258
|
+
const result = resolveSelector(args.selector);
|
|
259
|
+
if (!result.element) throw new Error(describeNoMatch(args.selector));
|
|
260
|
+
const el = result.element as HTMLElement;
|
|
261
|
+
const target = args.target ?? 'innerHTML';
|
|
262
|
+
const before = target === 'innerHTML' ? el.innerHTML : el.outerHTML;
|
|
263
|
+
if (target === 'innerHTML') {
|
|
264
|
+
el.innerHTML = args.html;
|
|
265
|
+
return { via: result.via, target, before: truncate(before, 500) };
|
|
266
|
+
}
|
|
267
|
+
// outerHTML replacement — the element is removed from the DOM; return the new element tag
|
|
268
|
+
const tag = el.tagName.toLowerCase();
|
|
269
|
+
el.outerHTML = args.html;
|
|
270
|
+
return { via: result.via, target, replacedTag: tag, before: truncate(before, 500) };
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
[COMMAND.PAGE_SET_STYLE]: async (raw) => {
|
|
274
|
+
const args = raw as SetStyleArgs;
|
|
275
|
+
|
|
276
|
+
// Global injection mode: { rule: "<raw css>" }
|
|
277
|
+
if (!args.selector) {
|
|
278
|
+
const rule = args.styles['rule'];
|
|
279
|
+
if (!rule) throw new Error('page.set_style: pass { rule: "<css>" } when no selector is provided');
|
|
280
|
+
const styleId = '__hfe_injected_style__';
|
|
281
|
+
let styleEl = document.getElementById(styleId) as HTMLStyleElement | null;
|
|
282
|
+
if (!styleEl) {
|
|
283
|
+
styleEl = document.createElement('style');
|
|
284
|
+
styleEl.id = styleId;
|
|
285
|
+
document.head.appendChild(styleEl);
|
|
286
|
+
}
|
|
287
|
+
styleEl.textContent += `\n${rule}`;
|
|
288
|
+
return { injected: true, rule };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Element inline-style mode
|
|
292
|
+
const result = resolveSelector(args.selector);
|
|
293
|
+
if (!result.element) throw new Error(describeNoMatch(args.selector));
|
|
294
|
+
const el = result.element as HTMLElement;
|
|
295
|
+
const merge = args.merge !== false; // default true
|
|
296
|
+
if (!merge) el.removeAttribute('style');
|
|
297
|
+
const applied: Record<string, string> = {};
|
|
298
|
+
for (const [prop, value] of Object.entries(args.styles)) {
|
|
299
|
+
// Accept both camelCase and kebab-case
|
|
300
|
+
const camel = prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
301
|
+
(el.style as unknown as Record<string, string>)[camel] = value;
|
|
302
|
+
applied[camel] = value;
|
|
303
|
+
}
|
|
304
|
+
return { via: result.via, applied, currentStyle: el.getAttribute('style') };
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
[COMMAND.CONSOLE_TAIL]: async (raw, ctx) => {
|
|
308
|
+
const args = raw as { n: number };
|
|
309
|
+
return { entries: ctx.capture.console.tail(args.n) };
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
[COMMAND.NETWORK_TAIL]: async (raw, ctx) => {
|
|
313
|
+
const args = raw as { n: number };
|
|
314
|
+
return { entries: ctx.capture.network.tail(args.n) };
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
[COMMAND.ERRORS_TAIL]: async (raw, ctx) => {
|
|
318
|
+
const args = raw as { n: number };
|
|
319
|
+
return { entries: ctx.capture.errors.tail(args.n) };
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
function truncate(s: string, n: number): string {
|
|
324
|
+
if (s.length <= n) return s;
|
|
325
|
+
return `${s.slice(0, n)}… (truncated, total ${s.length} chars)`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function safeJson(value: unknown): unknown {
|
|
329
|
+
if (value === undefined) return null;
|
|
330
|
+
try {
|
|
331
|
+
return JSON.parse(JSON.stringify(value));
|
|
332
|
+
} catch {
|
|
333
|
+
return String(value);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { deriveDashboardUrl } from './dashboardUrl.js';
|
|
3
|
+
|
|
4
|
+
describe('deriveDashboardUrl', () => {
|
|
5
|
+
it('swaps ws:// to http:// and points at /dashboard/', () => {
|
|
6
|
+
expect(deriveDashboardUrl({ mcpUrl: 'ws://127.0.0.1:47729' })).toBe(
|
|
7
|
+
'http://127.0.0.1:47729/dashboard/',
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('swaps wss:// to https:// (production / LAN with TLS)', () => {
|
|
12
|
+
expect(deriveDashboardUrl({ mcpUrl: 'wss://harness.lan:47729' })).toBe(
|
|
13
|
+
'https://harness.lan:47729/dashboard/',
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('carries the token query through verbatim', () => {
|
|
18
|
+
expect(
|
|
19
|
+
deriveDashboardUrl({ mcpUrl: 'ws://127.0.0.1:47729?token=abc' }),
|
|
20
|
+
).toBe('http://127.0.0.1:47729/dashboard/?token=abc');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('URL-encodes the token (defensive against weird HARNESS_FE_TOKEN values)', () => {
|
|
24
|
+
expect(
|
|
25
|
+
deriveDashboardUrl({ mcpUrl: 'ws://127.0.0.1:47729?token=a%20b%26c' }),
|
|
26
|
+
).toBe('http://127.0.0.1:47729/dashboard/?token=a%20b%26c');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('deep-links to /dashboard/sessions/:id when sessionId is provided', () => {
|
|
30
|
+
expect(
|
|
31
|
+
deriveDashboardUrl({
|
|
32
|
+
mcpUrl: 'ws://127.0.0.1:47729?token=abc',
|
|
33
|
+
sessionId: 'sess-1',
|
|
34
|
+
}),
|
|
35
|
+
).toBe('http://127.0.0.1:47729/dashboard/sessions/sess-1?token=abc');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('URL-encodes the session id', () => {
|
|
39
|
+
expect(
|
|
40
|
+
deriveDashboardUrl({
|
|
41
|
+
mcpUrl: 'ws://127.0.0.1:47729',
|
|
42
|
+
sessionId: 'a/b c',
|
|
43
|
+
}),
|
|
44
|
+
).toBe('http://127.0.0.1:47729/dashboard/sessions/a%2Fb%20c');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('strips other query/hash from the WS URL — only token is forwarded', () => {
|
|
48
|
+
expect(
|
|
49
|
+
deriveDashboardUrl({
|
|
50
|
+
mcpUrl: 'ws://127.0.0.1:47729/?token=abc&other=secret#hash',
|
|
51
|
+
}),
|
|
52
|
+
).toBe('http://127.0.0.1:47729/dashboard/?token=abc');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns undefined for empty or invalid input', () => {
|
|
56
|
+
expect(deriveDashboardUrl({ mcpUrl: '' })).toBeUndefined();
|
|
57
|
+
expect(deriveDashboardUrl({ mcpUrl: 'not-a-url' })).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert the runtime's `mcpUrl` (a WebSocket URL the plugin gave us) into
|
|
3
|
+
* the dashboard URL the same daemon serves.
|
|
4
|
+
*
|
|
5
|
+
* The daemon binds one HTTP+WS port; the dashboard lives at
|
|
6
|
+
* `<http-scheme>://<host>:<port>/dashboard/`. The token, if any, is
|
|
7
|
+
* carried in the query string so the browser is pre-authenticated on
|
|
8
|
+
* first hit (after which mcp-server hands it off to a cookie — see
|
|
9
|
+
* `packages/mcp-server/src/dashboardSpa.ts`).
|
|
10
|
+
*
|
|
11
|
+
* Optionally deep-links into a session's detail page when `sessionId` is
|
|
12
|
+
* provided.
|
|
13
|
+
*/
|
|
14
|
+
export interface DashboardUrlInput {
|
|
15
|
+
mcpUrl: string;
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function deriveDashboardUrl(input: DashboardUrlInput): string | undefined {
|
|
20
|
+
if (!input.mcpUrl) return undefined;
|
|
21
|
+
let url: URL;
|
|
22
|
+
try {
|
|
23
|
+
url = new URL(input.mcpUrl);
|
|
24
|
+
} catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const httpScheme = url.protocol === 'wss:' ? 'https:' : 'http:';
|
|
28
|
+
const path = input.sessionId
|
|
29
|
+
? `/dashboard/sessions/${encodeURIComponent(input.sessionId)}`
|
|
30
|
+
: '/dashboard/';
|
|
31
|
+
const token = url.searchParams.get('token');
|
|
32
|
+
const search = token ? `?token=${encodeURIComponent(token)}` : '';
|
|
33
|
+
// Build manually so we don't leak any extra query/hash from the WS URL
|
|
34
|
+
// (rare, but be defensive — the agent only ever sees what we hand it).
|
|
35
|
+
return `${httpScheme}//${url.host}${path}${search}`;
|
|
36
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { installFetchPatch } from './fetchPatch.js';
|
|
4
|
+
import type { NetworkEntry } from '@harness-fe/protocol';
|
|
5
|
+
|
|
6
|
+
// happy-dom installs `window.fetch` via Node's undici. We override with a
|
|
7
|
+
// controllable mock per test so we can shape the Response exactly.
|
|
8
|
+
|
|
9
|
+
let originalFetch: typeof globalThis.fetch;
|
|
10
|
+
let entries: NetworkEntry[];
|
|
11
|
+
let dispose: () => void;
|
|
12
|
+
|
|
13
|
+
function setMockFetch(impl: typeof globalThis.fetch): void {
|
|
14
|
+
window.fetch = impl as typeof window.fetch;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
|
18
|
+
return new Response(JSON.stringify(body), {
|
|
19
|
+
status: 200,
|
|
20
|
+
headers: { 'content-type': 'application/json', ...(init.headers ?? {}) },
|
|
21
|
+
...init,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sseStream(chunks: string[]): ReadableStream<Uint8Array> {
|
|
26
|
+
const enc = new TextEncoder();
|
|
27
|
+
let i = 0;
|
|
28
|
+
return new ReadableStream({
|
|
29
|
+
pull(controller) {
|
|
30
|
+
if (i < chunks.length) {
|
|
31
|
+
controller.enqueue(enc.encode(chunks[i++]));
|
|
32
|
+
} else {
|
|
33
|
+
controller.close();
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
originalFetch = window.fetch;
|
|
41
|
+
entries = [];
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
dispose?.();
|
|
46
|
+
window.fetch = originalFetch;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('installFetchPatch — identity', () => {
|
|
50
|
+
it('keeps fetch.name === "fetch"', () => {
|
|
51
|
+
setMockFetch(() => Promise.resolve(new Response('ok')));
|
|
52
|
+
dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
|
|
53
|
+
expect(window.fetch.name).toBe('fetch');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns the original Response without wrapping', async () => {
|
|
57
|
+
const original = new Response('hello', { status: 201 });
|
|
58
|
+
setMockFetch(() => Promise.resolve(original));
|
|
59
|
+
dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
|
|
60
|
+
const got = await window.fetch('http://x/');
|
|
61
|
+
// business reads the body — capture must not have stolen it
|
|
62
|
+
await expect(got.text()).resolves.toBe('hello');
|
|
63
|
+
expect(got.status).toBe(201);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('dispose restores window.fetch', () => {
|
|
67
|
+
const mock: typeof globalThis.fetch = () => Promise.resolve(new Response());
|
|
68
|
+
setMockFetch(mock);
|
|
69
|
+
dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
|
|
70
|
+
expect(window.fetch).not.toBe(mock);
|
|
71
|
+
dispose();
|
|
72
|
+
dispose = () => {};
|
|
73
|
+
expect(window.fetch).toBe(mock);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('re-install while patched is a no-op', () => {
|
|
77
|
+
setMockFetch(() => Promise.resolve(new Response()));
|
|
78
|
+
const d1 = installFetchPatch({ onEntry: (e) => entries.push(e) });
|
|
79
|
+
const first = window.fetch;
|
|
80
|
+
const d2 = installFetchPatch({ onEntry: (e) => entries.push(e) });
|
|
81
|
+
expect(window.fetch).toBe(first);
|
|
82
|
+
d2(); // no-op
|
|
83
|
+
d1();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('installFetchPatch — emission', () => {
|
|
88
|
+
it('emits a req event eagerly and a res event after completion', async () => {
|
|
89
|
+
setMockFetch(() => Promise.resolve(jsonResponse({ ok: true })));
|
|
90
|
+
dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
|
|
91
|
+
await window.fetch('http://x/', { method: 'POST', body: '{"k":1}' });
|
|
92
|
+
// give the body-clone branch a chance to flush
|
|
93
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
94
|
+
const ids = new Set(entries.map((e) => e.id));
|
|
95
|
+
expect(ids.size).toBe(1);
|
|
96
|
+
const phases = entries.map((e) => e.phase);
|
|
97
|
+
expect(phases).toContain('req');
|
|
98
|
+
expect(phases).toContain('res');
|
|
99
|
+
const res = entries.find((e) => e.phase === 'res')!;
|
|
100
|
+
expect(res.status).toBe(200);
|
|
101
|
+
expect(res.responseBody).toEqual({ ok: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('captures and caps a large JSON response body', async () => {
|
|
105
|
+
const huge = 'x'.repeat(2000);
|
|
106
|
+
setMockFetch(() => Promise.resolve(jsonResponse({ s: huge })));
|
|
107
|
+
dispose = installFetchPatch({
|
|
108
|
+
onEntry: (e) => entries.push(e),
|
|
109
|
+
bodyCap: 500,
|
|
110
|
+
});
|
|
111
|
+
await window.fetch('http://x/');
|
|
112
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
113
|
+
const res = entries.find((e) => e.phase === 'res')!;
|
|
114
|
+
expect(res.responseBodyTruncated).toBe(true);
|
|
115
|
+
// body is the raw truncated text when JSON.parse fails
|
|
116
|
+
expect(typeof res.responseBody).toBe('string');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('redacts sensitive request headers', async () => {
|
|
120
|
+
setMockFetch(() => Promise.resolve(new Response('ok')));
|
|
121
|
+
dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
|
|
122
|
+
await window.fetch('http://x/', {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: {
|
|
125
|
+
Authorization: 'Bearer abc.def.ghi',
|
|
126
|
+
'X-Api-Key': 'sk-12345',
|
|
127
|
+
'content-type': 'text/plain',
|
|
128
|
+
},
|
|
129
|
+
body: 'hi',
|
|
130
|
+
});
|
|
131
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
132
|
+
const req = entries.find((e) => e.phase === 'req' && e.requestHeaders)!;
|
|
133
|
+
expect(req.requestHeaders!.Authorization).toMatch(/^\[redacted \d+\]$/);
|
|
134
|
+
expect(req.requestHeaders!['X-Api-Key']).toMatch(/^\[redacted \d+\]$/);
|
|
135
|
+
// non-sensitive header is preserved
|
|
136
|
+
expect(req.requestHeaders!['content-type']).toBe('text/plain');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('emits res with error field when underlying fetch rejects', async () => {
|
|
140
|
+
setMockFetch(() => Promise.reject(new Error('network down')));
|
|
141
|
+
dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
|
|
142
|
+
await expect(window.fetch('http://x/')).rejects.toThrow('network down');
|
|
143
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
144
|
+
const res = entries.find((e) => e.phase === 'res')!;
|
|
145
|
+
expect(res.error).toBe('network down');
|
|
146
|
+
expect(res.status).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('caps an SSE stream and cancels the cloned reader', async () => {
|
|
150
|
+
const longChunk = 'data: ' + 'x'.repeat(2000) + '\n\n';
|
|
151
|
+
setMockFetch(() =>
|
|
152
|
+
Promise.resolve(
|
|
153
|
+
new Response(sseStream([longChunk, longChunk, longChunk]), {
|
|
154
|
+
status: 200,
|
|
155
|
+
headers: { 'content-type': 'text/event-stream' },
|
|
156
|
+
}),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
dispose = installFetchPatch({
|
|
160
|
+
onEntry: (e) => entries.push(e),
|
|
161
|
+
bodyCap: 500,
|
|
162
|
+
});
|
|
163
|
+
const res = await window.fetch('http://x/');
|
|
164
|
+
// business can still read its own stream
|
|
165
|
+
await res.text();
|
|
166
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
167
|
+
const captured = entries.find((e) => e.phase === 'res')!;
|
|
168
|
+
expect(captured.responseBodyTruncated).toBe(true);
|
|
169
|
+
expect((captured.responseBody as string).length).toBe(500);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('skips internal traffic when __hfeInternal flag is set', async () => {
|
|
173
|
+
setMockFetch(() => Promise.resolve(new Response('ok')));
|
|
174
|
+
dispose = installFetchPatch({ onEntry: (e) => entries.push(e) });
|
|
175
|
+
await window.fetch('http://x/', {
|
|
176
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
177
|
+
...({ __hfeInternal: true } as any),
|
|
178
|
+
});
|
|
179
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
180
|
+
expect(entries).toHaveLength(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('skips denylisted URLs', async () => {
|
|
184
|
+
setMockFetch(() => Promise.resolve(new Response('ok')));
|
|
185
|
+
dispose = installFetchPatch({
|
|
186
|
+
onEntry: (e) => entries.push(e),
|
|
187
|
+
denylist: [/example/],
|
|
188
|
+
});
|
|
189
|
+
await window.fetch('http://example.com/__hfe__');
|
|
190
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
191
|
+
expect(entries).toHaveLength(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('does not let an onEntry throw crash business fetch', async () => {
|
|
195
|
+
setMockFetch(() => Promise.resolve(new Response('ok')));
|
|
196
|
+
dispose = installFetchPatch({
|
|
197
|
+
onEntry: () => {
|
|
198
|
+
throw new Error('boom');
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
await expect(window.fetch('http://x/')).resolves.toBeInstanceOf(Response);
|
|
202
|
+
});
|
|
203
|
+
});
|