@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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -0
  3. package/dist/buffer.d.ts +13 -0
  4. package/dist/buffer.js +26 -0
  5. package/dist/capture.d.ts +47 -0
  6. package/dist/capture.js +112 -0
  7. package/dist/client.d.ts +82 -0
  8. package/dist/client.js +364 -0
  9. package/dist/commands.d.ts +10 -0
  10. package/dist/commands.js +304 -0
  11. package/dist/dashboardUrl.d.ts +18 -0
  12. package/dist/dashboardUrl.js +20 -0
  13. package/dist/fetchPatch.d.ts +39 -0
  14. package/dist/fetchPatch.js +311 -0
  15. package/dist/index.d.ts +8 -0
  16. package/dist/index.js +23 -0
  17. package/dist/outbox.d.ts +37 -0
  18. package/dist/outbox.js +80 -0
  19. package/dist/overlay.d.ts +68 -0
  20. package/dist/overlay.js +1946 -0
  21. package/dist/parent-inherit.d.ts +25 -0
  22. package/dist/parent-inherit.js +43 -0
  23. package/dist/recording.d.ts +27 -0
  24. package/dist/recording.js +86 -0
  25. package/dist/rrweb-types.d.ts +13 -0
  26. package/dist/rrweb-types.js +20 -0
  27. package/dist/selectors.d.ts +14 -0
  28. package/dist/selectors.js +91 -0
  29. package/dist/snapshot.d.ts +12 -0
  30. package/dist/snapshot.js +111 -0
  31. package/dist/visitor.d.ts +28 -0
  32. package/dist/visitor.js +107 -0
  33. package/dist/xhrPatch.d.ts +26 -0
  34. package/dist/xhrPatch.js +269 -0
  35. package/package.json +50 -0
  36. package/src/buffer.test.ts +26 -0
  37. package/src/buffer.ts +29 -0
  38. package/src/capture.ts +126 -0
  39. package/src/client.test.ts +89 -0
  40. package/src/client.ts +423 -0
  41. package/src/commands.test.ts +128 -0
  42. package/src/commands.ts +335 -0
  43. package/src/dashboardUrl.test.ts +59 -0
  44. package/src/dashboardUrl.ts +36 -0
  45. package/src/fetchPatch.test.ts +203 -0
  46. package/src/fetchPatch.ts +371 -0
  47. package/src/index.ts +32 -0
  48. package/src/outbox.test.ts +115 -0
  49. package/src/outbox.ts +84 -0
  50. package/src/overlay.test.ts +319 -0
  51. package/src/overlay.ts +2070 -0
  52. package/src/parent-inherit.ts +54 -0
  53. package/src/recording.ts +88 -0
  54. package/src/rrweb-types.test.ts +40 -0
  55. package/src/rrweb-types.ts +24 -0
  56. package/src/selectors.test.ts +50 -0
  57. package/src/selectors.ts +103 -0
  58. package/src/snapshot.ts +112 -0
  59. package/src/visitor.ts +116 -0
  60. package/src/xhrPatch.test.ts +191 -0
  61. package/src/xhrPatch.ts +314 -0
@@ -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
+ });