@harness-fe/runtime 3.4.1 → 4.0.0-next.4
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/dist/capture.d.ts +1 -1
- package/dist/client.d.ts +18 -1
- package/dist/client.js +57 -1
- package/dist/index.js +1 -2
- package/dist/overlay.d.ts +7 -1
- package/dist/overlay.js +92 -61
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/package.json +5 -5
- package/src/client.ts +60 -0
- package/src/consentGate.test.ts +115 -0
- package/src/index.ts +1 -3
- package/src/overlay.test.ts +39 -21
- package/src/overlay.ts +107 -59
- package/src/runtimeClient.e2e.test.ts +3 -3
- package/src/version.ts +3 -0
package/dist/capture.d.ts
CHANGED
|
@@ -65,7 +65,7 @@ export declare class CaptureStore {
|
|
|
65
65
|
}>;
|
|
66
66
|
readonly storage: RingBuffer<{
|
|
67
67
|
op: "set" | "remove" | "clear";
|
|
68
|
-
which: "
|
|
68
|
+
which: "session" | "local" | "cookie";
|
|
69
69
|
ts: number;
|
|
70
70
|
key?: string | undefined;
|
|
71
71
|
value?: string | undefined;
|
package/dist/client.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Started lazily by `auto-start.ts` when the script is imported.
|
|
6
6
|
*/
|
|
7
|
-
import { COMMAND } from '@harness-fe/protocol';
|
|
7
|
+
import { COMMAND, type ConsentDecision, type ConsentRequest } from '@harness-fe/protocol';
|
|
8
8
|
import type { QueryMethod } from '@harness-fe/protocol';
|
|
9
9
|
export interface ClientOptions {
|
|
10
10
|
projectId: string;
|
|
@@ -54,6 +54,11 @@ export declare class RuntimeClient {
|
|
|
54
54
|
/** WebSocket state: 'connecting' | 'open' | 'closed'. */
|
|
55
55
|
getConnectionState(): 'connecting' | 'open' | 'closed';
|
|
56
56
|
private pageLoadSent;
|
|
57
|
+
private consentMode;
|
|
58
|
+
/** Set once the user grants blanket control for this pageload (mode=session). */
|
|
59
|
+
private consentSessionGranted;
|
|
60
|
+
/** Set by the overlay to collect the user's decision. Absent ⇒ fail-safe deny. */
|
|
61
|
+
private consentPrompter?;
|
|
57
62
|
private readonly ctx;
|
|
58
63
|
private readonly recorder;
|
|
59
64
|
private reconnectAttempts;
|
|
@@ -69,8 +74,20 @@ export declare class RuntimeClient {
|
|
|
69
74
|
private onClose;
|
|
70
75
|
private onMessage;
|
|
71
76
|
private onQueryResponse;
|
|
77
|
+
/**
|
|
78
|
+
* Register the consent prompter (the overlay installs this). When the
|
|
79
|
+
* policy is on and a control command arrives, the client asks this for the
|
|
80
|
+
* user's decision. Without it, gated commands are denied (fail-safe).
|
|
81
|
+
*/
|
|
82
|
+
setConsentPrompter(fn: (req: ConsentRequest) => Promise<ConsentDecision>): void;
|
|
72
83
|
private onHelloAck;
|
|
73
84
|
private handleCommand;
|
|
85
|
+
/**
|
|
86
|
+
* Ask the user (via the overlay-registered prompter) to approve a control
|
|
87
|
+
* command. Fail-safe: if no prompter is registered, or it throws, deny —
|
|
88
|
+
* a consent policy that can't ask must not silently allow.
|
|
89
|
+
*/
|
|
90
|
+
private requestConsent;
|
|
74
91
|
sendEvent(name: string, payload: unknown): void;
|
|
75
92
|
/**
|
|
76
93
|
* Request/reply RPC to the daemon. Currently used by the in-page
|
package/dist/client.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Started lazily by `auto-start.ts` when the script is imported.
|
|
6
6
|
*/
|
|
7
|
-
import { COMMAND, DEFAULT_WS_PORT, EVENT_NAME, frameSchema, } from '@harness-fe/protocol';
|
|
7
|
+
import { ALWAYS_CONFIRM_COMMANDS, COMMAND, DEFAULT_WS_PORT, EVENT_NAME, requiresConsent, frameSchema, } from '@harness-fe/protocol';
|
|
8
8
|
import { getCaptureStore } from './capture.js';
|
|
9
9
|
import { commandHandlers } from './commands.js';
|
|
10
10
|
import { Outbox } from './outbox.js';
|
|
@@ -89,6 +89,14 @@ export class RuntimeClient {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
pageLoadSent = false;
|
|
92
|
+
// Browser consent (4.0 · P2). Mode comes from the daemon in hello.ack;
|
|
93
|
+
// default `off` so a daemon that never sends a policy (or loopback solo
|
|
94
|
+
// dev) keeps running control commands without prompting.
|
|
95
|
+
consentMode = 'off';
|
|
96
|
+
/** Set once the user grants blanket control for this pageload (mode=session). */
|
|
97
|
+
consentSessionGranted = false;
|
|
98
|
+
/** Set by the overlay to collect the user's decision. Absent ⇒ fail-safe deny. */
|
|
99
|
+
consentPrompter;
|
|
92
100
|
ctx = { capture: getCaptureStore() };
|
|
93
101
|
// Initialized in constructor (parameter property `opts` isn't readable at
|
|
94
102
|
// class-field-initializer time — field initializers run before parameter
|
|
@@ -209,11 +217,21 @@ export class RuntimeClient {
|
|
|
209
217
|
pending.reject(new Error(frame.error?.message ?? 'query failed'));
|
|
210
218
|
}
|
|
211
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Register the consent prompter (the overlay installs this). When the
|
|
222
|
+
* policy is on and a control command arrives, the client asks this for the
|
|
223
|
+
* user's decision. Without it, gated commands are denied (fail-safe).
|
|
224
|
+
*/
|
|
225
|
+
setConsentPrompter(fn) {
|
|
226
|
+
this.consentPrompter = fn;
|
|
227
|
+
}
|
|
212
228
|
onHelloAck(frame) {
|
|
213
229
|
if (frame.error) {
|
|
214
230
|
// Bridge rejected this hello — do not send PAGE_LOAD.
|
|
215
231
|
return;
|
|
216
232
|
}
|
|
233
|
+
// Adopt the daemon's consent policy for this connection (4.0 · P2).
|
|
234
|
+
this.consentMode = frame.consent?.mode ?? 'off';
|
|
217
235
|
// Force a fresh rrweb FullSnapshot on every ack — including reconnects
|
|
218
236
|
// after daemon restart, network blips, or page-recovery from sleep.
|
|
219
237
|
// Without this, the only baseline for the session is whatever rrweb
|
|
@@ -247,6 +265,23 @@ export class RuntimeClient {
|
|
|
247
265
|
});
|
|
248
266
|
return;
|
|
249
267
|
}
|
|
268
|
+
// Browser-consent gate (4.0 · P2): control commands need the user's
|
|
269
|
+
// OK in the page before they run when the daemon enabled consent.
|
|
270
|
+
if (requiresConsent(frame.command, this.consentMode, this.consentSessionGranted)) {
|
|
271
|
+
const decision = await this.requestConsent(frame);
|
|
272
|
+
if (decision === 'deny') {
|
|
273
|
+
this.send({
|
|
274
|
+
type: 'response',
|
|
275
|
+
id: frame.id,
|
|
276
|
+
ok: false,
|
|
277
|
+
error: { code: 'CONSENT_DENIED', message: `user denied "${frame.command}"` },
|
|
278
|
+
});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (decision === 'session')
|
|
282
|
+
this.consentSessionGranted = true;
|
|
283
|
+
// 'once' → run this one without granting the rest of the session.
|
|
284
|
+
}
|
|
250
285
|
try {
|
|
251
286
|
const result = await handler(frame.args ?? {}, this.ctx);
|
|
252
287
|
this.send({
|
|
@@ -266,6 +301,27 @@ export class RuntimeClient {
|
|
|
266
301
|
});
|
|
267
302
|
}
|
|
268
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* Ask the user (via the overlay-registered prompter) to approve a control
|
|
306
|
+
* command. Fail-safe: if no prompter is registered, or it throws, deny —
|
|
307
|
+
* a consent policy that can't ask must not silently allow.
|
|
308
|
+
*/
|
|
309
|
+
async requestConsent(frame) {
|
|
310
|
+
if (!this.consentPrompter)
|
|
311
|
+
return 'deny';
|
|
312
|
+
const req = {
|
|
313
|
+
command: frame.command,
|
|
314
|
+
args: frame.args,
|
|
315
|
+
tabId: this.tabId,
|
|
316
|
+
alwaysConfirm: ALWAYS_CONFIRM_COMMANDS.has(frame.command),
|
|
317
|
+
};
|
|
318
|
+
try {
|
|
319
|
+
return await this.consentPrompter(req);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
return 'deny';
|
|
323
|
+
}
|
|
324
|
+
}
|
|
269
325
|
sendEvent(name, payload) {
|
|
270
326
|
const event = {
|
|
271
327
|
type: 'event',
|
package/dist/index.js
CHANGED
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
import { installOverlay } from './overlay.js';
|
|
8
8
|
import { RuntimeClient, readInjectedConfig } from './client.js';
|
|
9
9
|
import { registerOverlayPlugin, drainPluginQueue, } from './pluginRegistry.js';
|
|
10
|
-
|
|
11
|
-
const VERSION = '3.3.0';
|
|
10
|
+
import { VERSION } from './version.js';
|
|
12
11
|
const w = window;
|
|
13
12
|
if (typeof window !== 'undefined' && !w.__harness_fe_started__) {
|
|
14
13
|
w.__harness_fe_started__ = true;
|
package/dist/overlay.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Single Shadow DOM root attached to <body>; host page styles never leak in
|
|
14
14
|
* or out. State machine: idle → info → (picker → question) → flash → idle.
|
|
15
15
|
*/
|
|
16
|
-
import { type TaskAttachment } from '@harness-fe/protocol';
|
|
16
|
+
import { type ConsentDecision, type ConsentRequest, type TaskAttachment } from '@harness-fe/protocol';
|
|
17
17
|
import { type OverlayPluginLogs, type OverlayPluginGetLogsOptions } from './pluginRegistry.js';
|
|
18
18
|
export interface OverlayClient {
|
|
19
19
|
readonly projectId: string;
|
|
@@ -37,6 +37,12 @@ export interface OverlayClient {
|
|
|
37
37
|
* tasks. Resolves with `result`, rejects with the remote error message.
|
|
38
38
|
*/
|
|
39
39
|
query?<TResult = unknown>(method: string, args?: unknown): Promise<TResult>;
|
|
40
|
+
/**
|
|
41
|
+
* Register the browser-consent prompter (4.0 · P2). The client calls this
|
|
42
|
+
* to ask the user before running a control command. Optional so embedders
|
|
43
|
+
* with a non-RuntimeClient overlay client still type-check.
|
|
44
|
+
*/
|
|
45
|
+
setConsentPrompter?(fn: (req: ConsentRequest) => Promise<ConsentDecision>): void;
|
|
40
46
|
}
|
|
41
47
|
export declare function installOverlay(client: OverlayClient): void;
|
|
42
48
|
interface ArrowStroke {
|
package/dist/overlay.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { EVENT_NAME, } from '@harness-fe/protocol';
|
|
17
17
|
import { snapdom } from '@zumer/snapdom';
|
|
18
18
|
import { deriveDashboardUrl } from './dashboardUrl.js';
|
|
19
|
+
import { VERSION } from './version.js';
|
|
19
20
|
import { getCaptureStore } from './capture.js';
|
|
20
21
|
import { collectPageLoadSnapshot } from './snapshot.js';
|
|
21
22
|
import { getOverlayPlugins, subscribeOverlayPlugins, } from './pluginRegistry.js';
|
|
@@ -77,8 +78,9 @@ export function installOverlay(client) {
|
|
|
77
78
|
const pickerBar = buildPickerBar();
|
|
78
79
|
const annotateModal = buildAnnotateModal();
|
|
79
80
|
const questionPanel = buildQuestionPanel();
|
|
81
|
+
const consentPanel = buildConsentPanel();
|
|
80
82
|
const highlight = buildHighlight();
|
|
81
|
-
root.append(fab, infoCard, reportsCard, pickerBar, annotateModal, questionPanel, highlight);
|
|
83
|
+
root.append(fab, infoCard, reportsCard, pickerBar, annotateModal, questionPanel, consentPanel, highlight);
|
|
82
84
|
const mount = () => {
|
|
83
85
|
if (!document.body)
|
|
84
86
|
return false;
|
|
@@ -249,12 +251,6 @@ export function installOverlay(client) {
|
|
|
249
251
|
let pendingAttachment = null;
|
|
250
252
|
/** Set while the picker is collecting an element for a `requiresElement` plugin. */
|
|
251
253
|
let pluginAwaitingElement = null;
|
|
252
|
-
/**
|
|
253
|
-
* Purpose of the current picker session:
|
|
254
|
-
* - 'copy': copy element info to clipboard for use with an agent (default)
|
|
255
|
-
* - 'report': legacy report-a-problem flow (still used internally by plugins)
|
|
256
|
-
*/
|
|
257
|
-
let pickerPurpose = 'copy';
|
|
258
254
|
const setState = (next) => {
|
|
259
255
|
state = next;
|
|
260
256
|
infoCard.style.display = next === 'info' ? 'flex' : 'none';
|
|
@@ -262,6 +258,7 @@ export function installOverlay(client) {
|
|
|
262
258
|
pickerBar.style.display = next === 'picker' ? 'flex' : 'none';
|
|
263
259
|
annotateModal.style.display = next === 'annotate' ? 'flex' : 'none';
|
|
264
260
|
questionPanel.style.display = next === 'question' ? 'flex' : 'none';
|
|
261
|
+
consentPanel.style.display = next === 'consent' ? 'flex' : 'none';
|
|
265
262
|
document.documentElement.style.cursor = next === 'picker' ? 'crosshair' : '';
|
|
266
263
|
fab.dataset.state = (next === 'picker' || next === 'annotate') ? 'active' : 'idle';
|
|
267
264
|
if (next !== 'picker' && next !== 'question' && next !== 'annotate') {
|
|
@@ -290,6 +287,40 @@ export function installOverlay(client) {
|
|
|
290
287
|
requestAnimationFrame(() => repositionCards());
|
|
291
288
|
}
|
|
292
289
|
};
|
|
290
|
+
// ─── Browser consent prompter (4.0 · P2) ─────────────────────────────
|
|
291
|
+
// The client calls this before running a control command when the daemon
|
|
292
|
+
// enabled consent. We show a modal and resolve with the user's choice.
|
|
293
|
+
const consentCmdEl = consentPanel.querySelector('[data-role="consent-cmd"]');
|
|
294
|
+
const consentAllowOnce = consentPanel.querySelector('[data-role="consent-once"]');
|
|
295
|
+
const consentAllowSession = consentPanel.querySelector('[data-role="consent-session"]');
|
|
296
|
+
const consentDeny = consentPanel.querySelector('[data-role="consent-deny"]');
|
|
297
|
+
let consentChain = Promise.resolve();
|
|
298
|
+
const promptConsent = (req) => new Promise((resolve) => {
|
|
299
|
+
consentCmdEl.textContent = formatConsentCommand(req);
|
|
300
|
+
// page.evaluate (alwaysConfirm) can never be granted session-wide.
|
|
301
|
+
consentAllowSession.style.display = req.alwaysConfirm ? 'none' : '';
|
|
302
|
+
setState('consent');
|
|
303
|
+
const finish = (decision) => {
|
|
304
|
+
consentAllowOnce.removeEventListener('click', onOnce);
|
|
305
|
+
consentAllowSession.removeEventListener('click', onSession);
|
|
306
|
+
consentDeny.removeEventListener('click', onDeny);
|
|
307
|
+
setState('idle');
|
|
308
|
+
resolve(decision);
|
|
309
|
+
};
|
|
310
|
+
const onOnce = () => finish('once');
|
|
311
|
+
const onSession = () => finish('session');
|
|
312
|
+
const onDeny = () => finish('deny');
|
|
313
|
+
consentAllowOnce.addEventListener('click', onOnce);
|
|
314
|
+
consentAllowSession.addEventListener('click', onSession);
|
|
315
|
+
consentDeny.addEventListener('click', onDeny);
|
|
316
|
+
});
|
|
317
|
+
// Serialize prompts so a burst of control commands queues one dialog at a time.
|
|
318
|
+
const showConsentPrompt = (req) => {
|
|
319
|
+
const result = consentChain.then(() => promptConsent(req));
|
|
320
|
+
consentChain = result.catch(() => undefined);
|
|
321
|
+
return result;
|
|
322
|
+
};
|
|
323
|
+
client.setConsentPrompter?.(showConsentPrompt);
|
|
293
324
|
// ─── Picker handlers ─────────────────────────────────────────────────
|
|
294
325
|
const setHighlight = (el) => {
|
|
295
326
|
if (!el || !(el instanceof HTMLElement || el instanceof SVGElement)) {
|
|
@@ -328,7 +359,7 @@ export function installOverlay(client) {
|
|
|
328
359
|
lockedEl = hoveredEl;
|
|
329
360
|
setHighlight(lockedEl);
|
|
330
361
|
// A plugin requested the element — hand it straight to its onClick and
|
|
331
|
-
// skip
|
|
362
|
+
// skip the report/question flow entirely.
|
|
332
363
|
if (pluginAwaitingElement) {
|
|
333
364
|
const plugin = pluginAwaitingElement;
|
|
334
365
|
pluginAwaitingElement = null;
|
|
@@ -338,18 +369,9 @@ export function installOverlay(client) {
|
|
|
338
369
|
void invokePlugin(plugin, el);
|
|
339
370
|
return;
|
|
340
371
|
}
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
lockedEl = null;
|
|
345
|
-
const text = buildElementCopyText(el);
|
|
346
|
-
void copyText(text).then(() => {
|
|
347
|
-
showToast('✓ Element info copied');
|
|
348
|
-
});
|
|
349
|
-
setState('idle');
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
352
|
-
// Report mode (legacy, no longer exposed in UI but kept for plugin compatibility).
|
|
372
|
+
// Go straight to the question step. Screenshots are now opt-in via
|
|
373
|
+
// the "Add screenshot" button inside the question panel — users
|
|
374
|
+
// shouldn't have to draw on every report.
|
|
353
375
|
pendingAttachment = null;
|
|
354
376
|
const info = questionPanel.querySelector('[data-role=info]');
|
|
355
377
|
info.textContent = describeElement(lockedEl);
|
|
@@ -373,7 +395,6 @@ export function installOverlay(client) {
|
|
|
373
395
|
lockedEl = null;
|
|
374
396
|
pendingAttachment = null;
|
|
375
397
|
pluginAwaitingElement = null;
|
|
376
|
-
pickerPurpose = 'copy';
|
|
377
398
|
setState('info');
|
|
378
399
|
}
|
|
379
400
|
else if (state === 'info') {
|
|
@@ -391,11 +412,13 @@ export function installOverlay(client) {
|
|
|
391
412
|
// ─── Info card rendering ─────────────────────────────────────────────
|
|
392
413
|
const renderInfo = () => {
|
|
393
414
|
const proj = infoCard.querySelector('[data-role=project]');
|
|
415
|
+
const version = infoCard.querySelector('[data-role=version]');
|
|
394
416
|
const build = infoCard.querySelector('[data-role=build]');
|
|
395
417
|
const session = infoCard.querySelector('[data-role=session]');
|
|
396
418
|
const tab = infoCard.querySelector('[data-role=tab]');
|
|
397
419
|
const url = infoCard.querySelector('[data-role=url]');
|
|
398
420
|
proj.textContent = client.displayName ?? client.projectId;
|
|
421
|
+
version.textContent = `v${VERSION}`;
|
|
399
422
|
build.textContent = client.buildId ? abbr(client.buildId) : '—';
|
|
400
423
|
build.title = client.buildId ?? 'No buildId — set HarnessScript buildId prop in prod';
|
|
401
424
|
session.textContent = abbr(client.sessionId);
|
|
@@ -444,31 +467,6 @@ export function installOverlay(client) {
|
|
|
444
467
|
}, 1200);
|
|
445
468
|
}
|
|
446
469
|
};
|
|
447
|
-
/**
|
|
448
|
-
* Build a compact element-info block for pasting into an agent prompt.
|
|
449
|
-
* Omits HTML (too verbose); includes source location, component name, css
|
|
450
|
-
* path, and session context — enough for the agent to locate and fix the
|
|
451
|
-
* element without any further investigation.
|
|
452
|
-
*/
|
|
453
|
-
const buildElementCopyText = (el) => {
|
|
454
|
-
const tag = el.tagName.toLowerCase();
|
|
455
|
-
const comp = el.getAttribute('data-morphix-comp');
|
|
456
|
-
const loc = el.getAttribute('data-morphix-loc');
|
|
457
|
-
const css = buildCssPath(el);
|
|
458
|
-
const lines = [];
|
|
459
|
-
lines.push(`### Element context`);
|
|
460
|
-
lines.push('');
|
|
461
|
-
if (comp)
|
|
462
|
-
lines.push(`- component: \`${comp}\``);
|
|
463
|
-
if (loc)
|
|
464
|
-
lines.push(`- source: \`${loc}\``);
|
|
465
|
-
lines.push(`- tag: \`${tag}\``);
|
|
466
|
-
lines.push(`- css: \`${css}\``);
|
|
467
|
-
lines.push(`- project: \`${client.projectId}\`${client.displayName ? ` (${client.displayName})` : ''}`);
|
|
468
|
-
lines.push(`- session: \`${client.sessionId}\``);
|
|
469
|
-
lines.push(`- url: ${location.href}`);
|
|
470
|
-
return lines.join('\n') + '\n';
|
|
471
|
-
};
|
|
472
470
|
const buildSnapshot = () => {
|
|
473
471
|
const lines = [];
|
|
474
472
|
lines.push(`### Harness-FE snapshot`);
|
|
@@ -566,10 +564,6 @@ export function installOverlay(client) {
|
|
|
566
564
|
btn.addEventListener('click', () => {
|
|
567
565
|
if (plugin.requiresElement) {
|
|
568
566
|
pluginAwaitingElement = plugin;
|
|
569
|
-
pickerPurpose = 'report'; // plugins use the legacy element-selection flow
|
|
570
|
-
const label = pickerBar.querySelector('[data-role=picker-label]');
|
|
571
|
-
if (label)
|
|
572
|
-
label.textContent = '🎯 Click an element';
|
|
573
567
|
setState('picker');
|
|
574
568
|
}
|
|
575
569
|
else {
|
|
@@ -798,17 +792,16 @@ export function installOverlay(client) {
|
|
|
798
792
|
setState(state === 'idle' ? 'info' : 'idle');
|
|
799
793
|
});
|
|
800
794
|
infoCard.querySelector('[data-role=close]').addEventListener('click', () => setState('idle'));
|
|
801
|
-
infoCard.querySelector('[data-role=
|
|
802
|
-
pickerPurpose = 'copy';
|
|
803
|
-
const label = pickerBar.querySelector('[data-role=picker-label]');
|
|
804
|
-
if (label)
|
|
805
|
-
label.textContent = '🔍 Click element to copy info';
|
|
795
|
+
infoCard.querySelector('[data-role=report]').addEventListener('click', () => {
|
|
806
796
|
setState('picker');
|
|
807
797
|
});
|
|
808
798
|
infoCard.querySelector('[data-role=copy-snapshot]').addEventListener('click', (ev) => {
|
|
809
799
|
const btn = ev.currentTarget;
|
|
810
800
|
void copyText(buildSnapshot(), btn);
|
|
811
801
|
});
|
|
802
|
+
infoCard.querySelector('[data-role=view-reports]').addEventListener('click', () => {
|
|
803
|
+
setState('reports');
|
|
804
|
+
});
|
|
812
805
|
// "Open dashboard" — derive the daemon's dashboard URL from mcpUrl and
|
|
813
806
|
// pop it in a new tab, deep-linked to this session. Show the button
|
|
814
807
|
// only when we actually know the daemon address (mcpUrl was supplied by
|
|
@@ -861,7 +854,6 @@ export function installOverlay(client) {
|
|
|
861
854
|
pickerBar.querySelector('[data-role=cancel]').addEventListener('click', () => {
|
|
862
855
|
lockedEl = null;
|
|
863
856
|
pluginAwaitingElement = null;
|
|
864
|
-
pickerPurpose = 'copy';
|
|
865
857
|
setState('info');
|
|
866
858
|
});
|
|
867
859
|
questionPanel.querySelector('[data-role=cancel]').addEventListener('click', () => {
|
|
@@ -1980,21 +1972,23 @@ function buildInfoCard() {
|
|
|
1980
1972
|
<button class="close-btn" data-role="close" title="Close (Esc)" type="button">×</button>
|
|
1981
1973
|
</div>
|
|
1982
1974
|
<div class="rows">
|
|
1975
|
+
<div class="row"><span class="key">version</span><span class="pill" data-role="version" title="harness runtime version"></span></div>
|
|
1983
1976
|
<div class="row"><span class="key">build</span><span class="pill" data-role="build" title="Click to copy"></span></div>
|
|
1984
1977
|
<div class="row"><span class="key">session</span><span class="pill" data-role="session" title="Click to copy"></span></div>
|
|
1985
1978
|
<div class="row"><span class="key">tab</span><span class="pill" data-role="tab" title="Click to copy"></span></div>
|
|
1986
1979
|
<div class="row"><span class="key">url</span><span class="pill url" data-role="url"></span></div>
|
|
1987
1980
|
</div>
|
|
1988
1981
|
<div class="actions">
|
|
1989
|
-
<button class="primary" data-role="
|
|
1990
|
-
<span class="icon"
|
|
1991
|
-
<span class="label">
|
|
1992
|
-
<span class="hint">
|
|
1982
|
+
<button class="primary" data-role="report" type="button">
|
|
1983
|
+
<span class="icon">🎯</span>
|
|
1984
|
+
<span class="label">Report a problem</span>
|
|
1985
|
+
<span class="hint">Pick an element →</span>
|
|
1993
1986
|
</button>
|
|
1994
1987
|
<button class="secondary" data-role="open-dashboard" type="button" style="display:none">
|
|
1995
1988
|
<span class="icon">↗</span>
|
|
1996
1989
|
<span>Open dashboard</span>
|
|
1997
1990
|
</button>
|
|
1991
|
+
<button class="secondary" data-role="view-reports" type="button">📁 My reports</button>
|
|
1998
1992
|
<button class="secondary" data-role="copy-snapshot" type="button">📋 Copy snapshot</button>
|
|
1999
1993
|
</div>
|
|
2000
1994
|
<div class="plugin-actions" data-role="plugin-actions" style="display:none"></div>
|
|
@@ -2025,7 +2019,7 @@ function buildPickerBar() {
|
|
|
2025
2019
|
const bar = document.createElement('div');
|
|
2026
2020
|
bar.className = 'picker-bar';
|
|
2027
2021
|
bar.innerHTML = `
|
|
2028
|
-
<span class="label"
|
|
2022
|
+
<span class="label">🎯 Click an element to flag it</span>
|
|
2029
2023
|
<span class="hint">esc to cancel</span>
|
|
2030
2024
|
<button data-role="cancel" type="button">Cancel</button>
|
|
2031
2025
|
`;
|
|
@@ -2090,6 +2084,43 @@ function buildHighlight() {
|
|
|
2090
2084
|
div.className = 'highlight';
|
|
2091
2085
|
return div;
|
|
2092
2086
|
}
|
|
2087
|
+
/**
|
|
2088
|
+
* Browser-consent modal (4.0 · P2). Self-contained inline styles (no reliance
|
|
2089
|
+
* on buildStyle) — a fixed, centered card with command preview + 3 choices.
|
|
2090
|
+
*/
|
|
2091
|
+
function buildConsentPanel() {
|
|
2092
|
+
const panel = document.createElement('div');
|
|
2093
|
+
panel.className = 'consent';
|
|
2094
|
+
panel.style.cssText = [
|
|
2095
|
+
'display:none', 'position:fixed', 'left:50%', 'top:24px', 'transform:translateX(-50%)',
|
|
2096
|
+
'z-index:2147483647', 'flex-direction:column', 'gap:10px', 'max-width:380px',
|
|
2097
|
+
'width:calc(100% - 32px)', 'box-sizing:border-box', 'padding:16px',
|
|
2098
|
+
'background:#fff', 'color:#111', 'border:1px solid #e5e7eb', 'border-radius:10px',
|
|
2099
|
+
'box-shadow:0 8px 28px rgba(0,0,0,.16)',
|
|
2100
|
+
'font:13px/1.45 -apple-system,BlinkMacSystemFont,system-ui,sans-serif',
|
|
2101
|
+
].join(';');
|
|
2102
|
+
panel.innerHTML = `
|
|
2103
|
+
<div style="font-weight:600">Agent wants to control this page</div>
|
|
2104
|
+
<code data-role="consent-cmd" style="display:block;padding:8px 10px;background:#f6f6f7;border-radius:6px;font:12px ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all"></code>
|
|
2105
|
+
<div style="display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap">
|
|
2106
|
+
<button data-role="consent-deny" type="button" style="padding:7px 12px;border:1px solid #d1d5db;border-radius:6px;background:#fff;color:#111;cursor:pointer">Deny</button>
|
|
2107
|
+
<button data-role="consent-session" type="button" style="padding:7px 12px;border:0;border-radius:6px;background:#f0f0f0;color:#111;cursor:pointer">Allow for session</button>
|
|
2108
|
+
<button data-role="consent-once" type="button" style="padding:7px 12px;border:0;border-radius:6px;background:#111;color:#fff;cursor:pointer">Allow once</button>
|
|
2109
|
+
</div>
|
|
2110
|
+
`;
|
|
2111
|
+
return panel;
|
|
2112
|
+
}
|
|
2113
|
+
/** Render a control command + its most telling arg for the consent prompt. */
|
|
2114
|
+
function formatConsentCommand(req) {
|
|
2115
|
+
const args = req.args && typeof req.args === 'object'
|
|
2116
|
+
? req.args
|
|
2117
|
+
: undefined;
|
|
2118
|
+
const detail = args?.selector ?? args?.url ?? args?.expr ?? args?.value ?? args?.predicate;
|
|
2119
|
+
if (detail === undefined)
|
|
2120
|
+
return req.command;
|
|
2121
|
+
const s = String(detail);
|
|
2122
|
+
return `${req.command}(${s.length > 80 ? `${s.slice(0, 80)}…` : s})`;
|
|
2123
|
+
}
|
|
2093
2124
|
// ─── Element / payload helpers (unchanged from annotation.ts) ────────────
|
|
2094
2125
|
function describeElement(el) {
|
|
2095
2126
|
const tag = el.tagName.toLowerCase();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const VERSION = "4.0.0-next.4";
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harness-fe/runtime",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0-next.4",
|
|
4
4
|
"description": "Browser-side SDK injected into the dev page. Connects to the MCP server via WebSocket and executes commands.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@zumer/snapdom": "^2.12.0",
|
|
32
32
|
"rrweb": "2.0.0-alpha.4",
|
|
33
|
-
"@harness-fe/protocol": "
|
|
33
|
+
"@harness-fe/protocol": "4.0.0-next.4",
|
|
34
34
|
"@harness-fe/sandbox": "^3.2.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"access": "public"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
|
-
"build": "tsc",
|
|
46
|
-
"dev": "tsc --watch --preserveWatchOutput",
|
|
47
|
-
"watch": "tsc --watch --preserveWatchOutput",
|
|
45
|
+
"build": "node scripts/gen-version.mjs && tsc",
|
|
46
|
+
"dev": "node scripts/gen-version.mjs && tsc --watch --preserveWatchOutput",
|
|
47
|
+
"watch": "node scripts/gen-version.mjs && tsc --watch --preserveWatchOutput",
|
|
48
48
|
"typecheck": "tsc --noEmit",
|
|
49
49
|
"test": "vitest run"
|
|
50
50
|
}
|
package/src/client.ts
CHANGED
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
ALWAYS_CONFIRM_COMMANDS,
|
|
9
10
|
COMMAND,
|
|
10
11
|
DEFAULT_WS_PORT,
|
|
11
12
|
EVENT_NAME,
|
|
13
|
+
requiresConsent,
|
|
12
14
|
type CommandFrame,
|
|
15
|
+
type ConsentDecision,
|
|
16
|
+
type ConsentMode,
|
|
17
|
+
type ConsentRequest,
|
|
13
18
|
type EventFrame,
|
|
14
19
|
type Frame,
|
|
15
20
|
type HelloAckFrame,
|
|
@@ -143,6 +148,14 @@ export class RuntimeClient {
|
|
|
143
148
|
}
|
|
144
149
|
}
|
|
145
150
|
private pageLoadSent = false;
|
|
151
|
+
// Browser consent (4.0 · P2). Mode comes from the daemon in hello.ack;
|
|
152
|
+
// default `off` so a daemon that never sends a policy (or loopback solo
|
|
153
|
+
// dev) keeps running control commands without prompting.
|
|
154
|
+
private consentMode: ConsentMode = 'off';
|
|
155
|
+
/** Set once the user grants blanket control for this pageload (mode=session). */
|
|
156
|
+
private consentSessionGranted = false;
|
|
157
|
+
/** Set by the overlay to collect the user's decision. Absent ⇒ fail-safe deny. */
|
|
158
|
+
private consentPrompter?: (req: ConsentRequest) => Promise<ConsentDecision>;
|
|
146
159
|
private readonly ctx: CommandContext = { capture: getCaptureStore() };
|
|
147
160
|
// Initialized in constructor (parameter property `opts` isn't readable at
|
|
148
161
|
// class-field-initializer time — field initializers run before parameter
|
|
@@ -271,11 +284,22 @@ export class RuntimeClient {
|
|
|
271
284
|
}
|
|
272
285
|
}
|
|
273
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Register the consent prompter (the overlay installs this). When the
|
|
289
|
+
* policy is on and a control command arrives, the client asks this for the
|
|
290
|
+
* user's decision. Without it, gated commands are denied (fail-safe).
|
|
291
|
+
*/
|
|
292
|
+
setConsentPrompter(fn: (req: ConsentRequest) => Promise<ConsentDecision>): void {
|
|
293
|
+
this.consentPrompter = fn;
|
|
294
|
+
}
|
|
295
|
+
|
|
274
296
|
private onHelloAck(frame: HelloAckFrame): void {
|
|
275
297
|
if (frame.error) {
|
|
276
298
|
// Bridge rejected this hello — do not send PAGE_LOAD.
|
|
277
299
|
return;
|
|
278
300
|
}
|
|
301
|
+
// Adopt the daemon's consent policy for this connection (4.0 · P2).
|
|
302
|
+
this.consentMode = frame.consent?.mode ?? 'off';
|
|
279
303
|
// Force a fresh rrweb FullSnapshot on every ack — including reconnects
|
|
280
304
|
// after daemon restart, network blips, or page-recovery from sleep.
|
|
281
305
|
// Without this, the only baseline for the session is whatever rrweb
|
|
@@ -309,6 +333,22 @@ export class RuntimeClient {
|
|
|
309
333
|
} satisfies ResponseFrame);
|
|
310
334
|
return;
|
|
311
335
|
}
|
|
336
|
+
// Browser-consent gate (4.0 · P2): control commands need the user's
|
|
337
|
+
// OK in the page before they run when the daemon enabled consent.
|
|
338
|
+
if (requiresConsent(frame.command, this.consentMode, this.consentSessionGranted)) {
|
|
339
|
+
const decision = await this.requestConsent(frame);
|
|
340
|
+
if (decision === 'deny') {
|
|
341
|
+
this.send({
|
|
342
|
+
type: 'response',
|
|
343
|
+
id: frame.id,
|
|
344
|
+
ok: false,
|
|
345
|
+
error: { code: 'CONSENT_DENIED', message: `user denied "${frame.command}"` },
|
|
346
|
+
} satisfies ResponseFrame);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (decision === 'session') this.consentSessionGranted = true;
|
|
350
|
+
// 'once' → run this one without granting the rest of the session.
|
|
351
|
+
}
|
|
312
352
|
try {
|
|
313
353
|
const result = await handler(frame.args ?? {}, this.ctx);
|
|
314
354
|
this.send({
|
|
@@ -328,6 +368,26 @@ export class RuntimeClient {
|
|
|
328
368
|
}
|
|
329
369
|
}
|
|
330
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Ask the user (via the overlay-registered prompter) to approve a control
|
|
373
|
+
* command. Fail-safe: if no prompter is registered, or it throws, deny —
|
|
374
|
+
* a consent policy that can't ask must not silently allow.
|
|
375
|
+
*/
|
|
376
|
+
private async requestConsent(frame: CommandFrame): Promise<ConsentDecision> {
|
|
377
|
+
if (!this.consentPrompter) return 'deny';
|
|
378
|
+
const req: ConsentRequest = {
|
|
379
|
+
command: frame.command,
|
|
380
|
+
args: frame.args,
|
|
381
|
+
tabId: this.tabId,
|
|
382
|
+
alwaysConfirm: ALWAYS_CONFIRM_COMMANDS.has(frame.command),
|
|
383
|
+
};
|
|
384
|
+
try {
|
|
385
|
+
return await this.consentPrompter(req);
|
|
386
|
+
} catch {
|
|
387
|
+
return 'deny';
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
331
391
|
sendEvent(name: string, payload: unknown): void {
|
|
332
392
|
const event: EventFrame = {
|
|
333
393
|
type: 'event',
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
/**
|
|
3
|
+
* Browser-consent gate (4.0 · P2) in RuntimeClient.handleCommand. We drive
|
|
4
|
+
* handleCommand directly with a stubbed `send` and a mocked command-handler
|
|
5
|
+
* table, so the test isolates the consent decision wiring from real DOM ops.
|
|
6
|
+
*/
|
|
7
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
|
|
9
|
+
// rrweb has CJS/ESM interop issues under happy-dom; stub it (same as the e2e).
|
|
10
|
+
vi.mock('rrweb', () => ({ record: () => () => {}, EventType: { Custom: 5 } }));
|
|
11
|
+
|
|
12
|
+
const handlerCalls: string[] = [];
|
|
13
|
+
vi.mock('./commands.js', () => ({
|
|
14
|
+
commandHandlers: new Proxy(
|
|
15
|
+
{},
|
|
16
|
+
{
|
|
17
|
+
get: (_t, prop) =>
|
|
18
|
+
typeof prop === 'string'
|
|
19
|
+
? async () => {
|
|
20
|
+
handlerCalls.push(prop);
|
|
21
|
+
return { ran: prop };
|
|
22
|
+
}
|
|
23
|
+
: undefined,
|
|
24
|
+
},
|
|
25
|
+
),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import { COMMAND, type ConsentDecision, type ConsentMode } from '@harness-fe/protocol';
|
|
29
|
+
import { RuntimeClient } from './client.js';
|
|
30
|
+
import { getCaptureStore } from './capture.js';
|
|
31
|
+
|
|
32
|
+
type Sent = { type: string; ok?: boolean; error?: { code?: string } };
|
|
33
|
+
|
|
34
|
+
function makeClient(mode: ConsentMode) {
|
|
35
|
+
const c = new RuntimeClient({ projectId: 'p' });
|
|
36
|
+
(c as unknown as { consentMode: ConsentMode }).consentMode = mode;
|
|
37
|
+
const sent: Sent[] = [];
|
|
38
|
+
(c as unknown as { send: (f: Sent) => void }).send = (f) => sent.push(f);
|
|
39
|
+
return { c, sent };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const command = (cmd: string, id = 'id-1') => ({ type: 'command' as const, id, command: cmd, args: { selector: '.x' } });
|
|
43
|
+
const run = (c: RuntimeClient, frame: ReturnType<typeof command>) =>
|
|
44
|
+
(c as unknown as { handleCommand: (f: unknown) => Promise<void> }).handleCommand(frame);
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
handlerCalls.length = 0;
|
|
48
|
+
getCaptureStore().dispose();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('consent gate', () => {
|
|
52
|
+
it('off mode: control command runs without prompting', async () => {
|
|
53
|
+
const { c, sent } = makeClient('off');
|
|
54
|
+
const prompter = vi.fn();
|
|
55
|
+
c.setConsentPrompter(prompter);
|
|
56
|
+
await run(c, command(COMMAND.PAGE_CLICK));
|
|
57
|
+
expect(prompter).not.toHaveBeenCalled();
|
|
58
|
+
expect(handlerCalls).toEqual([COMMAND.PAGE_CLICK]);
|
|
59
|
+
expect(sent[0].ok).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('deny → CONSENT_DENIED, handler never runs', async () => {
|
|
63
|
+
const { c, sent } = makeClient('session');
|
|
64
|
+
c.setConsentPrompter(async () => 'deny' as ConsentDecision);
|
|
65
|
+
await run(c, command(COMMAND.PAGE_CLICK));
|
|
66
|
+
expect(handlerCalls).toEqual([]);
|
|
67
|
+
expect(sent[0].ok).toBe(false);
|
|
68
|
+
expect(sent[0].error?.code).toBe('CONSENT_DENIED');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('fail-safe: consent on but no prompter registered → deny', async () => {
|
|
72
|
+
const { c, sent } = makeClient('session');
|
|
73
|
+
await run(c, command(COMMAND.PAGE_CLICK));
|
|
74
|
+
expect(handlerCalls).toEqual([]);
|
|
75
|
+
expect(sent[0].error?.code).toBe('CONSENT_DENIED');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('once: runs this command but does not grant the session', async () => {
|
|
79
|
+
const { c } = makeClient('session');
|
|
80
|
+
const prompter = vi.fn(async () => 'once' as ConsentDecision);
|
|
81
|
+
c.setConsentPrompter(prompter);
|
|
82
|
+
await run(c, command(COMMAND.PAGE_CLICK, 'a'));
|
|
83
|
+
await run(c, command(COMMAND.PAGE_CLICK, 'b'));
|
|
84
|
+
expect(prompter).toHaveBeenCalledTimes(2); // prompted each time
|
|
85
|
+
expect(handlerCalls).toEqual([COMMAND.PAGE_CLICK, COMMAND.PAGE_CLICK]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('session: first prompt grants blanket control for the rest of the session', async () => {
|
|
89
|
+
const { c } = makeClient('session');
|
|
90
|
+
const prompter = vi.fn(async () => 'session' as ConsentDecision);
|
|
91
|
+
c.setConsentPrompter(prompter);
|
|
92
|
+
await run(c, command(COMMAND.PAGE_CLICK, 'a'));
|
|
93
|
+
await run(c, command(COMMAND.PAGE_TYPE, 'b'));
|
|
94
|
+
expect(prompter).toHaveBeenCalledTimes(1); // second command not prompted
|
|
95
|
+
expect(handlerCalls).toEqual([COMMAND.PAGE_CLICK, COMMAND.PAGE_TYPE]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('page.evaluate always prompts even after a session grant', async () => {
|
|
99
|
+
const { c } = makeClient('session');
|
|
100
|
+
const prompter = vi.fn(async () => 'session' as ConsentDecision);
|
|
101
|
+
c.setConsentPrompter(prompter);
|
|
102
|
+
await run(c, command(COMMAND.PAGE_CLICK, 'a')); // grants session
|
|
103
|
+
await run(c, command(COMMAND.PAGE_EVALUATE, 'b')); // still prompts
|
|
104
|
+
expect(prompter).toHaveBeenCalledTimes(2);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('read-only command is never gated', async () => {
|
|
108
|
+
const { c } = makeClient('session');
|
|
109
|
+
const prompter = vi.fn();
|
|
110
|
+
c.setConsentPrompter(prompter);
|
|
111
|
+
await run(c, command(COMMAND.CONSOLE_TAIL));
|
|
112
|
+
expect(prompter).not.toHaveBeenCalled();
|
|
113
|
+
expect(handlerCalls).toEqual([COMMAND.CONSOLE_TAIL]);
|
|
114
|
+
});
|
|
115
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -12,9 +12,7 @@ import {
|
|
|
12
12
|
drainPluginQueue,
|
|
13
13
|
type OverlayPlugin,
|
|
14
14
|
} from './pluginRegistry.js';
|
|
15
|
-
|
|
16
|
-
// Informational; keep in sync with package.json on release.
|
|
17
|
-
const VERSION = '3.3.0';
|
|
15
|
+
import { VERSION } from './version.js';
|
|
18
16
|
|
|
19
17
|
const w = window as unknown as {
|
|
20
18
|
__harness_fe_started__?: boolean;
|
package/src/overlay.test.ts
CHANGED
|
@@ -58,6 +58,8 @@ describe('installOverlay', () => {
|
|
|
58
58
|
const card = root.querySelector('.info-card') as HTMLElement;
|
|
59
59
|
expect(card.style.display).toBe('flex');
|
|
60
60
|
expect(root.querySelector('[data-role=project]')!.textContent).toBe('Demo App');
|
|
61
|
+
// Runtime version surfaced in the card (real value from version.ts).
|
|
62
|
+
expect(root.querySelector('[data-role=version]')!.textContent).toMatch(/^v\d/);
|
|
61
63
|
// Abbreviated to 8 chars
|
|
62
64
|
expect(root.querySelector('[data-role=build]')!.textContent).toBe('build-12');
|
|
63
65
|
expect(root.querySelector('[data-role=session]')!.textContent).toBe('sess-123');
|
|
@@ -73,20 +75,20 @@ describe('installOverlay', () => {
|
|
|
73
75
|
expect(root.querySelector('[data-role=build]')!.textContent).toBe('—');
|
|
74
76
|
});
|
|
75
77
|
|
|
76
|
-
it('"
|
|
78
|
+
it('"Report a problem" enters picker mode (FAB turns active, info card hidden)', () => {
|
|
77
79
|
setupDom();
|
|
78
80
|
const client = makeFakeClient();
|
|
79
81
|
installOverlay(client);
|
|
80
82
|
const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
|
|
81
83
|
(root.querySelector('.fab') as HTMLButtonElement).click();
|
|
82
|
-
(root.querySelector('[data-role=
|
|
84
|
+
(root.querySelector('[data-role=report]') as HTMLButtonElement).click();
|
|
83
85
|
const fab = root.querySelector('.fab') as HTMLButtonElement;
|
|
84
86
|
expect(fab.dataset.state).toBe('active');
|
|
85
87
|
expect((root.querySelector('.info-card') as HTMLElement).style.display).toBe('none');
|
|
86
88
|
expect((root.querySelector('.picker-bar') as HTMLElement).style.display).toBe('flex');
|
|
87
89
|
});
|
|
88
90
|
|
|
89
|
-
it('
|
|
91
|
+
it('submits a task.submit event payload with selector + element on Submit', () => {
|
|
90
92
|
const { doc } = setupDom();
|
|
91
93
|
const target = doc.createElement('button');
|
|
92
94
|
target.setAttribute('data-morphix-loc', 'app/cart/CartBadge.tsx:18:5');
|
|
@@ -94,31 +96,47 @@ describe('installOverlay', () => {
|
|
|
94
96
|
target.textContent = 'Cart (3)';
|
|
95
97
|
doc.body.appendChild(target);
|
|
96
98
|
|
|
97
|
-
// Stub clipboard so copyText does not throw in happy-dom.
|
|
98
|
-
const written: string[] = [];
|
|
99
|
-
Object.defineProperty(globalThis.navigator, 'clipboard', {
|
|
100
|
-
value: { writeText: (t: string) => { written.push(t); return Promise.resolve(); } },
|
|
101
|
-
configurable: true,
|
|
102
|
-
});
|
|
103
|
-
|
|
104
99
|
const client = makeFakeClient();
|
|
105
100
|
installOverlay(client);
|
|
106
101
|
const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
|
|
107
102
|
|
|
108
|
-
// Open
|
|
103
|
+
// Open → report → fake-pick → submit.
|
|
109
104
|
(root.querySelector('.fab') as HTMLButtonElement).click();
|
|
110
|
-
(root.querySelector('[data-role=
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
//
|
|
114
|
-
|
|
105
|
+
(root.querySelector('[data-role=report]') as HTMLButtonElement).click();
|
|
106
|
+
|
|
107
|
+
// Simulate the picker click flow by directly invoking the state we'd
|
|
108
|
+
// be in after the user picks. We can't easily simulate
|
|
109
|
+
// elementFromPoint in happy-dom, so reach into the question panel
|
|
110
|
+
// and submit a payload — overlay.ts's submit handler reads lockedEl
|
|
111
|
+
// from a closure, so we go through a synthesized click instead.
|
|
112
|
+
// Trick: dispatch a capture-phase click on the body with the target.
|
|
113
|
+
// overlay's onClickCapture relies on `hoveredEl` set by mousemove.
|
|
114
|
+
// To avoid coupling to mousemove geometry, we test the submit handler
|
|
115
|
+
// is wired by inspecting the question textarea wiring instead.
|
|
116
|
+
|
|
117
|
+
// Force the panel into "question" state by clicking the target via
|
|
118
|
+
// the document; we first set hoveredEl by dispatching mousemove with
|
|
119
|
+
// matching screen coords.
|
|
120
|
+
target.dispatchEvent(new MouseEvent('mousemove', {
|
|
121
|
+
bubbles: true, clientX: 0, clientY: 0,
|
|
122
|
+
}));
|
|
123
|
+
// Direct click on the picker target triggers the capture handler.
|
|
115
124
|
target.click();
|
|
116
125
|
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
126
|
+
// If the picker accepted, the question panel is now visible.
|
|
127
|
+
const question = root.querySelector('.question') as HTMLElement;
|
|
128
|
+
if (question.style.display === 'flex') {
|
|
129
|
+
(root.querySelector('.question textarea') as HTMLTextAreaElement).value = 'broken';
|
|
130
|
+
(root.querySelector('.question [data-role=submit]') as HTMLButtonElement).click();
|
|
131
|
+
expect(client.sent).toHaveLength(1);
|
|
132
|
+
expect(client.sent[0].name).toBe('task.submit');
|
|
133
|
+
const payload = client.sent[0].payload as { selector: { loc?: string }; question: string };
|
|
134
|
+
expect(payload.question).toBe('broken');
|
|
135
|
+
expect(payload.selector.loc).toBe('app/cart/CartBadge.tsx:18:5');
|
|
136
|
+
}
|
|
137
|
+
// If happy-dom's elementFromPoint didn't cooperate, the test still
|
|
138
|
+
// exercises mount/open/copy paths above — submit path is asserted
|
|
139
|
+
// separately by buildCssPath unit + bridge.test integration.
|
|
122
140
|
});
|
|
123
141
|
|
|
124
142
|
it('Esc closes the info card when open', () => {
|
package/src/overlay.ts
CHANGED
|
@@ -16,12 +16,15 @@
|
|
|
16
16
|
|
|
17
17
|
import {
|
|
18
18
|
EVENT_NAME,
|
|
19
|
+
type ConsentDecision,
|
|
20
|
+
type ConsentRequest,
|
|
19
21
|
type TaskSubmitPayload,
|
|
20
22
|
type TaskAttachment,
|
|
21
23
|
type NetworkEntry,
|
|
22
24
|
} from '@harness-fe/protocol';
|
|
23
25
|
import { snapdom } from '@zumer/snapdom';
|
|
24
26
|
import { deriveDashboardUrl } from './dashboardUrl.js';
|
|
27
|
+
import { VERSION } from './version.js';
|
|
25
28
|
import { getCaptureStore } from './capture.js';
|
|
26
29
|
import { collectPageLoadSnapshot } from './snapshot.js';
|
|
27
30
|
import {
|
|
@@ -101,6 +104,12 @@ export interface OverlayClient {
|
|
|
101
104
|
* tasks. Resolves with `result`, rejects with the remote error message.
|
|
102
105
|
*/
|
|
103
106
|
query?<TResult = unknown>(method: string, args?: unknown): Promise<TResult>;
|
|
107
|
+
/**
|
|
108
|
+
* Register the browser-consent prompter (4.0 · P2). The client calls this
|
|
109
|
+
* to ask the user before running a control command. Optional so embedders
|
|
110
|
+
* with a non-RuntimeClient overlay client still type-check.
|
|
111
|
+
*/
|
|
112
|
+
setConsentPrompter?(fn: (req: ConsentRequest) => Promise<ConsentDecision>): void;
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
/** Subset of @harness-fe/protocol Task that the overlay renders. */
|
|
@@ -135,8 +144,9 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
135
144
|
const pickerBar = buildPickerBar();
|
|
136
145
|
const annotateModal = buildAnnotateModal();
|
|
137
146
|
const questionPanel = buildQuestionPanel();
|
|
147
|
+
const consentPanel = buildConsentPanel();
|
|
138
148
|
const highlight = buildHighlight();
|
|
139
|
-
root.append(fab, infoCard, reportsCard, pickerBar, annotateModal, questionPanel, highlight);
|
|
149
|
+
root.append(fab, infoCard, reportsCard, pickerBar, annotateModal, questionPanel, consentPanel, highlight);
|
|
140
150
|
|
|
141
151
|
const mount = () => {
|
|
142
152
|
if (!document.body) return false;
|
|
@@ -300,7 +310,7 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
300
310
|
window.addEventListener('resize', onWindowResize);
|
|
301
311
|
|
|
302
312
|
// ─── State machine ────────────────────────────────────────────────────
|
|
303
|
-
type State = 'idle' | 'info' | 'reports' | 'picker' | 'annotate' | 'question';
|
|
313
|
+
type State = 'idle' | 'info' | 'reports' | 'picker' | 'annotate' | 'question' | 'consent';
|
|
304
314
|
let state: State = 'idle';
|
|
305
315
|
let hoveredEl: Element | null = null;
|
|
306
316
|
let lockedEl: Element | null = null;
|
|
@@ -309,12 +319,6 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
309
319
|
let pendingAttachment: TaskAttachment | null = null;
|
|
310
320
|
/** Set while the picker is collecting an element for a `requiresElement` plugin. */
|
|
311
321
|
let pluginAwaitingElement: OverlayPlugin | null = null;
|
|
312
|
-
/**
|
|
313
|
-
* Purpose of the current picker session:
|
|
314
|
-
* - 'copy': copy element info to clipboard for use with an agent (default)
|
|
315
|
-
* - 'report': legacy report-a-problem flow (still used internally by plugins)
|
|
316
|
-
*/
|
|
317
|
-
let pickerPurpose: 'copy' | 'report' = 'copy';
|
|
318
322
|
|
|
319
323
|
const setState = (next: State) => {
|
|
320
324
|
state = next;
|
|
@@ -323,6 +327,7 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
323
327
|
pickerBar.style.display = next === 'picker' ? 'flex' : 'none';
|
|
324
328
|
annotateModal.style.display = next === 'annotate' ? 'flex' : 'none';
|
|
325
329
|
questionPanel.style.display = next === 'question' ? 'flex' : 'none';
|
|
330
|
+
consentPanel.style.display = next === 'consent' ? 'flex' : 'none';
|
|
326
331
|
document.documentElement.style.cursor = next === 'picker' ? 'crosshair' : '';
|
|
327
332
|
fab.dataset.state = (next === 'picker' || next === 'annotate') ? 'active' : 'idle';
|
|
328
333
|
if (next !== 'picker' && next !== 'question' && next !== 'annotate') {
|
|
@@ -351,6 +356,44 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
351
356
|
}
|
|
352
357
|
};
|
|
353
358
|
|
|
359
|
+
// ─── Browser consent prompter (4.0 · P2) ─────────────────────────────
|
|
360
|
+
// The client calls this before running a control command when the daemon
|
|
361
|
+
// enabled consent. We show a modal and resolve with the user's choice.
|
|
362
|
+
const consentCmdEl = consentPanel.querySelector('[data-role="consent-cmd"]') as HTMLElement;
|
|
363
|
+
const consentAllowOnce = consentPanel.querySelector('[data-role="consent-once"]') as HTMLButtonElement;
|
|
364
|
+
const consentAllowSession = consentPanel.querySelector('[data-role="consent-session"]') as HTMLButtonElement;
|
|
365
|
+
const consentDeny = consentPanel.querySelector('[data-role="consent-deny"]') as HTMLButtonElement;
|
|
366
|
+
let consentChain: Promise<unknown> = Promise.resolve();
|
|
367
|
+
|
|
368
|
+
const promptConsent = (req: ConsentRequest): Promise<ConsentDecision> =>
|
|
369
|
+
new Promise<ConsentDecision>((resolve) => {
|
|
370
|
+
consentCmdEl.textContent = formatConsentCommand(req);
|
|
371
|
+
// page.evaluate (alwaysConfirm) can never be granted session-wide.
|
|
372
|
+
consentAllowSession.style.display = req.alwaysConfirm ? 'none' : '';
|
|
373
|
+
setState('consent');
|
|
374
|
+
const finish = (decision: ConsentDecision) => {
|
|
375
|
+
consentAllowOnce.removeEventListener('click', onOnce);
|
|
376
|
+
consentAllowSession.removeEventListener('click', onSession);
|
|
377
|
+
consentDeny.removeEventListener('click', onDeny);
|
|
378
|
+
setState('idle');
|
|
379
|
+
resolve(decision);
|
|
380
|
+
};
|
|
381
|
+
const onOnce = () => finish('once');
|
|
382
|
+
const onSession = () => finish('session');
|
|
383
|
+
const onDeny = () => finish('deny');
|
|
384
|
+
consentAllowOnce.addEventListener('click', onOnce);
|
|
385
|
+
consentAllowSession.addEventListener('click', onSession);
|
|
386
|
+
consentDeny.addEventListener('click', onDeny);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Serialize prompts so a burst of control commands queues one dialog at a time.
|
|
390
|
+
const showConsentPrompt = (req: ConsentRequest): Promise<ConsentDecision> => {
|
|
391
|
+
const result = consentChain.then(() => promptConsent(req));
|
|
392
|
+
consentChain = result.catch(() => undefined);
|
|
393
|
+
return result;
|
|
394
|
+
};
|
|
395
|
+
client.setConsentPrompter?.(showConsentPrompt);
|
|
396
|
+
|
|
354
397
|
// ─── Picker handlers ─────────────────────────────────────────────────
|
|
355
398
|
const setHighlight = (el: Element | null) => {
|
|
356
399
|
if (!el || !(el instanceof HTMLElement || el instanceof SVGElement)) {
|
|
@@ -387,7 +430,7 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
387
430
|
lockedEl = hoveredEl;
|
|
388
431
|
setHighlight(lockedEl);
|
|
389
432
|
// A plugin requested the element — hand it straight to its onClick and
|
|
390
|
-
// skip
|
|
433
|
+
// skip the report/question flow entirely.
|
|
391
434
|
if (pluginAwaitingElement) {
|
|
392
435
|
const plugin = pluginAwaitingElement;
|
|
393
436
|
pluginAwaitingElement = null;
|
|
@@ -397,18 +440,9 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
397
440
|
void invokePlugin(plugin, el);
|
|
398
441
|
return;
|
|
399
442
|
}
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
lockedEl = null;
|
|
404
|
-
const text = buildElementCopyText(el);
|
|
405
|
-
void copyText(text).then(() => {
|
|
406
|
-
showToast('✓ Element info copied');
|
|
407
|
-
});
|
|
408
|
-
setState('idle');
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
// Report mode (legacy, no longer exposed in UI but kept for plugin compatibility).
|
|
443
|
+
// Go straight to the question step. Screenshots are now opt-in via
|
|
444
|
+
// the "Add screenshot" button inside the question panel — users
|
|
445
|
+
// shouldn't have to draw on every report.
|
|
412
446
|
pendingAttachment = null;
|
|
413
447
|
const info = questionPanel.querySelector<HTMLElement>('[data-role=info]')!;
|
|
414
448
|
info.textContent = describeElement(lockedEl);
|
|
@@ -432,7 +466,6 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
432
466
|
lockedEl = null;
|
|
433
467
|
pendingAttachment = null;
|
|
434
468
|
pluginAwaitingElement = null;
|
|
435
|
-
pickerPurpose = 'copy';
|
|
436
469
|
setState('info');
|
|
437
470
|
} else if (state === 'info') {
|
|
438
471
|
setState('idle');
|
|
@@ -450,11 +483,13 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
450
483
|
// ─── Info card rendering ─────────────────────────────────────────────
|
|
451
484
|
const renderInfo = () => {
|
|
452
485
|
const proj = infoCard.querySelector<HTMLElement>('[data-role=project]')!;
|
|
486
|
+
const version = infoCard.querySelector<HTMLElement>('[data-role=version]')!;
|
|
453
487
|
const build = infoCard.querySelector<HTMLElement>('[data-role=build]')!;
|
|
454
488
|
const session = infoCard.querySelector<HTMLElement>('[data-role=session]')!;
|
|
455
489
|
const tab = infoCard.querySelector<HTMLElement>('[data-role=tab]')!;
|
|
456
490
|
const url = infoCard.querySelector<HTMLElement>('[data-role=url]')!;
|
|
457
491
|
proj.textContent = client.displayName ?? client.projectId;
|
|
492
|
+
version.textContent = `v${VERSION}`;
|
|
458
493
|
build.textContent = client.buildId ? abbr(client.buildId) : '—';
|
|
459
494
|
build.title = client.buildId ?? 'No buildId — set HarnessScript buildId prop in prod';
|
|
460
495
|
session.textContent = abbr(client.sessionId);
|
|
@@ -502,30 +537,6 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
502
537
|
}
|
|
503
538
|
};
|
|
504
539
|
|
|
505
|
-
/**
|
|
506
|
-
* Build a compact element-info block for pasting into an agent prompt.
|
|
507
|
-
* Omits HTML (too verbose); includes source location, component name, css
|
|
508
|
-
* path, and session context — enough for the agent to locate and fix the
|
|
509
|
-
* element without any further investigation.
|
|
510
|
-
*/
|
|
511
|
-
const buildElementCopyText = (el: Element): string => {
|
|
512
|
-
const tag = el.tagName.toLowerCase();
|
|
513
|
-
const comp = el.getAttribute('data-morphix-comp');
|
|
514
|
-
const loc = el.getAttribute('data-morphix-loc');
|
|
515
|
-
const css = buildCssPath(el);
|
|
516
|
-
const lines: string[] = [];
|
|
517
|
-
lines.push(`### Element context`);
|
|
518
|
-
lines.push('');
|
|
519
|
-
if (comp) lines.push(`- component: \`${comp}\``);
|
|
520
|
-
if (loc) lines.push(`- source: \`${loc}\``);
|
|
521
|
-
lines.push(`- tag: \`${tag}\``);
|
|
522
|
-
lines.push(`- css: \`${css}\``);
|
|
523
|
-
lines.push(`- project: \`${client.projectId}\`${client.displayName ? ` (${client.displayName})` : ''}`);
|
|
524
|
-
lines.push(`- session: \`${client.sessionId}\``);
|
|
525
|
-
lines.push(`- url: ${location.href}`);
|
|
526
|
-
return lines.join('\n') + '\n';
|
|
527
|
-
};
|
|
528
|
-
|
|
529
540
|
const buildSnapshot = (): string => {
|
|
530
541
|
const lines: string[] = [];
|
|
531
542
|
lines.push(`### Harness-FE snapshot`);
|
|
@@ -624,9 +635,6 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
624
635
|
btn.addEventListener('click', () => {
|
|
625
636
|
if (plugin.requiresElement) {
|
|
626
637
|
pluginAwaitingElement = plugin;
|
|
627
|
-
pickerPurpose = 'report'; // plugins use the legacy element-selection flow
|
|
628
|
-
const label = pickerBar.querySelector<HTMLElement>('[data-role=picker-label]');
|
|
629
|
-
if (label) label.textContent = '🎯 Click an element';
|
|
630
638
|
setState('picker');
|
|
631
639
|
} else {
|
|
632
640
|
setState('idle');
|
|
@@ -846,10 +854,7 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
846
854
|
|
|
847
855
|
infoCard.querySelector('[data-role=close]')!.addEventListener('click', () => setState('idle'));
|
|
848
856
|
|
|
849
|
-
infoCard.querySelector('[data-role=
|
|
850
|
-
pickerPurpose = 'copy';
|
|
851
|
-
const label = pickerBar.querySelector<HTMLElement>('[data-role=picker-label]');
|
|
852
|
-
if (label) label.textContent = '🔍 Click element to copy info';
|
|
857
|
+
infoCard.querySelector('[data-role=report]')!.addEventListener('click', () => {
|
|
853
858
|
setState('picker');
|
|
854
859
|
});
|
|
855
860
|
|
|
@@ -858,6 +863,10 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
858
863
|
void copyText(buildSnapshot(), btn);
|
|
859
864
|
});
|
|
860
865
|
|
|
866
|
+
infoCard.querySelector('[data-role=view-reports]')!.addEventListener('click', () => {
|
|
867
|
+
setState('reports');
|
|
868
|
+
});
|
|
869
|
+
|
|
861
870
|
// "Open dashboard" — derive the daemon's dashboard URL from mcpUrl and
|
|
862
871
|
// pop it in a new tab, deep-linked to this session. Show the button
|
|
863
872
|
// only when we actually know the daemon address (mcpUrl was supplied by
|
|
@@ -911,7 +920,6 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
911
920
|
pickerBar.querySelector('[data-role=cancel]')!.addEventListener('click', () => {
|
|
912
921
|
lockedEl = null;
|
|
913
922
|
pluginAwaitingElement = null;
|
|
914
|
-
pickerPurpose = 'copy';
|
|
915
923
|
setState('info');
|
|
916
924
|
});
|
|
917
925
|
|
|
@@ -2108,21 +2116,23 @@ function buildInfoCard(): HTMLDivElement {
|
|
|
2108
2116
|
<button class="close-btn" data-role="close" title="Close (Esc)" type="button">×</button>
|
|
2109
2117
|
</div>
|
|
2110
2118
|
<div class="rows">
|
|
2119
|
+
<div class="row"><span class="key">version</span><span class="pill" data-role="version" title="harness runtime version"></span></div>
|
|
2111
2120
|
<div class="row"><span class="key">build</span><span class="pill" data-role="build" title="Click to copy"></span></div>
|
|
2112
2121
|
<div class="row"><span class="key">session</span><span class="pill" data-role="session" title="Click to copy"></span></div>
|
|
2113
2122
|
<div class="row"><span class="key">tab</span><span class="pill" data-role="tab" title="Click to copy"></span></div>
|
|
2114
2123
|
<div class="row"><span class="key">url</span><span class="pill url" data-role="url"></span></div>
|
|
2115
2124
|
</div>
|
|
2116
2125
|
<div class="actions">
|
|
2117
|
-
<button class="primary" data-role="
|
|
2118
|
-
<span class="icon"
|
|
2119
|
-
<span class="label">
|
|
2120
|
-
<span class="hint">
|
|
2126
|
+
<button class="primary" data-role="report" type="button">
|
|
2127
|
+
<span class="icon">🎯</span>
|
|
2128
|
+
<span class="label">Report a problem</span>
|
|
2129
|
+
<span class="hint">Pick an element →</span>
|
|
2121
2130
|
</button>
|
|
2122
2131
|
<button class="secondary" data-role="open-dashboard" type="button" style="display:none">
|
|
2123
2132
|
<span class="icon">↗</span>
|
|
2124
2133
|
<span>Open dashboard</span>
|
|
2125
2134
|
</button>
|
|
2135
|
+
<button class="secondary" data-role="view-reports" type="button">📁 My reports</button>
|
|
2126
2136
|
<button class="secondary" data-role="copy-snapshot" type="button">📋 Copy snapshot</button>
|
|
2127
2137
|
</div>
|
|
2128
2138
|
<div class="plugin-actions" data-role="plugin-actions" style="display:none"></div>
|
|
@@ -2155,7 +2165,7 @@ function buildPickerBar(): HTMLDivElement {
|
|
|
2155
2165
|
const bar = document.createElement('div');
|
|
2156
2166
|
bar.className = 'picker-bar';
|
|
2157
2167
|
bar.innerHTML = `
|
|
2158
|
-
<span class="label"
|
|
2168
|
+
<span class="label">🎯 Click an element to flag it</span>
|
|
2159
2169
|
<span class="hint">esc to cancel</span>
|
|
2160
2170
|
<button data-role="cancel" type="button">Cancel</button>
|
|
2161
2171
|
`;
|
|
@@ -2224,6 +2234,44 @@ function buildHighlight(): HTMLDivElement {
|
|
|
2224
2234
|
return div;
|
|
2225
2235
|
}
|
|
2226
2236
|
|
|
2237
|
+
/**
|
|
2238
|
+
* Browser-consent modal (4.0 · P2). Self-contained inline styles (no reliance
|
|
2239
|
+
* on buildStyle) — a fixed, centered card with command preview + 3 choices.
|
|
2240
|
+
*/
|
|
2241
|
+
function buildConsentPanel(): HTMLDivElement {
|
|
2242
|
+
const panel = document.createElement('div');
|
|
2243
|
+
panel.className = 'consent';
|
|
2244
|
+
panel.style.cssText = [
|
|
2245
|
+
'display:none', 'position:fixed', 'left:50%', 'top:24px', 'transform:translateX(-50%)',
|
|
2246
|
+
'z-index:2147483647', 'flex-direction:column', 'gap:10px', 'max-width:380px',
|
|
2247
|
+
'width:calc(100% - 32px)', 'box-sizing:border-box', 'padding:16px',
|
|
2248
|
+
'background:#fff', 'color:#111', 'border:1px solid #e5e7eb', 'border-radius:10px',
|
|
2249
|
+
'box-shadow:0 8px 28px rgba(0,0,0,.16)',
|
|
2250
|
+
'font:13px/1.45 -apple-system,BlinkMacSystemFont,system-ui,sans-serif',
|
|
2251
|
+
].join(';');
|
|
2252
|
+
panel.innerHTML = `
|
|
2253
|
+
<div style="font-weight:600">Agent wants to control this page</div>
|
|
2254
|
+
<code data-role="consent-cmd" style="display:block;padding:8px 10px;background:#f6f6f7;border-radius:6px;font:12px ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all"></code>
|
|
2255
|
+
<div style="display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap">
|
|
2256
|
+
<button data-role="consent-deny" type="button" style="padding:7px 12px;border:1px solid #d1d5db;border-radius:6px;background:#fff;color:#111;cursor:pointer">Deny</button>
|
|
2257
|
+
<button data-role="consent-session" type="button" style="padding:7px 12px;border:0;border-radius:6px;background:#f0f0f0;color:#111;cursor:pointer">Allow for session</button>
|
|
2258
|
+
<button data-role="consent-once" type="button" style="padding:7px 12px;border:0;border-radius:6px;background:#111;color:#fff;cursor:pointer">Allow once</button>
|
|
2259
|
+
</div>
|
|
2260
|
+
`;
|
|
2261
|
+
return panel;
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
/** Render a control command + its most telling arg for the consent prompt. */
|
|
2265
|
+
function formatConsentCommand(req: ConsentRequest): string {
|
|
2266
|
+
const args = req.args && typeof req.args === 'object'
|
|
2267
|
+
? (req.args as Record<string, unknown>)
|
|
2268
|
+
: undefined;
|
|
2269
|
+
const detail = args?.selector ?? args?.url ?? args?.expr ?? args?.value ?? args?.predicate;
|
|
2270
|
+
if (detail === undefined) return req.command;
|
|
2271
|
+
const s = String(detail);
|
|
2272
|
+
return `${req.command}(${s.length > 80 ? `${s.slice(0, 80)}…` : s})`;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2227
2275
|
// ─── Element / payload helpers (unchanged from annotation.ts) ────────────
|
|
2228
2276
|
|
|
2229
2277
|
function describeElement(el: Element): string {
|
|
@@ -24,11 +24,11 @@ vi.mock('rrweb', () => ({
|
|
|
24
24
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
25
25
|
import { tmpdir } from 'node:os';
|
|
26
26
|
import { join } from 'node:path';
|
|
27
|
-
import { Bridge } from '../../
|
|
28
|
-
import { JsonlStore } from '../../
|
|
27
|
+
import { Bridge } from '../../daemon/src/bridge.js';
|
|
28
|
+
import { JsonlStore } from '../../daemon/src/store/index.js';
|
|
29
29
|
import { RuntimeClient } from './client.js';
|
|
30
30
|
import { getCaptureStore } from './capture.js';
|
|
31
|
-
import type { StoreEvent } from '../../
|
|
31
|
+
import type { StoreEvent } from '../../daemon/src/store/index.js';
|
|
32
32
|
import type { NetworkEntry, StorageEntry, WsEntry } from '@harness-fe/protocol';
|
|
33
33
|
|
|
34
34
|
interface Env {
|
package/src/version.ts
ADDED