@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/client.ts
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime client core. Connects to the MCP server over WS, executes
|
|
3
|
+
* commands dispatched by the server, and forwards page events back.
|
|
4
|
+
*
|
|
5
|
+
* Started lazily by `auto-start.ts` when the script is imported.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
COMMAND,
|
|
10
|
+
DEFAULT_WS_PORT,
|
|
11
|
+
EVENT_NAME,
|
|
12
|
+
type CommandFrame,
|
|
13
|
+
type EventFrame,
|
|
14
|
+
type Frame,
|
|
15
|
+
type HelloAckFrame,
|
|
16
|
+
type HelloFrame,
|
|
17
|
+
type ResponseFrame,
|
|
18
|
+
frameSchema,
|
|
19
|
+
} from '@harness-fe/protocol';
|
|
20
|
+
import { getCaptureStore } from './capture.js';
|
|
21
|
+
import { commandHandlers, type CommandContext } from './commands.js';
|
|
22
|
+
import { Outbox } from './outbox.js';
|
|
23
|
+
import { RrwebRecorder } from './recording.js';
|
|
24
|
+
import { chunkHasFullSnapshot } from './rrweb-types.js';
|
|
25
|
+
import { collectPageLoadSnapshot } from './snapshot.js';
|
|
26
|
+
import {
|
|
27
|
+
collectEnv,
|
|
28
|
+
getOrCreateVisitorId,
|
|
29
|
+
publishVisitorIdToWindow,
|
|
30
|
+
tryInheritVisitorFromParent,
|
|
31
|
+
} from './visitor.js';
|
|
32
|
+
import type { QueryFrame, QueryMethod, QueryResponseFrame } from '@harness-fe/protocol';
|
|
33
|
+
|
|
34
|
+
export interface ClientOptions {
|
|
35
|
+
projectId: string;
|
|
36
|
+
mcpUrl?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Build artifact id, threaded through `window.__HARNESS_FE__.buildId`.
|
|
39
|
+
* Stamped on every event so agents can trace "what code was running".
|
|
40
|
+
*/
|
|
41
|
+
buildId?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Parent project's id. Set by the plugin when the host app declares it,
|
|
44
|
+
* or auto-inferred at runtime via `tryInheritFromParent()` when this
|
|
45
|
+
* runtime is loaded inside a same-origin iframe.
|
|
46
|
+
*/
|
|
47
|
+
parentProjectId?: string;
|
|
48
|
+
/** Optional human-readable name; mostly used by the project tree. */
|
|
49
|
+
displayName?: string;
|
|
50
|
+
/**
|
|
51
|
+
* App-supplied user identifier (e.g. supabase.user.id, auth0 sub, …).
|
|
52
|
+
* Optional. When absent, traffic is treated as anonymous (only stitched
|
|
53
|
+
* by visitorId). Propagated by HarnessScript via window.__HARNESS_FE__.userId.
|
|
54
|
+
*/
|
|
55
|
+
userId?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const TAB_ID_KEY = '__hfe_tab_id__';
|
|
59
|
+
|
|
60
|
+
function getOrCreateTabId(): string {
|
|
61
|
+
try {
|
|
62
|
+
const existing = sessionStorage.getItem(TAB_ID_KEY);
|
|
63
|
+
if (existing) return existing;
|
|
64
|
+
const id = `${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`;
|
|
65
|
+
sessionStorage.setItem(TAB_ID_KEY, id);
|
|
66
|
+
return id;
|
|
67
|
+
} catch {
|
|
68
|
+
return `${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Generate a fresh sessionId for this page load. Intentionally NOT persisted
|
|
74
|
+
* to sessionStorage — a refresh MUST yield a new id. WebSocket reconnects
|
|
75
|
+
* within the same page load reuse this in-memory value.
|
|
76
|
+
*
|
|
77
|
+
* (Previously called `loadId`; renamed to align with the narrative model
|
|
78
|
+
* where one page-load = one "session" of user activity.)
|
|
79
|
+
*/
|
|
80
|
+
function generateSessionId(): string {
|
|
81
|
+
try {
|
|
82
|
+
return crypto.randomUUID();
|
|
83
|
+
} catch {
|
|
84
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Attempt to read a server-generated sessionId from `window.__HARNESS_FE_SEED__`
|
|
90
|
+
* or from `window.__HARNESS_FE__.sessionId` (both written by `<HarnessScript>`).
|
|
91
|
+
*
|
|
92
|
+
* When found, the client adopts that id instead of generating its own. This
|
|
93
|
+
* ensures server-side events emitted by `@harness-fe/node-runtime` during
|
|
94
|
+
* the same request and client-side events all land in the same
|
|
95
|
+
* `sessions/{sessionId}/timeline.jsonl` on the daemon.
|
|
96
|
+
*
|
|
97
|
+
* Returns `undefined` when no seed is present (e.g. app doesn't use
|
|
98
|
+
* `<HarnessScript>` or running outside a browser).
|
|
99
|
+
*/
|
|
100
|
+
function tryAdoptServerSeed(): string | undefined {
|
|
101
|
+
if (typeof window === 'undefined') return undefined;
|
|
102
|
+
const w = window as unknown as {
|
|
103
|
+
__HARNESS_FE_SEED__?: { sessionId?: string };
|
|
104
|
+
__HARNESS_FE__?: { sessionId?: string };
|
|
105
|
+
};
|
|
106
|
+
return w.__HARNESS_FE_SEED__?.sessionId ?? w.__HARNESS_FE__?.sessionId;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Re-export inheritance helper. Implementation lives in parent-inherit.ts
|
|
110
|
+
// so its unit tests can import it without dragging the rrweb-dependent
|
|
111
|
+
// recorder module into the test runtime.
|
|
112
|
+
export { tryInheritFromParent } from './parent-inherit.js';
|
|
113
|
+
export type { ParentInheritance } from './parent-inherit.js';
|
|
114
|
+
import { tryInheritFromParent as _tryInheritFromParent } from './parent-inherit.js';
|
|
115
|
+
|
|
116
|
+
export class RuntimeClient {
|
|
117
|
+
private ws?: WebSocket;
|
|
118
|
+
readonly tabId: string;
|
|
119
|
+
readonly sessionId: string;
|
|
120
|
+
readonly visitorId: string;
|
|
121
|
+
readonly parentProjectId?: string;
|
|
122
|
+
|
|
123
|
+
/** Read-only accessors exposed for the in-page info panel. */
|
|
124
|
+
get projectId(): string { return this.opts.projectId; }
|
|
125
|
+
get buildId(): string | undefined { return this.opts.buildId; }
|
|
126
|
+
get displayName(): string | undefined { return this.opts.displayName; }
|
|
127
|
+
get userId(): string | undefined { return this.opts.userId; }
|
|
128
|
+
get mcpUrl(): string | undefined { return this.opts.mcpUrl; }
|
|
129
|
+
/** WebSocket state: 'connecting' | 'open' | 'closed'. */
|
|
130
|
+
getConnectionState(): 'connecting' | 'open' | 'closed' {
|
|
131
|
+
if (!this.ws) return 'closed';
|
|
132
|
+
switch (this.ws.readyState) {
|
|
133
|
+
case WebSocket.OPEN: return 'open';
|
|
134
|
+
case WebSocket.CONNECTING: return 'connecting';
|
|
135
|
+
default: return 'closed';
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
private pageLoadSent = false;
|
|
139
|
+
private readonly ctx: CommandContext = { capture: getCaptureStore() };
|
|
140
|
+
private readonly recorder = new RrwebRecorder((chunk) => this.sendEvent(EVENT_NAME.RRWEB, chunk));
|
|
141
|
+
private reconnectAttempts = 0;
|
|
142
|
+
private closed = false;
|
|
143
|
+
private static readonly MAX_OUTBOX_FRAMES = 500;
|
|
144
|
+
private static readonly MAX_OUTBOX_BYTES = 8 * 1024 * 1024;
|
|
145
|
+
private readonly outbox = new Outbox(
|
|
146
|
+
RuntimeClient.MAX_OUTBOX_FRAMES,
|
|
147
|
+
RuntimeClient.MAX_OUTBOX_BYTES,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
constructor(private readonly opts: ClientOptions) {
|
|
151
|
+
const inherited = _tryInheritFromParent();
|
|
152
|
+
this.tabId = inherited.tabId ?? getOrCreateTabId();
|
|
153
|
+
// Priority: iframe parent seed > server seed > fresh generation.
|
|
154
|
+
this.sessionId = inherited.sessionId ?? tryAdoptServerSeed() ?? generateSessionId();
|
|
155
|
+
// Explicit option wins over runtime auto-detection.
|
|
156
|
+
this.parentProjectId = opts.parentProjectId ?? inherited.parentProjectId;
|
|
157
|
+
// Same-origin iframes share a visitorId so the journey stitches across
|
|
158
|
+
// micro-frontends. Cross-origin children fall back to their own.
|
|
159
|
+
const inheritedVisitor = tryInheritVisitorFromParent();
|
|
160
|
+
this.visitorId = inheritedVisitor ?? getOrCreateVisitorId();
|
|
161
|
+
publishVisitorIdToWindow(this.visitorId);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
start(): void {
|
|
166
|
+
this.ctx.capture.install((name, payload) => this.sendEvent(name, payload));
|
|
167
|
+
this.recorder.start();
|
|
168
|
+
this.connect();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
stop(): void {
|
|
172
|
+
this.closed = true;
|
|
173
|
+
this.recorder.stop();
|
|
174
|
+
this.ws?.close();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private connect(): void {
|
|
178
|
+
const url = this.opts.mcpUrl ?? `ws://127.0.0.1:${DEFAULT_WS_PORT}`;
|
|
179
|
+
try {
|
|
180
|
+
this.ws = new WebSocket(url);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
console.warn('[morphix-dev-bridge] failed to construct WebSocket', err);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
this.ws.addEventListener('open', () => this.onOpen());
|
|
186
|
+
this.ws.addEventListener('message', (ev) => this.onMessage(ev));
|
|
187
|
+
this.ws.addEventListener('close', () => this.onClose());
|
|
188
|
+
this.ws.addEventListener('error', () => {
|
|
189
|
+
/* close will follow */
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private onOpen(): void {
|
|
194
|
+
this.reconnectAttempts = 0;
|
|
195
|
+
const hello: HelloFrame = {
|
|
196
|
+
type: 'hello',
|
|
197
|
+
id: crypto.randomUUID(),
|
|
198
|
+
role: 'runtime-client',
|
|
199
|
+
projectId: this.opts.projectId,
|
|
200
|
+
parentProjectId: this.parentProjectId,
|
|
201
|
+
displayName: this.opts.displayName,
|
|
202
|
+
buildId: this.opts.buildId,
|
|
203
|
+
tabId: this.tabId,
|
|
204
|
+
sessionId: this.sessionId,
|
|
205
|
+
visitorId: this.visitorId,
|
|
206
|
+
userId: this.opts.userId,
|
|
207
|
+
env: collectEnv(),
|
|
208
|
+
page: {
|
|
209
|
+
url: location.href,
|
|
210
|
+
title: document.title,
|
|
211
|
+
userAgent: navigator.userAgent,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
this.send(hello);
|
|
215
|
+
// Any pre-OPEN frames (rrweb chunk 1 with the Meta+FullSnapshot
|
|
216
|
+
// baseline is the canonical example) get flushed *after* hello, so
|
|
217
|
+
// the daemon has a registered peer before they arrive.
|
|
218
|
+
this.drainOutbox();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private onClose(): void {
|
|
222
|
+
if (this.closed) return;
|
|
223
|
+
const delay = Math.min(15_000, 500 * 2 ** Math.min(this.reconnectAttempts, 5));
|
|
224
|
+
this.reconnectAttempts++;
|
|
225
|
+
setTimeout(() => {
|
|
226
|
+
if (!this.closed) this.connect();
|
|
227
|
+
}, delay);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private onMessage(ev: MessageEvent): void {
|
|
231
|
+
let parsed: unknown;
|
|
232
|
+
try {
|
|
233
|
+
parsed = JSON.parse(String(ev.data));
|
|
234
|
+
} catch {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const result = frameSchema.safeParse(parsed);
|
|
238
|
+
if (!result.success) return;
|
|
239
|
+
const frame = result.data;
|
|
240
|
+
if (frame.type === 'command') this.handleCommand(frame);
|
|
241
|
+
else if (frame.type === 'hello.ack') this.onHelloAck(frame);
|
|
242
|
+
else if (frame.type === 'query.response') this.onQueryResponse(frame);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private onQueryResponse(frame: QueryResponseFrame): void {
|
|
246
|
+
const pending = this.pendingQueries.get(frame.id);
|
|
247
|
+
if (!pending) return;
|
|
248
|
+
this.pendingQueries.delete(frame.id);
|
|
249
|
+
if (frame.ok) {
|
|
250
|
+
pending.resolve(frame.result);
|
|
251
|
+
} else {
|
|
252
|
+
pending.reject(new Error(frame.error?.message ?? 'query failed'));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private onHelloAck(frame: HelloAckFrame): void {
|
|
257
|
+
if (frame.error) {
|
|
258
|
+
// Bridge rejected this hello — do not send PAGE_LOAD.
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// Force a fresh rrweb FullSnapshot on every ack — including reconnects
|
|
262
|
+
// after daemon restart, network blips, or page-recovery from sleep.
|
|
263
|
+
// Without this, the only baseline for the session is whatever rrweb
|
|
264
|
+
// emitted at start(); if that chunk was evicted from the outbox
|
|
265
|
+
// (FIFO overflow during a long disconnect) or the daemon was down at
|
|
266
|
+
// the critical moment, the session is unreplayable forever.
|
|
267
|
+
// Safe to call on every ack: rrweb emits another type:2, replay
|
|
268
|
+
// engines treat additional baselines as a checkpoint reset.
|
|
269
|
+
this.recorder.takeFullSnapshot();
|
|
270
|
+
|
|
271
|
+
// Send the page-load snapshot exactly once per load. The reconnect
|
|
272
|
+
// path also lands here; emit only on the first ack of this load.
|
|
273
|
+
if (this.pageLoadSent) return;
|
|
274
|
+
this.pageLoadSent = true;
|
|
275
|
+
try {
|
|
276
|
+
const payload = collectPageLoadSnapshot(this.sessionId);
|
|
277
|
+
this.sendEvent(EVENT_NAME.PAGE_LOAD, payload);
|
|
278
|
+
} catch {
|
|
279
|
+
/* snapshot failures must not propagate */
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private async handleCommand(frame: CommandFrame): Promise<void> {
|
|
284
|
+
const handler = commandHandlers[frame.command];
|
|
285
|
+
if (!handler) {
|
|
286
|
+
this.send({
|
|
287
|
+
type: 'response',
|
|
288
|
+
id: frame.id,
|
|
289
|
+
ok: false,
|
|
290
|
+
error: { code: 'UNKNOWN_COMMAND', message: `no handler for "${frame.command}"` },
|
|
291
|
+
} satisfies ResponseFrame);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
const result = await handler(frame.args ?? {}, this.ctx);
|
|
296
|
+
this.send({
|
|
297
|
+
type: 'response',
|
|
298
|
+
id: frame.id,
|
|
299
|
+
ok: true,
|
|
300
|
+
result,
|
|
301
|
+
} satisfies ResponseFrame);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
304
|
+
this.send({
|
|
305
|
+
type: 'response',
|
|
306
|
+
id: frame.id,
|
|
307
|
+
ok: false,
|
|
308
|
+
error: { message },
|
|
309
|
+
} satisfies ResponseFrame);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
sendEvent(name: string, payload: unknown): void {
|
|
314
|
+
const event: EventFrame = {
|
|
315
|
+
type: 'event',
|
|
316
|
+
id: crypto.randomUUID(),
|
|
317
|
+
tabId: this.tabId,
|
|
318
|
+
projectId: this.opts.projectId,
|
|
319
|
+
// v0.2: stamp every event with sessionId + buildId so cross-project
|
|
320
|
+
// queries (`session.timeline`, `build.timeline`) can filter without
|
|
321
|
+
// extra lookups. v0.5 also stamps visitorId so visitor-scoped
|
|
322
|
+
// filtering ("show me everything from this user") is row-level too.
|
|
323
|
+
sessionId: this.sessionId,
|
|
324
|
+
buildId: this.opts.buildId,
|
|
325
|
+
visitorId: this.visitorId,
|
|
326
|
+
name,
|
|
327
|
+
ts: Date.now(),
|
|
328
|
+
payload,
|
|
329
|
+
};
|
|
330
|
+
this.send(event);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Request/reply RPC to the daemon. Currently used by the in-page
|
|
335
|
+
* overlay to fetch / mutate the visitor's own tasks. Resolves with the
|
|
336
|
+
* remote `result`, rejects with the remote `error.message` (or a
|
|
337
|
+
* timeout after 10 s).
|
|
338
|
+
*/
|
|
339
|
+
query<TResult = unknown>(method: QueryMethod, args?: unknown, timeoutMs = 10_000): Promise<TResult> {
|
|
340
|
+
const id = crypto.randomUUID();
|
|
341
|
+
const frame: QueryFrame = { type: 'query', id, method, args };
|
|
342
|
+
return new Promise<TResult>((resolve, reject) => {
|
|
343
|
+
const timer = setTimeout(() => {
|
|
344
|
+
this.pendingQueries.delete(id);
|
|
345
|
+
reject(new Error(`harness-fe query "${method}" timed out after ${timeoutMs}ms`));
|
|
346
|
+
}, timeoutMs);
|
|
347
|
+
this.pendingQueries.set(id, {
|
|
348
|
+
resolve: (v: unknown) => { clearTimeout(timer); resolve(v as TResult); },
|
|
349
|
+
reject: (e: Error) => { clearTimeout(timer); reject(e); },
|
|
350
|
+
});
|
|
351
|
+
this.send(frame);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
private pendingQueries = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
|
355
|
+
|
|
356
|
+
private send(frame: Frame): void {
|
|
357
|
+
let payload: string;
|
|
358
|
+
try {
|
|
359
|
+
payload = JSON.stringify(frame);
|
|
360
|
+
} catch {
|
|
361
|
+
return; // unserializable; drop
|
|
362
|
+
}
|
|
363
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
364
|
+
try {
|
|
365
|
+
this.ws.send(payload);
|
|
366
|
+
return;
|
|
367
|
+
} catch {
|
|
368
|
+
// write failed mid-stream — fall through and buffer for retry
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
this.outbox.enqueue(payload, isStickyFrame(frame));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private drainOutbox(): void {
|
|
375
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
376
|
+
this.outbox.flush((payload) => {
|
|
377
|
+
this.ws!.send(payload);
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Decide whether an outgoing frame must survive outbox eviction.
|
|
384
|
+
*
|
|
385
|
+
* Today: any rrweb chunk that contains a FullSnapshot (type:2). Without
|
|
386
|
+
* this, the FullSnapshot — being the *first* rrweb frame emitted at
|
|
387
|
+
* recorder start — was always the oldest in the outbox and the FIFO
|
|
388
|
+
* evictor dropped it first when the daemon was unreachable. That left the
|
|
389
|
+
* session unreplayable for its entire life.
|
|
390
|
+
*/
|
|
391
|
+
function isStickyFrame(frame: Frame): boolean {
|
|
392
|
+
if (frame.type !== 'event') return false;
|
|
393
|
+
if (frame.name !== EVENT_NAME.RRWEB) return false;
|
|
394
|
+
const payload = frame.payload as { events?: unknown[] } | undefined;
|
|
395
|
+
if (!payload || !Array.isArray(payload.events)) return false;
|
|
396
|
+
return chunkHasFullSnapshot(payload as { events: unknown[] });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Pull the well-known config object planted by the Vite plugin on window. */
|
|
400
|
+
export function readInjectedConfig(): ClientOptions {
|
|
401
|
+
const w = window as unknown as {
|
|
402
|
+
__HARNESS_FE__?: {
|
|
403
|
+
projectId?: string;
|
|
404
|
+
mcpUrl?: string;
|
|
405
|
+
buildId?: string;
|
|
406
|
+
parentProjectId?: string;
|
|
407
|
+
displayName?: string;
|
|
408
|
+
userId?: string;
|
|
409
|
+
sessionId?: string;
|
|
410
|
+
};
|
|
411
|
+
};
|
|
412
|
+
return {
|
|
413
|
+
projectId: w.__HARNESS_FE__?.projectId ?? 'unknown-project',
|
|
414
|
+
mcpUrl: w.__HARNESS_FE__?.mcpUrl,
|
|
415
|
+
buildId: w.__HARNESS_FE__?.buildId,
|
|
416
|
+
parentProjectId: w.__HARNESS_FE__?.parentProjectId,
|
|
417
|
+
displayName: w.__HARNESS_FE__?.displayName,
|
|
418
|
+
userId: w.__HARNESS_FE__?.userId,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Re-export command names for outside callers. */
|
|
423
|
+
export { COMMAND };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
/**
|
|
3
|
+
* Behavior tests for PAGE_SCREENSHOT — focused on the two recent bug fixes:
|
|
4
|
+
* 1. The overlay host must be hidden during capture so it never bleeds into
|
|
5
|
+
* the resulting image.
|
|
6
|
+
* 2. The screenshot must end up with an opaque background by default so a
|
|
7
|
+
* transparent page doesn't render a visually blank result.
|
|
8
|
+
*
|
|
9
|
+
* We stub `@zumer/snapdom` (the underlying capture library) since it pulls
|
|
10
|
+
* in DOM canvas APIs that aren't well-modeled in happy-dom.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import { Window } from 'happy-dom';
|
|
15
|
+
import { COMMAND, type ScreenshotArgs } from '@harness-fe/protocol';
|
|
16
|
+
|
|
17
|
+
const snapdomCalls: Array<{ target: unknown; options: { backgroundColor?: string } }> = [];
|
|
18
|
+
const overlayVisibilityDuringSnapdom: string[] = [];
|
|
19
|
+
|
|
20
|
+
vi.mock('@zumer/snapdom', () => ({
|
|
21
|
+
snapdom: vi.fn(async (target: unknown, options: { backgroundColor?: string }) => {
|
|
22
|
+
snapdomCalls.push({ target, options });
|
|
23
|
+
const host = document.getElementById('__harness_fe_overlay__') as HTMLElement | null;
|
|
24
|
+
overlayVisibilityDuringSnapdom.push(host?.style.visibility ?? '<no overlay host>');
|
|
25
|
+
return {
|
|
26
|
+
toCanvas: async () => {
|
|
27
|
+
const canvas = document.createElement('canvas');
|
|
28
|
+
canvas.width = 200;
|
|
29
|
+
canvas.height = 100;
|
|
30
|
+
return canvas;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
function setupDom(): void {
|
|
37
|
+
const win = new Window();
|
|
38
|
+
globalThis.window = win as unknown as typeof globalThis.window;
|
|
39
|
+
globalThis.document = win.document as unknown as typeof globalThis.document;
|
|
40
|
+
globalThis.HTMLElement = win.HTMLElement as unknown as typeof HTMLElement;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('PAGE_SCREENSHOT', () => {
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
snapdomCalls.length = 0;
|
|
46
|
+
overlayVisibilityDuringSnapdom.length = 0;
|
|
47
|
+
document.getElementById('__harness_fe_overlay__')?.remove();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
async function loadHandlers() {
|
|
51
|
+
const mod = await import('./commands.js');
|
|
52
|
+
return mod.commandHandlers;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fakeCtx() {
|
|
56
|
+
return {
|
|
57
|
+
capture: {
|
|
58
|
+
console: { push: () => {} },
|
|
59
|
+
errors: { push: () => {} },
|
|
60
|
+
network: { push: () => {} },
|
|
61
|
+
},
|
|
62
|
+
} as unknown as Parameters<Awaited<ReturnType<typeof loadHandlers>>[typeof COMMAND.PAGE_SCREENSHOT]>[1];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
it('uses an opaque white background by default (transparent pages no longer render blank)', async () => {
|
|
66
|
+
setupDom();
|
|
67
|
+
const handlers = await loadHandlers();
|
|
68
|
+
await handlers[COMMAND.PAGE_SCREENSHOT]({ format: 'webp' } satisfies ScreenshotArgs, fakeCtx());
|
|
69
|
+
expect(snapdomCalls).toHaveLength(1);
|
|
70
|
+
expect(snapdomCalls[0].options.backgroundColor).toBe('#ffffff');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('preserves transparency when backgroundColor: null is explicitly passed', async () => {
|
|
74
|
+
setupDom();
|
|
75
|
+
const handlers = await loadHandlers();
|
|
76
|
+
await handlers[COMMAND.PAGE_SCREENSHOT](
|
|
77
|
+
{ format: 'png', backgroundColor: null } satisfies ScreenshotArgs,
|
|
78
|
+
fakeCtx(),
|
|
79
|
+
);
|
|
80
|
+
expect(snapdomCalls[0].options.backgroundColor).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('honors a custom backgroundColor', async () => {
|
|
84
|
+
setupDom();
|
|
85
|
+
const handlers = await loadHandlers();
|
|
86
|
+
await handlers[COMMAND.PAGE_SCREENSHOT](
|
|
87
|
+
{ format: 'png', backgroundColor: '#0a0a0f' } satisfies ScreenshotArgs,
|
|
88
|
+
fakeCtx(),
|
|
89
|
+
);
|
|
90
|
+
expect(snapdomCalls[0].options.backgroundColor).toBe('#0a0a0f');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('hides the overlay host during capture and restores its visibility afterwards', async () => {
|
|
94
|
+
setupDom();
|
|
95
|
+
const host = document.createElement('div');
|
|
96
|
+
host.id = '__harness_fe_overlay__';
|
|
97
|
+
host.style.visibility = ''; // start visible
|
|
98
|
+
document.body.appendChild(host);
|
|
99
|
+
|
|
100
|
+
const handlers = await loadHandlers();
|
|
101
|
+
await handlers[COMMAND.PAGE_SCREENSHOT]({ format: 'webp' } satisfies ScreenshotArgs, fakeCtx());
|
|
102
|
+
|
|
103
|
+
// During the snapdom call we captured the overlay's current visibility.
|
|
104
|
+
expect(overlayVisibilityDuringSnapdom).toHaveLength(1);
|
|
105
|
+
expect(overlayVisibilityDuringSnapdom[0]).toBe('hidden');
|
|
106
|
+
// After the handler returned, the overlay is visible again.
|
|
107
|
+
expect(host.style.visibility).toBe('');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('restores overlay visibility even if snapdom throws (try/finally)', async () => {
|
|
111
|
+
setupDom();
|
|
112
|
+
const host = document.createElement('div');
|
|
113
|
+
host.id = '__harness_fe_overlay__';
|
|
114
|
+
host.style.visibility = '';
|
|
115
|
+
document.body.appendChild(host);
|
|
116
|
+
|
|
117
|
+
// Make snapdom reject for this single call.
|
|
118
|
+
const { snapdom } = await import('@zumer/snapdom');
|
|
119
|
+
(snapdom as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('boom'));
|
|
120
|
+
|
|
121
|
+
const handlers = await loadHandlers();
|
|
122
|
+
await expect(
|
|
123
|
+
handlers[COMMAND.PAGE_SCREENSHOT]({ format: 'webp' } satisfies ScreenshotArgs, fakeCtx()),
|
|
124
|
+
).rejects.toThrow(/boom/);
|
|
125
|
+
// Overlay must be restored regardless.
|
|
126
|
+
expect(host.style.visibility).toBe('');
|
|
127
|
+
});
|
|
128
|
+
});
|