@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/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>;
|
package/dist/commands.js
ADDED
|
@@ -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;
|