@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/dist/xhrPatch.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XMLHttpRequest monkey-patch — captures URL/method/headers/body for
|
|
3
|
+
* request and response WITHOUT replacing the XMLHttpRequest constructor.
|
|
4
|
+
*
|
|
5
|
+
* The previous implementation wrapped `window.XMLHttpRequest` with a new
|
|
6
|
+
* constructor, which broke `xhr instanceof XMLHttpRequest` checks in
|
|
7
|
+
* business code. This patch attaches per-instance metadata via a
|
|
8
|
+
* non-enumerable Symbol key and overrides only prototype methods, leaving
|
|
9
|
+
* the constructor and prototype chain native.
|
|
10
|
+
*
|
|
11
|
+
* Capture rules mirror fetchPatch.ts:
|
|
12
|
+
* - 256 KB body cap with content-type routing (json / text / binary)
|
|
13
|
+
* - Sensitive header redaction (Authorization / Cookie / x-api-key / x-auth-*)
|
|
14
|
+
* - Two events per request keyed by a shared `id` (`phase: 'req' | 'res'`)
|
|
15
|
+
* - Errors inside capture are swallowed via `safeEmit`
|
|
16
|
+
* - `__hfeInternal` opt-out via a magic header `x-hfe-internal: 1`
|
|
17
|
+
* (XHR has no init-style options bag like fetch)
|
|
18
|
+
* - Idempotent install + dispose() restores original prototype methods
|
|
19
|
+
*/
|
|
20
|
+
const DEFAULT_BODY_CAP = 256 * 1024;
|
|
21
|
+
const PATCHED_FLAG = '__hfeXhrPatched';
|
|
22
|
+
const META_KEY = Symbol.for('@harness-fe/xhr-meta');
|
|
23
|
+
const INTERNAL_HEADER = 'x-hfe-internal';
|
|
24
|
+
const SENSITIVE_HEADER = /^(authorization|cookie|x-api-key|x-auth-.+)$/i;
|
|
25
|
+
export function installXhrPatch(opts) {
|
|
26
|
+
if (typeof XMLHttpRequest === 'undefined')
|
|
27
|
+
return () => { };
|
|
28
|
+
const proto = XMLHttpRequest.prototype;
|
|
29
|
+
if (proto[PATCHED_FLAG])
|
|
30
|
+
return () => { };
|
|
31
|
+
const bodyCap = opts.bodyCap ?? DEFAULT_BODY_CAP;
|
|
32
|
+
const denylist = opts.denylist ?? [];
|
|
33
|
+
const emit = (entry) => safeEmit(opts.onEntry, entry);
|
|
34
|
+
const origOpen = proto.open;
|
|
35
|
+
const origSetHeader = proto.setRequestHeader;
|
|
36
|
+
const origSend = proto.send;
|
|
37
|
+
const patchedOpen = function open(method, url, ...rest) {
|
|
38
|
+
const meta = {
|
|
39
|
+
id: generateId(),
|
|
40
|
+
method,
|
|
41
|
+
url: typeof url === 'string' ? url : url.toString(),
|
|
42
|
+
headers: {},
|
|
43
|
+
startedAt: 0,
|
|
44
|
+
startedTs: 0,
|
|
45
|
+
bodyCap,
|
|
46
|
+
internal: false,
|
|
47
|
+
skipped: denylist.some((re) => re.test(typeof url === 'string' ? url : url.toString())),
|
|
48
|
+
reqEmitted: false,
|
|
49
|
+
};
|
|
50
|
+
this[META_KEY] = meta;
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
52
|
+
return origOpen.call(this, method, url, ...rest);
|
|
53
|
+
};
|
|
54
|
+
const patchedSetHeader = function setRequestHeader(name, value) {
|
|
55
|
+
const meta = this[META_KEY];
|
|
56
|
+
if (meta) {
|
|
57
|
+
if (name.toLowerCase() === INTERNAL_HEADER) {
|
|
58
|
+
meta.internal = true;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
meta.headers[name] = value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Do NOT forward the internal sentinel to the server.
|
|
65
|
+
if (name.toLowerCase() === INTERNAL_HEADER)
|
|
66
|
+
return;
|
|
67
|
+
return origSetHeader.call(this, name, value);
|
|
68
|
+
};
|
|
69
|
+
const patchedSend = function send(body) {
|
|
70
|
+
const meta = this[META_KEY];
|
|
71
|
+
if (!meta || meta.internal || meta.skipped) {
|
|
72
|
+
return origSend.call(this, body ?? null);
|
|
73
|
+
}
|
|
74
|
+
meta.startedAt = performance.now();
|
|
75
|
+
meta.startedTs = Date.now();
|
|
76
|
+
// Emit req eagerly with headers; body added on second emit after
|
|
77
|
+
// serialization (mirrors fetchPatch behavior).
|
|
78
|
+
const reqRecord = {
|
|
79
|
+
ts: meta.startedTs,
|
|
80
|
+
id: meta.id,
|
|
81
|
+
phase: 'req',
|
|
82
|
+
method: meta.method,
|
|
83
|
+
url: meta.url,
|
|
84
|
+
requestHeaders: redactHeaders(meta.headers),
|
|
85
|
+
};
|
|
86
|
+
emit(reqRecord);
|
|
87
|
+
meta.reqEmitted = true;
|
|
88
|
+
const serialized = serializeBody(body, meta.bodyCap);
|
|
89
|
+
if (serialized.body !== undefined || serialized.truncated) {
|
|
90
|
+
emit({
|
|
91
|
+
...reqRecord,
|
|
92
|
+
requestBody: serialized.body,
|
|
93
|
+
requestBodyTruncated: serialized.truncated || undefined,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
this.addEventListener('loadend', () => {
|
|
97
|
+
const status = this.status;
|
|
98
|
+
const ct = safeGetResponseHeader(this, 'content-type') ?? '';
|
|
99
|
+
const respHeaders = parseAllResponseHeaders(this);
|
|
100
|
+
const isErr = status === 0;
|
|
101
|
+
const baseRes = {
|
|
102
|
+
ts: Date.now(),
|
|
103
|
+
id: meta.id,
|
|
104
|
+
phase: 'res',
|
|
105
|
+
method: meta.method,
|
|
106
|
+
url: meta.url,
|
|
107
|
+
status: isErr ? undefined : status,
|
|
108
|
+
responseHeaders: respHeaders,
|
|
109
|
+
durationMs: performance.now() - meta.startedAt,
|
|
110
|
+
};
|
|
111
|
+
if (isErr) {
|
|
112
|
+
emit({ ...baseRes, error: 'xhr error or aborted' });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (isTextLike(ct)) {
|
|
116
|
+
const text = safeReadResponseText(this);
|
|
117
|
+
if (text === undefined) {
|
|
118
|
+
emit(baseRes);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const capped = capText(text, meta.bodyCap);
|
|
122
|
+
emit({
|
|
123
|
+
...baseRes,
|
|
124
|
+
responseBody: /json/i.test(ct) ? safeParseJson(capped.body) : capped.body,
|
|
125
|
+
responseBodyTruncated: capped.truncated || undefined,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
// Binary or unknown → don't pull bytes, just record size when available.
|
|
130
|
+
const len = Number(safeGetResponseHeader(this, 'content-length') ?? '0') || 0;
|
|
131
|
+
emit({
|
|
132
|
+
...baseRes,
|
|
133
|
+
responseBody: len ? `[binary ${len}B]` : '[binary]',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
return origSend.call(this, body ?? null);
|
|
138
|
+
};
|
|
139
|
+
proto.open = patchedOpen;
|
|
140
|
+
proto.setRequestHeader = patchedSetHeader;
|
|
141
|
+
proto.send = patchedSend;
|
|
142
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
143
|
+
proto[PATCHED_FLAG] = true;
|
|
144
|
+
return () => {
|
|
145
|
+
// Only restore if we still own the patch — don't clobber a later patch.
|
|
146
|
+
if (proto.open !== patchedOpen)
|
|
147
|
+
return;
|
|
148
|
+
proto.open = origOpen;
|
|
149
|
+
proto.setRequestHeader = origSetHeader;
|
|
150
|
+
proto.send = origSend;
|
|
151
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
152
|
+
delete proto[PATCHED_FLAG];
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
156
|
+
function generateId() {
|
|
157
|
+
try {
|
|
158
|
+
return crypto.randomUUID();
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function redactHeaders(h) {
|
|
165
|
+
const out = {};
|
|
166
|
+
for (const [k, v] of Object.entries(h)) {
|
|
167
|
+
out[k] = SENSITIVE_HEADER.test(k) ? `[redacted ${String(v).length}]` : v;
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
function serializeBody(body, cap) {
|
|
172
|
+
if (body === undefined || body === null)
|
|
173
|
+
return { truncated: false };
|
|
174
|
+
if (typeof body === 'string')
|
|
175
|
+
return capText(body, cap);
|
|
176
|
+
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
|
177
|
+
const obj = {};
|
|
178
|
+
body.forEach((v, k) => {
|
|
179
|
+
obj[k] = typeof v === 'string'
|
|
180
|
+
? v
|
|
181
|
+
: `[File ${v.name} ${v.size}B]`;
|
|
182
|
+
});
|
|
183
|
+
return { body: obj, truncated: false };
|
|
184
|
+
}
|
|
185
|
+
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
|
|
186
|
+
return { body: body.toString(), truncated: false };
|
|
187
|
+
}
|
|
188
|
+
if (typeof Blob !== 'undefined' && body instanceof Blob) {
|
|
189
|
+
return { body: `[Blob ${body.size}B]`, truncated: false };
|
|
190
|
+
}
|
|
191
|
+
if (body instanceof ArrayBuffer) {
|
|
192
|
+
return { body: `[ArrayBuffer ${body.byteLength}B]`, truncated: false };
|
|
193
|
+
}
|
|
194
|
+
if (ArrayBuffer.isView(body)) {
|
|
195
|
+
return {
|
|
196
|
+
body: `[${body.constructor.name} ${body.byteLength}B]`,
|
|
197
|
+
truncated: false,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (typeof Document !== 'undefined' && body instanceof Document) {
|
|
201
|
+
return { body: '[Document]', truncated: false };
|
|
202
|
+
}
|
|
203
|
+
return { body: '[unknown body]', truncated: false };
|
|
204
|
+
}
|
|
205
|
+
function capText(text, cap) {
|
|
206
|
+
if (text.length > cap)
|
|
207
|
+
return { body: text.slice(0, cap), truncated: true };
|
|
208
|
+
return { body: text, truncated: false };
|
|
209
|
+
}
|
|
210
|
+
function safeGetResponseHeader(xhr, name) {
|
|
211
|
+
try {
|
|
212
|
+
return xhr.getResponseHeader(name);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function parseAllResponseHeaders(xhr) {
|
|
219
|
+
let raw;
|
|
220
|
+
try {
|
|
221
|
+
raw = xhr.getAllResponseHeaders();
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
if (!raw)
|
|
227
|
+
return undefined;
|
|
228
|
+
const out = {};
|
|
229
|
+
for (const line of raw.split('\r\n')) {
|
|
230
|
+
const idx = line.indexOf(':');
|
|
231
|
+
if (idx < 0)
|
|
232
|
+
continue;
|
|
233
|
+
const k = line.slice(0, idx).trim();
|
|
234
|
+
const v = line.slice(idx + 1).trim();
|
|
235
|
+
if (k)
|
|
236
|
+
out[k] = v;
|
|
237
|
+
}
|
|
238
|
+
return Object.keys(out).length ? redactHeaders(out) : undefined;
|
|
239
|
+
}
|
|
240
|
+
function safeReadResponseText(xhr) {
|
|
241
|
+
try {
|
|
242
|
+
// responseType must be '' or 'text' to access responseText.
|
|
243
|
+
if (xhr.responseType !== '' && xhr.responseType !== 'text')
|
|
244
|
+
return undefined;
|
|
245
|
+
return xhr.responseText;
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function safeParseJson(text) {
|
|
252
|
+
try {
|
|
253
|
+
return JSON.parse(text);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return text;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function isTextLike(ct) {
|
|
260
|
+
return /json|text|xml|javascript|x-www-form-urlencoded/i.test(ct);
|
|
261
|
+
}
|
|
262
|
+
function safeEmit(fn, entry) {
|
|
263
|
+
try {
|
|
264
|
+
fn(entry);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
/* swallow */
|
|
268
|
+
}
|
|
269
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@harness-fe/runtime",
|
|
3
|
+
"version": "3.0.1",
|
|
4
|
+
"description": "Browser-side SDK injected into the dev page. Connects to the MCP server via WebSocket and executes commands.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/Morphicai/harness-fe.git",
|
|
10
|
+
"directory": "packages/runtime-client"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/Morphicai/harness-fe#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Morphicai/harness-fe/issues"
|
|
15
|
+
},
|
|
16
|
+
"main": "dist/index.js",
|
|
17
|
+
"types": "dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"src",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@zumer/snapdom": "^2.12.0",
|
|
32
|
+
"rrweb": "2.0.0-alpha.4",
|
|
33
|
+
"@harness-fe/protocol": "3.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"happy-dom": "^20.9.0",
|
|
37
|
+
"typescript": "^5.6.0",
|
|
38
|
+
"vitest": "^2.1.9"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc",
|
|
45
|
+
"dev": "tsc --watch --preserveWatchOutput",
|
|
46
|
+
"watch": "tsc --watch --preserveWatchOutput",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"test": "vitest run"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { RingBuffer } from './buffer.js';
|
|
3
|
+
|
|
4
|
+
describe('RingBuffer', () => {
|
|
5
|
+
it('respects capacity', () => {
|
|
6
|
+
const buf = new RingBuffer<number>(3);
|
|
7
|
+
for (let i = 1; i <= 5; i++) buf.push(i);
|
|
8
|
+
expect(buf.size()).toBe(3);
|
|
9
|
+
expect(buf.tail(3)).toEqual([3, 4, 5]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('tail(n) returns last n items', () => {
|
|
13
|
+
const buf = new RingBuffer<string>(10);
|
|
14
|
+
['a', 'b', 'c', 'd'].forEach((v) => buf.push(v));
|
|
15
|
+
expect(buf.tail(2)).toEqual(['c', 'd']);
|
|
16
|
+
expect(buf.tail(99)).toEqual(['a', 'b', 'c', 'd']);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('clear empties the buffer', () => {
|
|
20
|
+
const buf = new RingBuffer<number>(3);
|
|
21
|
+
buf.push(1);
|
|
22
|
+
buf.clear();
|
|
23
|
+
expect(buf.size()).toBe(0);
|
|
24
|
+
expect(buf.tail(5)).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
});
|
package/src/buffer.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded ring buffer for console / network / error tails.
|
|
3
|
+
* O(1) push, O(n) drain. Fixed capacity to keep page memory bounded.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class RingBuffer<T> {
|
|
7
|
+
private items: T[] = [];
|
|
8
|
+
|
|
9
|
+
constructor(private readonly capacity: number) {}
|
|
10
|
+
|
|
11
|
+
push(item: T): void {
|
|
12
|
+
this.items.push(item);
|
|
13
|
+
if (this.items.length > this.capacity) {
|
|
14
|
+
this.items.splice(0, this.items.length - this.capacity);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
tail(n: number): T[] {
|
|
19
|
+
return this.items.slice(Math.max(0, this.items.length - n));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
size(): number {
|
|
23
|
+
return this.items.length;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
clear(): void {
|
|
27
|
+
this.items = [];
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/capture.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console + network + error capture. Monkey-patches in place once on init.
|
|
3
|
+
*
|
|
4
|
+
* Network capture covers fetch + XMLHttpRequest. Body capture is opt-in
|
|
5
|
+
* per-request to keep memory bounded.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
ConsoleEntry,
|
|
10
|
+
ErrorEntry,
|
|
11
|
+
NetworkEntry,
|
|
12
|
+
} from '@harness-fe/protocol';
|
|
13
|
+
import { RingBuffer } from './buffer.js';
|
|
14
|
+
import { installFetchPatch } from './fetchPatch.js';
|
|
15
|
+
import { installXhrPatch } from './xhrPatch.js';
|
|
16
|
+
|
|
17
|
+
const CONSOLE_CAP = 500;
|
|
18
|
+
const NETWORK_CAP = 200;
|
|
19
|
+
const ERROR_CAP = 200;
|
|
20
|
+
|
|
21
|
+
export class CaptureStore {
|
|
22
|
+
readonly console = new RingBuffer<ConsoleEntry>(CONSOLE_CAP);
|
|
23
|
+
readonly network = new RingBuffer<NetworkEntry>(NETWORK_CAP);
|
|
24
|
+
readonly errors = new RingBuffer<ErrorEntry>(ERROR_CAP);
|
|
25
|
+
|
|
26
|
+
private installed = false;
|
|
27
|
+
private fetchDispose?: () => void;
|
|
28
|
+
private xhrDispose?: () => void;
|
|
29
|
+
|
|
30
|
+
install(onEvent: (name: string, payload: unknown) => void): void {
|
|
31
|
+
if (this.installed) return;
|
|
32
|
+
this.installed = true;
|
|
33
|
+
this.installConsole(onEvent);
|
|
34
|
+
this.installFetch(onEvent);
|
|
35
|
+
this.installXhr(onEvent);
|
|
36
|
+
this.installErrors(onEvent);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
dispose(): void {
|
|
40
|
+
this.fetchDispose?.();
|
|
41
|
+
this.fetchDispose = undefined;
|
|
42
|
+
this.xhrDispose?.();
|
|
43
|
+
this.xhrDispose = undefined;
|
|
44
|
+
this.installed = false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private installConsole(onEvent: (name: string, payload: unknown) => void): void {
|
|
48
|
+
const methods: Array<ConsoleEntry['level']> = ['log', 'info', 'warn', 'error', 'debug'];
|
|
49
|
+
for (const level of methods) {
|
|
50
|
+
const original = console[level].bind(console);
|
|
51
|
+
console[level] = (...args: unknown[]) => {
|
|
52
|
+
const entry: ConsoleEntry = {
|
|
53
|
+
ts: Date.now(),
|
|
54
|
+
level,
|
|
55
|
+
args: args.map(safeClone),
|
|
56
|
+
};
|
|
57
|
+
this.console.push(entry);
|
|
58
|
+
onEvent('console', entry);
|
|
59
|
+
original(...args);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
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
|
+
}
|
|
109
|
+
|
|
110
|
+
let captureStoreSingleton: CaptureStore | undefined;
|
|
111
|
+
export function getCaptureStore(): CaptureStore {
|
|
112
|
+
captureStoreSingleton ??= new CaptureStore();
|
|
113
|
+
return captureStoreSingleton;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function safeClone(value: unknown): unknown {
|
|
117
|
+
if (value === null) return null;
|
|
118
|
+
if (typeof value === 'object') {
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(JSON.stringify(value));
|
|
121
|
+
} catch {
|
|
122
|
+
return String(value);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, afterEach } from 'vitest';
|
|
3
|
+
import { tryInheritFromParent } from './parent-inherit.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* tryInheritFromParent has three branches:
|
|
7
|
+
* 1. `window.parent === window` → top-level page, return {}
|
|
8
|
+
* 2. same-origin parent with harness-fe runtime → read tabId/sessionId/projectId
|
|
9
|
+
* 3. cross-origin parent → SecurityError when accessing parent props → catch → {}
|
|
10
|
+
*
|
|
11
|
+
* happy-dom doesn't enforce cross-origin security boundaries, so branch 3 is
|
|
12
|
+
* simulated by replacing `window.parent` with a Proxy that throws.
|
|
13
|
+
*/
|
|
14
|
+
describe('tryInheritFromParent', () => {
|
|
15
|
+
const origParentDescriptor = Object.getOwnPropertyDescriptor(window, 'parent');
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (origParentDescriptor) {
|
|
19
|
+
Object.defineProperty(window, 'parent', origParentDescriptor);
|
|
20
|
+
}
|
|
21
|
+
delete (window as any).__hfe_session_id__;
|
|
22
|
+
delete (window as any).__harness_fe_client__;
|
|
23
|
+
delete (window as any).__HARNESS_FE__;
|
|
24
|
+
try {
|
|
25
|
+
sessionStorage.removeItem('__hfe_tab_id__');
|
|
26
|
+
} catch {
|
|
27
|
+
/* noop */
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns empty object when running at top level (parent === window)', () => {
|
|
32
|
+
// happy-dom default: window.parent === window
|
|
33
|
+
expect(window.parent).toBe(window);
|
|
34
|
+
expect(tryInheritFromParent()).toEqual({});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('reads parent tabId / sessionId / projectId when same-origin parent has harness-fe', () => {
|
|
38
|
+
const fakeParent = {
|
|
39
|
+
__hfe_session_id__: 'parent-session-xyz',
|
|
40
|
+
__harness_fe_client__: {
|
|
41
|
+
tabId: 'parent-tab-abc',
|
|
42
|
+
sessionId: 'parent-session-xyz',
|
|
43
|
+
},
|
|
44
|
+
__HARNESS_FE__: { projectId: 'iframe-parent' },
|
|
45
|
+
// sessionStorage isn't read because __harness_fe_client__.tabId
|
|
46
|
+
// already returns a value. Keeping it minimal here.
|
|
47
|
+
sessionStorage: undefined as unknown as Storage,
|
|
48
|
+
};
|
|
49
|
+
Object.defineProperty(window, 'parent', { value: fakeParent, configurable: true });
|
|
50
|
+
|
|
51
|
+
expect(tryInheritFromParent()).toEqual({
|
|
52
|
+
tabId: 'parent-tab-abc',
|
|
53
|
+
sessionId: 'parent-session-xyz',
|
|
54
|
+
parentProjectId: 'iframe-parent',
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('falls back to parent.sessionStorage for tabId when no client global is exposed yet', () => {
|
|
59
|
+
// Simulates: parent runtime hasn't finished booting; sessionStorage
|
|
60
|
+
// is already populated from a previous tab session.
|
|
61
|
+
const fakeStorage = {
|
|
62
|
+
getItem: (key: string) => (key === '__hfe_tab_id__' ? 'storage-tab' : null),
|
|
63
|
+
} as unknown as Storage;
|
|
64
|
+
const fakeParent = {
|
|
65
|
+
__HARNESS_FE__: { projectId: 'iframe-parent' },
|
|
66
|
+
sessionStorage: fakeStorage,
|
|
67
|
+
};
|
|
68
|
+
Object.defineProperty(window, 'parent', { value: fakeParent, configurable: true });
|
|
69
|
+
|
|
70
|
+
const out = tryInheritFromParent();
|
|
71
|
+
expect(out.tabId).toBe('storage-tab');
|
|
72
|
+
expect(out.parentProjectId).toBe('iframe-parent');
|
|
73
|
+
expect(out.sessionId).toBeUndefined(); // parent hasn't booted yet
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns empty object on cross-origin SecurityError', () => {
|
|
77
|
+
// Cross-origin: any property read on window.parent throws.
|
|
78
|
+
const evilParent = new Proxy(
|
|
79
|
+
{},
|
|
80
|
+
{
|
|
81
|
+
get(): never {
|
|
82
|
+
throw new DOMException('Blocked a frame...', 'SecurityError');
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
Object.defineProperty(window, 'parent', { value: evilParent, configurable: true });
|
|
87
|
+
expect(tryInheritFromParent()).toEqual({});
|
|
88
|
+
});
|
|
89
|
+
});
|