@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
package/dist/client.js ADDED
@@ -0,0 +1,364 @@
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
+ import { COMMAND, DEFAULT_WS_PORT, EVENT_NAME, frameSchema, } from '@harness-fe/protocol';
8
+ import { getCaptureStore } from './capture.js';
9
+ import { commandHandlers } from './commands.js';
10
+ import { Outbox } from './outbox.js';
11
+ import { RrwebRecorder } from './recording.js';
12
+ import { chunkHasFullSnapshot } from './rrweb-types.js';
13
+ import { collectPageLoadSnapshot } from './snapshot.js';
14
+ import { collectEnv, getOrCreateVisitorId, publishVisitorIdToWindow, tryInheritVisitorFromParent, } from './visitor.js';
15
+ const TAB_ID_KEY = '__hfe_tab_id__';
16
+ function getOrCreateTabId() {
17
+ try {
18
+ const existing = sessionStorage.getItem(TAB_ID_KEY);
19
+ if (existing)
20
+ return existing;
21
+ const id = `${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`;
22
+ sessionStorage.setItem(TAB_ID_KEY, id);
23
+ return id;
24
+ }
25
+ catch {
26
+ return `${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`;
27
+ }
28
+ }
29
+ /**
30
+ * Generate a fresh sessionId for this page load. Intentionally NOT persisted
31
+ * to sessionStorage — a refresh MUST yield a new id. WebSocket reconnects
32
+ * within the same page load reuse this in-memory value.
33
+ *
34
+ * (Previously called `loadId`; renamed to align with the narrative model
35
+ * where one page-load = one "session" of user activity.)
36
+ */
37
+ function generateSessionId() {
38
+ try {
39
+ return crypto.randomUUID();
40
+ }
41
+ catch {
42
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
43
+ }
44
+ }
45
+ /**
46
+ * Attempt to read a server-generated sessionId from `window.__HARNESS_FE_SEED__`
47
+ * or from `window.__HARNESS_FE__.sessionId` (both written by `<HarnessScript>`).
48
+ *
49
+ * When found, the client adopts that id instead of generating its own. This
50
+ * ensures server-side events emitted by `@harness-fe/node-runtime` during
51
+ * the same request and client-side events all land in the same
52
+ * `sessions/{sessionId}/timeline.jsonl` on the daemon.
53
+ *
54
+ * Returns `undefined` when no seed is present (e.g. app doesn't use
55
+ * `<HarnessScript>` or running outside a browser).
56
+ */
57
+ function tryAdoptServerSeed() {
58
+ if (typeof window === 'undefined')
59
+ return undefined;
60
+ const w = window;
61
+ return w.__HARNESS_FE_SEED__?.sessionId ?? w.__HARNESS_FE__?.sessionId;
62
+ }
63
+ // Re-export inheritance helper. Implementation lives in parent-inherit.ts
64
+ // so its unit tests can import it without dragging the rrweb-dependent
65
+ // recorder module into the test runtime.
66
+ export { tryInheritFromParent } from './parent-inherit.js';
67
+ import { tryInheritFromParent as _tryInheritFromParent } from './parent-inherit.js';
68
+ export class RuntimeClient {
69
+ opts;
70
+ ws;
71
+ tabId;
72
+ sessionId;
73
+ visitorId;
74
+ parentProjectId;
75
+ /** Read-only accessors exposed for the in-page info panel. */
76
+ get projectId() { return this.opts.projectId; }
77
+ get buildId() { return this.opts.buildId; }
78
+ get displayName() { return this.opts.displayName; }
79
+ get userId() { return this.opts.userId; }
80
+ get mcpUrl() { return this.opts.mcpUrl; }
81
+ /** WebSocket state: 'connecting' | 'open' | 'closed'. */
82
+ getConnectionState() {
83
+ if (!this.ws)
84
+ return 'closed';
85
+ switch (this.ws.readyState) {
86
+ case WebSocket.OPEN: return 'open';
87
+ case WebSocket.CONNECTING: return 'connecting';
88
+ default: return 'closed';
89
+ }
90
+ }
91
+ pageLoadSent = false;
92
+ ctx = { capture: getCaptureStore() };
93
+ recorder = new RrwebRecorder((chunk) => this.sendEvent(EVENT_NAME.RRWEB, chunk));
94
+ reconnectAttempts = 0;
95
+ closed = false;
96
+ static MAX_OUTBOX_FRAMES = 500;
97
+ static MAX_OUTBOX_BYTES = 8 * 1024 * 1024;
98
+ outbox = new Outbox(RuntimeClient.MAX_OUTBOX_FRAMES, RuntimeClient.MAX_OUTBOX_BYTES);
99
+ constructor(opts) {
100
+ this.opts = opts;
101
+ const inherited = _tryInheritFromParent();
102
+ this.tabId = inherited.tabId ?? getOrCreateTabId();
103
+ // Priority: iframe parent seed > server seed > fresh generation.
104
+ this.sessionId = inherited.sessionId ?? tryAdoptServerSeed() ?? generateSessionId();
105
+ // Explicit option wins over runtime auto-detection.
106
+ this.parentProjectId = opts.parentProjectId ?? inherited.parentProjectId;
107
+ // Same-origin iframes share a visitorId so the journey stitches across
108
+ // micro-frontends. Cross-origin children fall back to their own.
109
+ const inheritedVisitor = tryInheritVisitorFromParent();
110
+ this.visitorId = inheritedVisitor ?? getOrCreateVisitorId();
111
+ publishVisitorIdToWindow(this.visitorId);
112
+ }
113
+ start() {
114
+ this.ctx.capture.install((name, payload) => this.sendEvent(name, payload));
115
+ this.recorder.start();
116
+ this.connect();
117
+ }
118
+ stop() {
119
+ this.closed = true;
120
+ this.recorder.stop();
121
+ this.ws?.close();
122
+ }
123
+ connect() {
124
+ const url = this.opts.mcpUrl ?? `ws://127.0.0.1:${DEFAULT_WS_PORT}`;
125
+ try {
126
+ this.ws = new WebSocket(url);
127
+ }
128
+ catch (err) {
129
+ console.warn('[morphix-dev-bridge] failed to construct WebSocket', err);
130
+ return;
131
+ }
132
+ this.ws.addEventListener('open', () => this.onOpen());
133
+ this.ws.addEventListener('message', (ev) => this.onMessage(ev));
134
+ this.ws.addEventListener('close', () => this.onClose());
135
+ this.ws.addEventListener('error', () => {
136
+ /* close will follow */
137
+ });
138
+ }
139
+ onOpen() {
140
+ this.reconnectAttempts = 0;
141
+ const hello = {
142
+ type: 'hello',
143
+ id: crypto.randomUUID(),
144
+ role: 'runtime-client',
145
+ projectId: this.opts.projectId,
146
+ parentProjectId: this.parentProjectId,
147
+ displayName: this.opts.displayName,
148
+ buildId: this.opts.buildId,
149
+ tabId: this.tabId,
150
+ sessionId: this.sessionId,
151
+ visitorId: this.visitorId,
152
+ userId: this.opts.userId,
153
+ env: collectEnv(),
154
+ page: {
155
+ url: location.href,
156
+ title: document.title,
157
+ userAgent: navigator.userAgent,
158
+ },
159
+ };
160
+ this.send(hello);
161
+ // Any pre-OPEN frames (rrweb chunk 1 with the Meta+FullSnapshot
162
+ // baseline is the canonical example) get flushed *after* hello, so
163
+ // the daemon has a registered peer before they arrive.
164
+ this.drainOutbox();
165
+ }
166
+ onClose() {
167
+ if (this.closed)
168
+ return;
169
+ const delay = Math.min(15_000, 500 * 2 ** Math.min(this.reconnectAttempts, 5));
170
+ this.reconnectAttempts++;
171
+ setTimeout(() => {
172
+ if (!this.closed)
173
+ this.connect();
174
+ }, delay);
175
+ }
176
+ onMessage(ev) {
177
+ let parsed;
178
+ try {
179
+ parsed = JSON.parse(String(ev.data));
180
+ }
181
+ catch {
182
+ return;
183
+ }
184
+ const result = frameSchema.safeParse(parsed);
185
+ if (!result.success)
186
+ return;
187
+ const frame = result.data;
188
+ if (frame.type === 'command')
189
+ this.handleCommand(frame);
190
+ else if (frame.type === 'hello.ack')
191
+ this.onHelloAck(frame);
192
+ else if (frame.type === 'query.response')
193
+ this.onQueryResponse(frame);
194
+ }
195
+ onQueryResponse(frame) {
196
+ const pending = this.pendingQueries.get(frame.id);
197
+ if (!pending)
198
+ return;
199
+ this.pendingQueries.delete(frame.id);
200
+ if (frame.ok) {
201
+ pending.resolve(frame.result);
202
+ }
203
+ else {
204
+ pending.reject(new Error(frame.error?.message ?? 'query failed'));
205
+ }
206
+ }
207
+ onHelloAck(frame) {
208
+ if (frame.error) {
209
+ // Bridge rejected this hello — do not send PAGE_LOAD.
210
+ return;
211
+ }
212
+ // Force a fresh rrweb FullSnapshot on every ack — including reconnects
213
+ // after daemon restart, network blips, or page-recovery from sleep.
214
+ // Without this, the only baseline for the session is whatever rrweb
215
+ // emitted at start(); if that chunk was evicted from the outbox
216
+ // (FIFO overflow during a long disconnect) or the daemon was down at
217
+ // the critical moment, the session is unreplayable forever.
218
+ // Safe to call on every ack: rrweb emits another type:2, replay
219
+ // engines treat additional baselines as a checkpoint reset.
220
+ this.recorder.takeFullSnapshot();
221
+ // Send the page-load snapshot exactly once per load. The reconnect
222
+ // path also lands here; emit only on the first ack of this load.
223
+ if (this.pageLoadSent)
224
+ return;
225
+ this.pageLoadSent = true;
226
+ try {
227
+ const payload = collectPageLoadSnapshot(this.sessionId);
228
+ this.sendEvent(EVENT_NAME.PAGE_LOAD, payload);
229
+ }
230
+ catch {
231
+ /* snapshot failures must not propagate */
232
+ }
233
+ }
234
+ async handleCommand(frame) {
235
+ const handler = commandHandlers[frame.command];
236
+ if (!handler) {
237
+ this.send({
238
+ type: 'response',
239
+ id: frame.id,
240
+ ok: false,
241
+ error: { code: 'UNKNOWN_COMMAND', message: `no handler for "${frame.command}"` },
242
+ });
243
+ return;
244
+ }
245
+ try {
246
+ const result = await handler(frame.args ?? {}, this.ctx);
247
+ this.send({
248
+ type: 'response',
249
+ id: frame.id,
250
+ ok: true,
251
+ result,
252
+ });
253
+ }
254
+ catch (err) {
255
+ const message = err instanceof Error ? err.message : String(err);
256
+ this.send({
257
+ type: 'response',
258
+ id: frame.id,
259
+ ok: false,
260
+ error: { message },
261
+ });
262
+ }
263
+ }
264
+ sendEvent(name, payload) {
265
+ const event = {
266
+ type: 'event',
267
+ id: crypto.randomUUID(),
268
+ tabId: this.tabId,
269
+ projectId: this.opts.projectId,
270
+ // v0.2: stamp every event with sessionId + buildId so cross-project
271
+ // queries (`session.timeline`, `build.timeline`) can filter without
272
+ // extra lookups. v0.5 also stamps visitorId so visitor-scoped
273
+ // filtering ("show me everything from this user") is row-level too.
274
+ sessionId: this.sessionId,
275
+ buildId: this.opts.buildId,
276
+ visitorId: this.visitorId,
277
+ name,
278
+ ts: Date.now(),
279
+ payload,
280
+ };
281
+ this.send(event);
282
+ }
283
+ /**
284
+ * Request/reply RPC to the daemon. Currently used by the in-page
285
+ * overlay to fetch / mutate the visitor's own tasks. Resolves with the
286
+ * remote `result`, rejects with the remote `error.message` (or a
287
+ * timeout after 10 s).
288
+ */
289
+ query(method, args, timeoutMs = 10_000) {
290
+ const id = crypto.randomUUID();
291
+ const frame = { type: 'query', id, method, args };
292
+ return new Promise((resolve, reject) => {
293
+ const timer = setTimeout(() => {
294
+ this.pendingQueries.delete(id);
295
+ reject(new Error(`harness-fe query "${method}" timed out after ${timeoutMs}ms`));
296
+ }, timeoutMs);
297
+ this.pendingQueries.set(id, {
298
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
299
+ reject: (e) => { clearTimeout(timer); reject(e); },
300
+ });
301
+ this.send(frame);
302
+ });
303
+ }
304
+ pendingQueries = new Map();
305
+ send(frame) {
306
+ let payload;
307
+ try {
308
+ payload = JSON.stringify(frame);
309
+ }
310
+ catch {
311
+ return; // unserializable; drop
312
+ }
313
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
314
+ try {
315
+ this.ws.send(payload);
316
+ return;
317
+ }
318
+ catch {
319
+ // write failed mid-stream — fall through and buffer for retry
320
+ }
321
+ }
322
+ this.outbox.enqueue(payload, isStickyFrame(frame));
323
+ }
324
+ drainOutbox() {
325
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
326
+ return;
327
+ this.outbox.flush((payload) => {
328
+ this.ws.send(payload);
329
+ });
330
+ }
331
+ }
332
+ /**
333
+ * Decide whether an outgoing frame must survive outbox eviction.
334
+ *
335
+ * Today: any rrweb chunk that contains a FullSnapshot (type:2). Without
336
+ * this, the FullSnapshot — being the *first* rrweb frame emitted at
337
+ * recorder start — was always the oldest in the outbox and the FIFO
338
+ * evictor dropped it first when the daemon was unreachable. That left the
339
+ * session unreplayable for its entire life.
340
+ */
341
+ function isStickyFrame(frame) {
342
+ if (frame.type !== 'event')
343
+ return false;
344
+ if (frame.name !== EVENT_NAME.RRWEB)
345
+ return false;
346
+ const payload = frame.payload;
347
+ if (!payload || !Array.isArray(payload.events))
348
+ return false;
349
+ return chunkHasFullSnapshot(payload);
350
+ }
351
+ /** Pull the well-known config object planted by the Vite plugin on window. */
352
+ export function readInjectedConfig() {
353
+ const w = window;
354
+ return {
355
+ projectId: w.__HARNESS_FE__?.projectId ?? 'unknown-project',
356
+ mcpUrl: w.__HARNESS_FE__?.mcpUrl,
357
+ buildId: w.__HARNESS_FE__?.buildId,
358
+ parentProjectId: w.__HARNESS_FE__?.parentProjectId,
359
+ displayName: w.__HARNESS_FE__?.displayName,
360
+ userId: w.__HARNESS_FE__?.userId,
361
+ };
362
+ }
363
+ /** Re-export command names for outside callers. */
364
+ export { COMMAND };
@@ -0,0 +1,10 @@
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
+ import type { CaptureStore } from './capture.js';
6
+ export interface CommandContext {
7
+ capture: CaptureStore;
8
+ }
9
+ export type CommandHandler = (args: unknown, ctx: CommandContext) => Promise<unknown>;
10
+ export declare const commandHandlers: Record<string, CommandHandler>;
@@ -0,0 +1,304 @@
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
+ import { COMMAND, } from '@harness-fe/protocol';
6
+ import { snapdom } from '@zumer/snapdom';
7
+ import { resolveSelector } from './selectors.js';
8
+ const HTML_TRUNCATE = 4000;
9
+ function describeNoMatch(selector) {
10
+ const fields = Object.entries(selector)
11
+ .filter(([, v]) => v !== undefined)
12
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
13
+ .join(' ');
14
+ return `no element matched selector: ${fields}`;
15
+ }
16
+ export const commandHandlers = {
17
+ [COMMAND.PAGE_CLICK]: async (raw) => {
18
+ const args = raw;
19
+ const result = resolveSelector(args.selector);
20
+ if (!result.element)
21
+ throw new Error(describeNoMatch(args.selector));
22
+ const target = result.element;
23
+ // When the resolved element is not itself an <a>, walk up to find the
24
+ // nearest anchor ancestor. This handles the common case where a text
25
+ // selector matches a child <span> inside a React Router <Link>, which
26
+ // would otherwise fire a click that bypasses the router's onClick handler.
27
+ let clickTarget = target;
28
+ if (target.tagName !== 'A') {
29
+ const anchor = target.closest('a');
30
+ if (anchor)
31
+ clickTarget = anchor;
32
+ }
33
+ // Dispatch a proper MouseEvent instead of calling .click() so that
34
+ // framework routers (React Router, Vue Router) receive a bubbling event
35
+ // with the correct button/modifier state they check before navigating.
36
+ clickTarget.dispatchEvent(new MouseEvent('click', {
37
+ bubbles: true,
38
+ cancelable: true,
39
+ view: window,
40
+ button: args.button === 'right' ? 2 : args.button === 'middle' ? 1 : 0,
41
+ }));
42
+ return { via: result.via, tag: clickTarget.tagName.toLowerCase() };
43
+ },
44
+ [COMMAND.PAGE_TYPE]: async (raw) => {
45
+ const args = raw;
46
+ const result = resolveSelector(args.selector);
47
+ if (!result.element)
48
+ throw new Error(describeNoMatch(args.selector));
49
+ const target = result.element;
50
+ if (typeof target.value !== 'string') {
51
+ throw new Error('page.type: target element does not support .value');
52
+ }
53
+ // React (and Vue's controlled inputs) install setters/trackers on
54
+ // input.value. Setting `.value = '...'` directly bypasses them, so
55
+ // their state never updates. Use the native prototype setter so the
56
+ // framework's tracker registers the change, then dispatch a bubbling
57
+ // 'input' + 'change' event.
58
+ const proto = target instanceof HTMLInputElement
59
+ ? HTMLInputElement.prototype
60
+ : HTMLTextAreaElement.prototype;
61
+ const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
62
+ const next = args.clear !== false ? args.value : target.value + args.value;
63
+ if (nativeSetter)
64
+ nativeSetter.call(target, next);
65
+ else
66
+ target.value = next;
67
+ target.dispatchEvent(new Event('input', { bubbles: true }));
68
+ target.dispatchEvent(new Event('change', { bubbles: true }));
69
+ return { via: result.via, value: target.value };
70
+ },
71
+ [COMMAND.PAGE_EVALUATE]: async (raw) => {
72
+ const args = raw;
73
+ // eslint-disable-next-line no-new-func
74
+ const fn = new Function(`return (async () => { return (${args.expr}); })();`);
75
+ const value = await fn();
76
+ return { value: safeJson(value) };
77
+ },
78
+ [COMMAND.PAGE_WAIT_FOR]: async (raw) => {
79
+ const args = raw;
80
+ const timeoutMs = args.timeoutMs ?? 10_000;
81
+ const deadline = Date.now() + timeoutMs;
82
+ const isBuiltin = args.predicate === 'network.idle' || args.predicate === 'dom.ready';
83
+ // eslint-disable-next-line no-new-func
84
+ const probe = !isBuiltin
85
+ ? new Function(`return Boolean(${args.predicate})`)
86
+ : undefined;
87
+ while (Date.now() < deadline) {
88
+ if (args.predicate === 'dom.ready' && document.readyState === 'complete') {
89
+ return { ok: true, after: Date.now() };
90
+ }
91
+ if (args.predicate === 'network.idle') {
92
+ // Crude heuristic — we don't have a real idle tracker yet.
93
+ await new Promise((r) => setTimeout(r, 200));
94
+ return { ok: true, after: Date.now() };
95
+ }
96
+ if (probe && probe())
97
+ return { ok: true, after: Date.now() };
98
+ await new Promise((r) => setTimeout(r, 50));
99
+ }
100
+ throw new Error(`page.wait_for: predicate "${args.predicate}" did not become truthy in ${timeoutMs}ms`);
101
+ },
102
+ [COMMAND.PAGE_SCREENSHOT]: async (raw) => {
103
+ const args = raw;
104
+ const format = args.format ?? 'webp';
105
+ const maxWidth = args.maxWidth ?? 1280;
106
+ // Default to opaque white so transparent pages don't render a blank
107
+ // screenshot. Callers can pass `null` to opt back into transparency.
108
+ // JPEG has no alpha channel so the field is effectively always set.
109
+ const backgroundColor = args.backgroundColor === null
110
+ ? undefined
111
+ : (args.backgroundColor ?? (format === 'jpeg' ? '#fff' : '#ffffff'));
112
+ let target;
113
+ let via = 'document';
114
+ if (args.selector) {
115
+ const result = resolveSelector(args.selector);
116
+ if (!result.element)
117
+ throw new Error(describeNoMatch(args.selector));
118
+ target = result.element;
119
+ via = result.via;
120
+ }
121
+ else {
122
+ target = document.documentElement;
123
+ }
124
+ const rect = target.getBoundingClientRect();
125
+ const naturalWidth = Math.max(1, Math.round(rect.width || target.clientWidth || window.innerWidth));
126
+ const width = naturalWidth > maxWidth ? maxWidth : naturalWidth;
127
+ // Hide our own overlay during capture so the screenshot reflects the
128
+ // real page state. Without this, the floating "H" FAB and any open
129
+ // info card would always end up in the corner of every shot.
130
+ const overlayHost = document.getElementById('__harness_fe_overlay__');
131
+ const prevVisibility = overlayHost?.style.visibility ?? '';
132
+ if (overlayHost)
133
+ overlayHost.style.visibility = 'hidden';
134
+ try {
135
+ const result = await snapdom(target, {
136
+ fast: true,
137
+ width,
138
+ backgroundColor,
139
+ });
140
+ const canvas = await result.toCanvas();
141
+ const mime = format === 'jpeg' ? 'image/jpeg' : `image/${format}`;
142
+ const quality = format === 'png' ? undefined : 0.85;
143
+ const dataUrl = canvas.toDataURL(mime, quality);
144
+ return {
145
+ via,
146
+ format,
147
+ width: canvas.width,
148
+ height: canvas.height,
149
+ dataUrl,
150
+ };
151
+ }
152
+ finally {
153
+ if (overlayHost)
154
+ overlayHost.style.visibility = prevVisibility;
155
+ }
156
+ },
157
+ [COMMAND.PAGE_DOM_QUERY]: async (raw) => {
158
+ const args = raw;
159
+ const limit = args.limit ?? 5;
160
+ const matches = [];
161
+ // Try each selector field independently — we want all matches up to limit.
162
+ if (args.selector.css) {
163
+ const list = document.querySelectorAll(args.selector.css);
164
+ for (let i = 0; i < list.length && matches.length < limit; i++) {
165
+ matches.push({
166
+ html: truncate(list[i].outerHTML, HTML_TRUNCATE),
167
+ tag: list[i].tagName.toLowerCase(),
168
+ via: 'css',
169
+ });
170
+ }
171
+ }
172
+ if (matches.length < limit) {
173
+ const result = resolveSelector(args.selector);
174
+ if (result.element) {
175
+ matches.push({
176
+ html: truncate(result.element.outerHTML, HTML_TRUNCATE),
177
+ tag: result.element.tagName.toLowerCase(),
178
+ via: result.via,
179
+ });
180
+ }
181
+ }
182
+ return { matches };
183
+ },
184
+ [COMMAND.PAGE_SCROLL]: async (raw) => {
185
+ const args = raw;
186
+ const behavior = args.behavior ?? 'smooth';
187
+ if (args.selector) {
188
+ const result = resolveSelector(args.selector);
189
+ if (!result.element)
190
+ throw new Error(describeNoMatch(args.selector));
191
+ result.element.scrollIntoView({ behavior, block: 'center' });
192
+ return { via: result.via, scrolledIntoView: true };
193
+ }
194
+ window.scrollTo({ top: args.y ?? 0, left: args.x ?? 0, behavior });
195
+ return { scrollX: window.scrollX, scrollY: window.scrollY };
196
+ },
197
+ [COMMAND.PAGE_NAVIGATE]: async (raw) => {
198
+ const args = raw;
199
+ const method = args.method ?? 'href';
200
+ const before = location.href;
201
+ if (method === 'href') {
202
+ location.href = args.url;
203
+ return { method, from: before, to: args.url };
204
+ }
205
+ if (method === 'push') {
206
+ history.pushState({}, '', args.url);
207
+ }
208
+ else {
209
+ history.replaceState({}, '', args.url);
210
+ }
211
+ // Notify SPA routers that listen on popstate
212
+ window.dispatchEvent(new PopStateEvent('popstate', { state: history.state }));
213
+ return { method, from: before, to: location.href };
214
+ },
215
+ [COMMAND.PAGE_RELOAD]: async (raw) => {
216
+ const args = raw;
217
+ if (args.hard) {
218
+ // Hard reload — bypass cache
219
+ location.reload();
220
+ }
221
+ else {
222
+ location.reload();
223
+ }
224
+ return { reloading: true };
225
+ },
226
+ [COMMAND.PAGE_SET_HTML]: async (raw) => {
227
+ const args = raw;
228
+ const result = resolveSelector(args.selector);
229
+ if (!result.element)
230
+ throw new Error(describeNoMatch(args.selector));
231
+ const el = result.element;
232
+ const target = args.target ?? 'innerHTML';
233
+ const before = target === 'innerHTML' ? el.innerHTML : el.outerHTML;
234
+ if (target === 'innerHTML') {
235
+ el.innerHTML = args.html;
236
+ return { via: result.via, target, before: truncate(before, 500) };
237
+ }
238
+ // outerHTML replacement — the element is removed from the DOM; return the new element tag
239
+ const tag = el.tagName.toLowerCase();
240
+ el.outerHTML = args.html;
241
+ return { via: result.via, target, replacedTag: tag, before: truncate(before, 500) };
242
+ },
243
+ [COMMAND.PAGE_SET_STYLE]: async (raw) => {
244
+ const args = raw;
245
+ // Global injection mode: { rule: "<raw css>" }
246
+ if (!args.selector) {
247
+ const rule = args.styles['rule'];
248
+ if (!rule)
249
+ throw new Error('page.set_style: pass { rule: "<css>" } when no selector is provided');
250
+ const styleId = '__hfe_injected_style__';
251
+ let styleEl = document.getElementById(styleId);
252
+ if (!styleEl) {
253
+ styleEl = document.createElement('style');
254
+ styleEl.id = styleId;
255
+ document.head.appendChild(styleEl);
256
+ }
257
+ styleEl.textContent += `\n${rule}`;
258
+ return { injected: true, rule };
259
+ }
260
+ // Element inline-style mode
261
+ const result = resolveSelector(args.selector);
262
+ if (!result.element)
263
+ throw new Error(describeNoMatch(args.selector));
264
+ const el = result.element;
265
+ const merge = args.merge !== false; // default true
266
+ if (!merge)
267
+ el.removeAttribute('style');
268
+ const applied = {};
269
+ for (const [prop, value] of Object.entries(args.styles)) {
270
+ // Accept both camelCase and kebab-case
271
+ const camel = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
272
+ el.style[camel] = value;
273
+ applied[camel] = value;
274
+ }
275
+ return { via: result.via, applied, currentStyle: el.getAttribute('style') };
276
+ },
277
+ [COMMAND.CONSOLE_TAIL]: async (raw, ctx) => {
278
+ const args = raw;
279
+ return { entries: ctx.capture.console.tail(args.n) };
280
+ },
281
+ [COMMAND.NETWORK_TAIL]: async (raw, ctx) => {
282
+ const args = raw;
283
+ return { entries: ctx.capture.network.tail(args.n) };
284
+ },
285
+ [COMMAND.ERRORS_TAIL]: async (raw, ctx) => {
286
+ const args = raw;
287
+ return { entries: ctx.capture.errors.tail(args.n) };
288
+ },
289
+ };
290
+ function truncate(s, n) {
291
+ if (s.length <= n)
292
+ return s;
293
+ return `${s.slice(0, n)}… (truncated, total ${s.length} chars)`;
294
+ }
295
+ function safeJson(value) {
296
+ if (value === undefined)
297
+ return null;
298
+ try {
299
+ return JSON.parse(JSON.stringify(value));
300
+ }
301
+ catch {
302
+ return String(value);
303
+ }
304
+ }
@@ -0,0 +1,18 @@
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
+ export declare function deriveDashboardUrl(input: DashboardUrlInput): string | undefined;