@harness-fe/runtime 3.3.0 → 3.4.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/dist/index.d.ts +2 -0
- package/dist/index.js +9 -0
- package/dist/overlay.d.ts +9 -0
- package/dist/overlay.js +291 -17
- package/dist/pluginRegistry.d.ts +127 -0
- package/dist/pluginRegistry.js +80 -0
- package/package.json +1 -1
- package/src/index.ts +29 -0
- package/src/overlay.test.ts +143 -37
- package/src/overlay.ts +306 -18
- package/src/pluginRegistry.test.ts +76 -0
- package/src/pluginRegistry.ts +189 -0
package/dist/index.d.ts
CHANGED
|
@@ -6,3 +6,5 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export { RuntimeClient, tryInheritFromParent } from './client.js';
|
|
8
8
|
export type { ClientOptions, ParentInheritance } from './client.js';
|
|
9
|
+
export { registerOverlayPlugin, getOverlayPlugins, subscribeOverlayPlugins, } from './pluginRegistry.js';
|
|
10
|
+
export type { OverlayPlugin, OverlayPluginContext, OverlayPluginSelectedElement, OverlayPluginSelector, OverlayPluginLogs, OverlayPluginGetLogsOptions, } from './pluginRegistry.js';
|
package/dist/index.js
CHANGED
|
@@ -6,9 +6,17 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { installOverlay } from './overlay.js';
|
|
8
8
|
import { RuntimeClient, readInjectedConfig } from './client.js';
|
|
9
|
+
import { registerOverlayPlugin, drainPluginQueue, } from './pluginRegistry.js';
|
|
10
|
+
// Informational; keep in sync with package.json on release.
|
|
11
|
+
const VERSION = '3.3.0';
|
|
9
12
|
const w = window;
|
|
10
13
|
if (typeof window !== 'undefined' && !w.__harness_fe_started__) {
|
|
11
14
|
w.__harness_fe_started__ = true;
|
|
15
|
+
// Public global for runtime plugin registration. Works before or after the
|
|
16
|
+
// overlay mounts — the registry buffers and the overlay subscribes.
|
|
17
|
+
w.HarnessFE = { registerOverlayPlugin, version: VERSION };
|
|
18
|
+
// Drain any plugins queued before the runtime loaded.
|
|
19
|
+
drainPluginQueue(w.__HARNESS_FE_PLUGINS__);
|
|
12
20
|
const cfg = readInjectedConfig();
|
|
13
21
|
const client = new RuntimeClient(cfg);
|
|
14
22
|
client.start();
|
|
@@ -21,3 +29,4 @@ if (typeof window !== 'undefined' && !w.__harness_fe_started__) {
|
|
|
21
29
|
w.__hfe_session_id__ = client.sessionId;
|
|
22
30
|
}
|
|
23
31
|
export { RuntimeClient, tryInheritFromParent } from './client.js';
|
|
32
|
+
export { registerOverlayPlugin, getOverlayPlugins, subscribeOverlayPlugins, } from './pluginRegistry.js';
|
package/dist/overlay.d.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* or out. State machine: idle → info → (picker → question) → flash → idle.
|
|
15
15
|
*/
|
|
16
16
|
import { type TaskAttachment } from '@harness-fe/protocol';
|
|
17
|
+
import { type OverlayPluginLogs, type OverlayPluginGetLogsOptions } from './pluginRegistry.js';
|
|
17
18
|
export interface OverlayClient {
|
|
18
19
|
readonly projectId: string;
|
|
19
20
|
readonly buildId?: string;
|
|
@@ -60,6 +61,14 @@ export declare function replayStrokes(ctx: CanvasRenderingContext2D, bgCanvas: H
|
|
|
60
61
|
* Exported for testing.
|
|
61
62
|
*/
|
|
62
63
|
export declare function finalizeAnnotation(): Promise<TaskAttachment | null>;
|
|
64
|
+
/**
|
|
65
|
+
* Rasterize an element to a PNG TaskAttachment via snapdom. Returns null on
|
|
66
|
+
* failure (cross-origin, test env, …) so plugins can degrade gracefully.
|
|
67
|
+
* Exported for testing.
|
|
68
|
+
*/
|
|
69
|
+
export declare function captureElementPng(el: Element): Promise<TaskAttachment | null>;
|
|
70
|
+
/** Read recent buffered logs for the plugin context. Redacts network by default. */
|
|
71
|
+
export declare function collectLogs(opts?: OverlayPluginGetLogsOptions): OverlayPluginLogs;
|
|
63
72
|
/**
|
|
64
73
|
* Best-effort CSS path. Depth cap 12, id anchor short-circuits, ` >>> `
|
|
65
74
|
* separates shadow-DOM boundaries.
|
package/dist/overlay.js
CHANGED
|
@@ -13,9 +13,14 @@
|
|
|
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 { EVENT_NAME } from '@harness-fe/protocol';
|
|
16
|
+
import { EVENT_NAME, } from '@harness-fe/protocol';
|
|
17
17
|
import { snapdom } from '@zumer/snapdom';
|
|
18
18
|
import { deriveDashboardUrl } from './dashboardUrl.js';
|
|
19
|
+
import { getCaptureStore } from './capture.js';
|
|
20
|
+
import { collectPageLoadSnapshot } from './snapshot.js';
|
|
21
|
+
import { getOverlayPlugins, subscribeOverlayPlugins, } from './pluginRegistry.js';
|
|
22
|
+
/** Repo URL surfaced in the overlay footer. */
|
|
23
|
+
const GITHUB_URL = 'https://github.com/Morphicai/harness-fe';
|
|
19
24
|
const HOST_ID = '__harness_fe_overlay__';
|
|
20
25
|
const MAX_OUTER_HTML = 2048;
|
|
21
26
|
// Internal instrumentation attributes injected by our build plugin. Must be
|
|
@@ -242,6 +247,14 @@ export function installOverlay(client) {
|
|
|
242
247
|
let statusPollTimer;
|
|
243
248
|
/** Flattened PNG from the annotate step; null if user skipped. */
|
|
244
249
|
let pendingAttachment = null;
|
|
250
|
+
/** Set while the picker is collecting an element for a `requiresElement` plugin. */
|
|
251
|
+
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';
|
|
245
258
|
const setState = (next) => {
|
|
246
259
|
state = next;
|
|
247
260
|
infoCard.style.display = next === 'info' ? 'flex' : 'none';
|
|
@@ -314,9 +327,29 @@ export function installOverlay(client) {
|
|
|
314
327
|
return;
|
|
315
328
|
lockedEl = hoveredEl;
|
|
316
329
|
setHighlight(lockedEl);
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
330
|
+
// A plugin requested the element — hand it straight to its onClick and
|
|
331
|
+
// skip all other flows.
|
|
332
|
+
if (pluginAwaitingElement) {
|
|
333
|
+
const plugin = pluginAwaitingElement;
|
|
334
|
+
pluginAwaitingElement = null;
|
|
335
|
+
const el = lockedEl;
|
|
336
|
+
lockedEl = null;
|
|
337
|
+
setState('idle');
|
|
338
|
+
void invokePlugin(plugin, el);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
// Copy mode: build element info and copy to clipboard for agent use.
|
|
342
|
+
if (pickerPurpose === 'copy') {
|
|
343
|
+
const el = lockedEl;
|
|
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).
|
|
320
353
|
pendingAttachment = null;
|
|
321
354
|
const info = questionPanel.querySelector('[data-role=info]');
|
|
322
355
|
info.textContent = describeElement(lockedEl);
|
|
@@ -339,6 +372,8 @@ export function installOverlay(client) {
|
|
|
339
372
|
else if (state === 'picker' || state === 'question') {
|
|
340
373
|
lockedEl = null;
|
|
341
374
|
pendingAttachment = null;
|
|
375
|
+
pluginAwaitingElement = null;
|
|
376
|
+
pickerPurpose = 'copy';
|
|
342
377
|
setState('info');
|
|
343
378
|
}
|
|
344
379
|
else if (state === 'info') {
|
|
@@ -409,6 +444,31 @@ export function installOverlay(client) {
|
|
|
409
444
|
}, 1200);
|
|
410
445
|
}
|
|
411
446
|
};
|
|
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
|
+
};
|
|
412
472
|
const buildSnapshot = () => {
|
|
413
473
|
const lines = [];
|
|
414
474
|
lines.push(`### Harness-FE snapshot`);
|
|
@@ -425,6 +485,107 @@ export function installOverlay(client) {
|
|
|
425
485
|
lines.push(`- daemon: ${client.getConnectionState()}`);
|
|
426
486
|
return lines.join('\n') + '\n';
|
|
427
487
|
};
|
|
488
|
+
// ─── Plugin support ──────────────────────────────────────────────────
|
|
489
|
+
const dashboardUrl = client.mcpUrl
|
|
490
|
+
? deriveDashboardUrl({ mcpUrl: client.mcpUrl, sessionId: client.sessionId })
|
|
491
|
+
: undefined;
|
|
492
|
+
/** Brief transient toast anchored near the FAB. */
|
|
493
|
+
const showToast = (message, kind = 'ok') => {
|
|
494
|
+
const el = document.createElement('div');
|
|
495
|
+
el.className = 'hfe-toast';
|
|
496
|
+
el.dataset.kind = kind;
|
|
497
|
+
el.textContent = message;
|
|
498
|
+
root.appendChild(el);
|
|
499
|
+
requestAnimationFrame(() => { el.dataset.show = '1'; });
|
|
500
|
+
setTimeout(() => {
|
|
501
|
+
el.dataset.show = '';
|
|
502
|
+
setTimeout(() => el.remove(), 250);
|
|
503
|
+
}, 2400);
|
|
504
|
+
};
|
|
505
|
+
const buildPluginContext = (selectedEl) => {
|
|
506
|
+
const selectedElement = selectedEl
|
|
507
|
+
? {
|
|
508
|
+
el: selectedEl,
|
|
509
|
+
selector: {
|
|
510
|
+
comp: selectedEl.getAttribute('data-morphix-comp') ?? undefined,
|
|
511
|
+
loc: selectedEl.getAttribute('data-morphix-loc') ?? undefined,
|
|
512
|
+
css: buildCssPath(selectedEl),
|
|
513
|
+
},
|
|
514
|
+
outerHTML: truncate(stripInternalAttrs(selectedEl.outerHTML), MAX_OUTER_HTML),
|
|
515
|
+
rect: (() => {
|
|
516
|
+
const r = selectedEl.getBoundingClientRect();
|
|
517
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
518
|
+
})(),
|
|
519
|
+
}
|
|
520
|
+
: undefined;
|
|
521
|
+
return {
|
|
522
|
+
projectId: client.projectId,
|
|
523
|
+
displayName: client.displayName,
|
|
524
|
+
buildId: client.buildId,
|
|
525
|
+
parentProjectId: client.parentProjectId,
|
|
526
|
+
sessionId: client.sessionId,
|
|
527
|
+
tabId: client.tabId,
|
|
528
|
+
visitorId: client.visitorId,
|
|
529
|
+
userId: client.userId,
|
|
530
|
+
url: location.href,
|
|
531
|
+
connectionState: client.getConnectionState(),
|
|
532
|
+
dashboardUrl,
|
|
533
|
+
selectedElement,
|
|
534
|
+
snapshotMarkdown: buildSnapshot,
|
|
535
|
+
snapshot: () => collectPageLoadSnapshot(client.sessionId),
|
|
536
|
+
getLogs: (opts) => collectLogs(opts),
|
|
537
|
+
captureScreenshot: (el) => captureElementPng(el ?? selectedEl ?? document.body),
|
|
538
|
+
query: client.query ? client.query.bind(client) : undefined,
|
|
539
|
+
copyToClipboard: (text) => copyText(text),
|
|
540
|
+
toast: showToast,
|
|
541
|
+
};
|
|
542
|
+
};
|
|
543
|
+
const invokePlugin = async (plugin, selectedEl) => {
|
|
544
|
+
try {
|
|
545
|
+
await plugin.onClick(buildPluginContext(selectedEl));
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
549
|
+
showToast(`${plugin.label}: ${msg}`, 'error');
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
/** (Re)render the plugin button group in the info card. */
|
|
553
|
+
const renderPluginButtons = () => {
|
|
554
|
+
const slot = infoCard.querySelector('[data-role=plugin-actions]');
|
|
555
|
+
if (!slot)
|
|
556
|
+
return;
|
|
557
|
+
const list = getOverlayPlugins();
|
|
558
|
+
slot.innerHTML = '';
|
|
559
|
+
slot.style.display = list.length ? '' : 'none';
|
|
560
|
+
for (const plugin of list) {
|
|
561
|
+
const btn = document.createElement('button');
|
|
562
|
+
btn.className = 'secondary';
|
|
563
|
+
btn.type = 'button';
|
|
564
|
+
btn.dataset.pluginId = plugin.id;
|
|
565
|
+
btn.textContent = `${plugin.icon ? plugin.icon + ' ' : ''}${plugin.label}`;
|
|
566
|
+
btn.addEventListener('click', () => {
|
|
567
|
+
if (plugin.requiresElement) {
|
|
568
|
+
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
|
+
setState('picker');
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
setState('idle');
|
|
577
|
+
void invokePlugin(plugin);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
slot.appendChild(btn);
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
renderPluginButtons();
|
|
584
|
+
const unsubscribePlugins = subscribeOverlayPlugins(() => {
|
|
585
|
+
// Only the info card shows plugin buttons; re-render whenever the set changes.
|
|
586
|
+
renderPluginButtons();
|
|
587
|
+
});
|
|
588
|
+
void unsubscribePlugins; // overlay lives for the page lifetime; no teardown path
|
|
428
589
|
// ─── Reports rendering ───────────────────────────────────────────────
|
|
429
590
|
let editingTaskId = null;
|
|
430
591
|
let deleteConfirmId = null;
|
|
@@ -637,25 +798,23 @@ export function installOverlay(client) {
|
|
|
637
798
|
setState(state === 'idle' ? 'info' : 'idle');
|
|
638
799
|
});
|
|
639
800
|
infoCard.querySelector('[data-role=close]').addEventListener('click', () => setState('idle'));
|
|
640
|
-
infoCard.querySelector('[data-role=
|
|
801
|
+
infoCard.querySelector('[data-role=pick-element]').addEventListener('click', () => {
|
|
802
|
+
pickerPurpose = 'copy';
|
|
803
|
+
const label = pickerBar.querySelector('[data-role=picker-label]');
|
|
804
|
+
if (label)
|
|
805
|
+
label.textContent = '🔍 Click element to copy info';
|
|
641
806
|
setState('picker');
|
|
642
807
|
});
|
|
643
808
|
infoCard.querySelector('[data-role=copy-snapshot]').addEventListener('click', (ev) => {
|
|
644
809
|
const btn = ev.currentTarget;
|
|
645
810
|
void copyText(buildSnapshot(), btn);
|
|
646
811
|
});
|
|
647
|
-
infoCard.querySelector('[data-role=view-reports]').addEventListener('click', () => {
|
|
648
|
-
setState('reports');
|
|
649
|
-
});
|
|
650
812
|
// "Open dashboard" — derive the daemon's dashboard URL from mcpUrl and
|
|
651
813
|
// pop it in a new tab, deep-linked to this session. Show the button
|
|
652
814
|
// only when we actually know the daemon address (mcpUrl was supplied by
|
|
653
815
|
// the plugin / runtime config).
|
|
654
816
|
{
|
|
655
817
|
const dashboardBtn = infoCard.querySelector('[data-role=open-dashboard]');
|
|
656
|
-
const dashboardUrl = client.mcpUrl
|
|
657
|
-
? deriveDashboardUrl({ mcpUrl: client.mcpUrl, sessionId: client.sessionId })
|
|
658
|
-
: undefined;
|
|
659
818
|
if (dashboardUrl) {
|
|
660
819
|
dashboardBtn.style.display = '';
|
|
661
820
|
dashboardBtn.title = `Open ${dashboardUrl} in a new tab`;
|
|
@@ -671,6 +830,19 @@ export function installOverlay(client) {
|
|
|
671
830
|
});
|
|
672
831
|
}
|
|
673
832
|
}
|
|
833
|
+
// GitHub promo link — anchor navigates natively; this fallback covers
|
|
834
|
+
// sandboxed iframes / popup blockers by copying the URL instead.
|
|
835
|
+
infoCard.querySelector('[data-role=github]').addEventListener('click', (ev) => {
|
|
836
|
+
try {
|
|
837
|
+
const opened = window.open(GITHUB_URL, '_blank', 'noopener,noreferrer');
|
|
838
|
+
if (opened)
|
|
839
|
+
ev.preventDefault();
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
ev.preventDefault();
|
|
843
|
+
void copyText(GITHUB_URL, ev.currentTarget);
|
|
844
|
+
}
|
|
845
|
+
});
|
|
674
846
|
reportsCard.querySelector('[data-role=back]').addEventListener('click', () => setState('info'));
|
|
675
847
|
reportsCard.querySelector('[data-role=close]').addEventListener('click', () => setState('idle'));
|
|
676
848
|
reportsCard.querySelector('[data-role=refresh]').addEventListener('click', () => void refreshReports());
|
|
@@ -688,6 +860,8 @@ export function installOverlay(client) {
|
|
|
688
860
|
}
|
|
689
861
|
pickerBar.querySelector('[data-role=cancel]').addEventListener('click', () => {
|
|
690
862
|
lockedEl = null;
|
|
863
|
+
pluginAwaitingElement = null;
|
|
864
|
+
pickerPurpose = 'copy';
|
|
691
865
|
setState('info');
|
|
692
866
|
});
|
|
693
867
|
questionPanel.querySelector('[data-role=cancel]').addEventListener('click', () => {
|
|
@@ -1037,6 +1211,62 @@ export async function finalizeAnnotation() {
|
|
|
1037
1211
|
resetAnnotateStrokes();
|
|
1038
1212
|
return att;
|
|
1039
1213
|
}
|
|
1214
|
+
// ─── Plugin helpers (screenshot / logs) ───────────────────────────────────
|
|
1215
|
+
/**
|
|
1216
|
+
* Rasterize an element to a PNG TaskAttachment via snapdom. Returns null on
|
|
1217
|
+
* failure (cross-origin, test env, …) so plugins can degrade gracefully.
|
|
1218
|
+
* Exported for testing.
|
|
1219
|
+
*/
|
|
1220
|
+
export async function captureElementPng(el) {
|
|
1221
|
+
try {
|
|
1222
|
+
const result = await snapdom(el, { fast: true });
|
|
1223
|
+
const canvas = await result.toCanvas();
|
|
1224
|
+
const dataUrl = canvas.toDataURL('image/png', 0.85);
|
|
1225
|
+
const base64 = dataUrl.replace(/^data:image\/png;base64,/, '');
|
|
1226
|
+
return {
|
|
1227
|
+
id: `att_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
|
1228
|
+
kind: 'screenshot',
|
|
1229
|
+
data: base64,
|
|
1230
|
+
width: canvas.width || 1,
|
|
1231
|
+
height: canvas.height || 1,
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
catch {
|
|
1235
|
+
return null;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
const SENSITIVE_HEADER_RE = /^(authorization|cookie|set-cookie|proxy-authorization)$/i;
|
|
1239
|
+
/** Strip bodies + auth/cookie headers from a network entry. */
|
|
1240
|
+
function redactNetworkEntry(e) {
|
|
1241
|
+
const out = { ...e };
|
|
1242
|
+
delete out.requestBody;
|
|
1243
|
+
delete out.responseBody;
|
|
1244
|
+
delete out.requestBodyTruncated;
|
|
1245
|
+
delete out.responseBodyTruncated;
|
|
1246
|
+
for (const key of ['requestHeaders', 'responseHeaders']) {
|
|
1247
|
+
const h = out[key];
|
|
1248
|
+
if (h) {
|
|
1249
|
+
const safe = {};
|
|
1250
|
+
for (const [k, v] of Object.entries(h)) {
|
|
1251
|
+
if (!SENSITIVE_HEADER_RE.test(k))
|
|
1252
|
+
safe[k] = v;
|
|
1253
|
+
}
|
|
1254
|
+
out[key] = safe;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return out;
|
|
1258
|
+
}
|
|
1259
|
+
/** Read recent buffered logs for the plugin context. Redacts network by default. */
|
|
1260
|
+
export function collectLogs(opts = {}) {
|
|
1261
|
+
const store = getCaptureStore();
|
|
1262
|
+
const redact = opts.redact !== false;
|
|
1263
|
+
const network = store.network.tail(opts.network ?? 0);
|
|
1264
|
+
return {
|
|
1265
|
+
console: store.console.tail(opts.console ?? 0),
|
|
1266
|
+
errors: store.errors.tail(opts.errors ?? 0),
|
|
1267
|
+
network: redact ? network.map(redactNetworkEntry) : network,
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1040
1270
|
// ─── DOM builders ────────────────────────────────────────────────────────
|
|
1041
1271
|
function buildStyle() {
|
|
1042
1272
|
const style = document.createElement('style');
|
|
@@ -1263,6 +1493,47 @@ function buildStyle() {
|
|
|
1263
1493
|
border-color: rgba(52, 211, 153, 0.3);
|
|
1264
1494
|
}
|
|
1265
1495
|
|
|
1496
|
+
.info-card .plugin-actions {
|
|
1497
|
+
display: flex;
|
|
1498
|
+
flex-direction: column;
|
|
1499
|
+
gap: 8px;
|
|
1500
|
+
margin-top: 8px;
|
|
1501
|
+
padding-top: 8px;
|
|
1502
|
+
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
1503
|
+
}
|
|
1504
|
+
.info-card .promo {
|
|
1505
|
+
margin-top: 8px;
|
|
1506
|
+
text-align: center;
|
|
1507
|
+
}
|
|
1508
|
+
.info-card .promo-link {
|
|
1509
|
+
color: #a1a1aa;
|
|
1510
|
+
font-size: 11px;
|
|
1511
|
+
text-decoration: none;
|
|
1512
|
+
opacity: 0.7;
|
|
1513
|
+
}
|
|
1514
|
+
.info-card .promo-link:hover { color: #f4f4f5; opacity: 1; }
|
|
1515
|
+
|
|
1516
|
+
.hfe-toast {
|
|
1517
|
+
position: fixed;
|
|
1518
|
+
bottom: 64px;
|
|
1519
|
+
left: 50%;
|
|
1520
|
+
transform: translate(-50%, 8px);
|
|
1521
|
+
max-width: 320px;
|
|
1522
|
+
padding: 8px 14px;
|
|
1523
|
+
background: #111827;
|
|
1524
|
+
color: #f4f4f5;
|
|
1525
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
1526
|
+
border-radius: 10px;
|
|
1527
|
+
font: 500 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1528
|
+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
|
|
1529
|
+
opacity: 0;
|
|
1530
|
+
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
1531
|
+
z-index: 2147483647;
|
|
1532
|
+
pointer-events: none;
|
|
1533
|
+
}
|
|
1534
|
+
.hfe-toast[data-show="1"] { opacity: 1; transform: translate(-50%, 0); }
|
|
1535
|
+
.hfe-toast[data-kind="error"] { border-color: rgba(248, 113, 113, 0.4); color: #fca5a5; }
|
|
1536
|
+
|
|
1266
1537
|
.picker-bar {
|
|
1267
1538
|
position: fixed;
|
|
1268
1539
|
top: 12px;
|
|
@@ -1715,18 +1986,21 @@ function buildInfoCard() {
|
|
|
1715
1986
|
<div class="row"><span class="key">url</span><span class="pill url" data-role="url"></span></div>
|
|
1716
1987
|
</div>
|
|
1717
1988
|
<div class="actions">
|
|
1718
|
-
<button class="primary" data-role="
|
|
1719
|
-
<span class="icon"
|
|
1720
|
-
<span class="label">
|
|
1721
|
-
<span class="hint">
|
|
1989
|
+
<button class="primary" data-role="pick-element" type="button">
|
|
1990
|
+
<span class="icon">🔍</span>
|
|
1991
|
+
<span class="label">Copy element info</span>
|
|
1992
|
+
<span class="hint">pick element →</span>
|
|
1722
1993
|
</button>
|
|
1723
1994
|
<button class="secondary" data-role="open-dashboard" type="button" style="display:none">
|
|
1724
1995
|
<span class="icon">↗</span>
|
|
1725
1996
|
<span>Open dashboard</span>
|
|
1726
1997
|
</button>
|
|
1727
|
-
<button class="secondary" data-role="view-reports" type="button">📁 My reports</button>
|
|
1728
1998
|
<button class="secondary" data-role="copy-snapshot" type="button">📋 Copy snapshot</button>
|
|
1729
1999
|
</div>
|
|
2000
|
+
<div class="plugin-actions" data-role="plugin-actions" style="display:none"></div>
|
|
2001
|
+
<div class="promo">
|
|
2002
|
+
<a class="promo-link" data-role="github" href="${GITHUB_URL}" target="_blank" rel="noopener noreferrer">⭐ Harness-FE on GitHub</a>
|
|
2003
|
+
</div>
|
|
1730
2004
|
`;
|
|
1731
2005
|
return card;
|
|
1732
2006
|
}
|
|
@@ -1751,7 +2025,7 @@ function buildPickerBar() {
|
|
|
1751
2025
|
const bar = document.createElement('div');
|
|
1752
2026
|
bar.className = 'picker-bar';
|
|
1753
2027
|
bar.innerHTML = `
|
|
1754
|
-
<span class="label"
|
|
2028
|
+
<span class="label" data-role="picker-label">🔍 Click element to copy info</span>
|
|
1755
2029
|
<span class="hint">esc to cancel</span>
|
|
1756
2030
|
<button data-role="cancel" type="button">Cancel</button>
|
|
1757
2031
|
`;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlay plugin registry.
|
|
3
|
+
*
|
|
4
|
+
* Lets developers extend the in-page "H" overlay with their own action buttons
|
|
5
|
+
* — e.g. "send this scene to Slack" / "create a Jira issue" — without forking
|
|
6
|
+
* `@harness-fe/runtime`. A plugin is just a button label + an `onClick(ctx)`
|
|
7
|
+
* handler; the handler receives an {@link OverlayPluginContext} with on-demand,
|
|
8
|
+
* redaction-aware access to the current scene, logs, screenshot, and selected
|
|
9
|
+
* element.
|
|
10
|
+
*
|
|
11
|
+
* Registration order doesn't matter: plugins registered before the overlay
|
|
12
|
+
* mounts are buffered here, and the overlay subscribes so anything registered
|
|
13
|
+
* later renders immediately. See `index.ts` for the `window.HarnessFE` global
|
|
14
|
+
* and the `window.__HARNESS_FE_PLUGINS__` pre-boot queue.
|
|
15
|
+
*/
|
|
16
|
+
import type { PageLoadPayload, ConsoleEntry, NetworkEntry, ErrorEntry, TaskAttachment } from '@harness-fe/protocol';
|
|
17
|
+
/** Selector descriptor for a picked element (mirrors TaskSubmitPayload.selector). */
|
|
18
|
+
export interface OverlayPluginSelector {
|
|
19
|
+
/** Source location `file:line:col` from the build transform, if present. */
|
|
20
|
+
loc?: string;
|
|
21
|
+
/** Component display name from the build transform, if present. */
|
|
22
|
+
comp?: string;
|
|
23
|
+
/** Best-effort CSS path. */
|
|
24
|
+
css: string;
|
|
25
|
+
}
|
|
26
|
+
/** The element the user picked (only present for `requiresElement` plugins). */
|
|
27
|
+
export interface OverlayPluginSelectedElement {
|
|
28
|
+
/** Live DOM node. */
|
|
29
|
+
el: Element;
|
|
30
|
+
selector: OverlayPluginSelector;
|
|
31
|
+
/** outerHTML with internal instrumentation attrs stripped + truncated. */
|
|
32
|
+
outerHTML: string;
|
|
33
|
+
rect: {
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export interface OverlayPluginLogs {
|
|
41
|
+
console: ConsoleEntry[];
|
|
42
|
+
network: NetworkEntry[];
|
|
43
|
+
errors: ErrorEntry[];
|
|
44
|
+
}
|
|
45
|
+
export interface OverlayPluginGetLogsOptions {
|
|
46
|
+
/** Max console entries (newest last). Default 0 — pass a count to include. */
|
|
47
|
+
console?: number;
|
|
48
|
+
/** Max network entries. Default 0. */
|
|
49
|
+
network?: number;
|
|
50
|
+
/** Max error entries. Default 0. */
|
|
51
|
+
errors?: number;
|
|
52
|
+
/**
|
|
53
|
+
* When `true` (the default), network entries are reduced to metadata:
|
|
54
|
+
* request/response bodies are dropped and `authorization` / `cookie`
|
|
55
|
+
* headers are stripped. Set `false` to receive raw entries — only do this
|
|
56
|
+
* when you control the destination, as bodies may contain secrets.
|
|
57
|
+
*/
|
|
58
|
+
redact?: boolean;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Per-invocation context handed to a plugin's `onClick`. Getters are lazy: the
|
|
62
|
+
* snapshot / logs / screenshot are only computed when you call them, so a
|
|
63
|
+
* plugin pays for exactly what it uses.
|
|
64
|
+
*/
|
|
65
|
+
export interface OverlayPluginContext {
|
|
66
|
+
readonly projectId: string;
|
|
67
|
+
readonly displayName?: string;
|
|
68
|
+
readonly buildId?: string;
|
|
69
|
+
readonly parentProjectId?: string;
|
|
70
|
+
readonly sessionId: string;
|
|
71
|
+
readonly tabId: string;
|
|
72
|
+
readonly visitorId?: string;
|
|
73
|
+
readonly userId?: string;
|
|
74
|
+
/** `location.href` at click time. */
|
|
75
|
+
readonly url: string;
|
|
76
|
+
readonly connectionState: 'connecting' | 'open' | 'closed';
|
|
77
|
+
/** Deep link to this session in the daemon dashboard, if the address is known. */
|
|
78
|
+
readonly dashboardUrl?: string;
|
|
79
|
+
/** Present only for plugins declared with `requiresElement: true`. */
|
|
80
|
+
readonly selectedElement?: OverlayPluginSelectedElement;
|
|
81
|
+
/** Shareable markdown summary (project / build / session / tab / url / time / conn). */
|
|
82
|
+
snapshotMarkdown(): string;
|
|
83
|
+
/** Structured page-load snapshot: page / viewport / storage / performance. */
|
|
84
|
+
snapshot(): PageLoadPayload;
|
|
85
|
+
/** Recent buffered logs. Redacted by default — see {@link OverlayPluginGetLogsOptions}. */
|
|
86
|
+
getLogs(opts?: OverlayPluginGetLogsOptions): OverlayPluginLogs;
|
|
87
|
+
/** Rasterize an element (default: the picked element, else `document.body`) to a PNG attachment. */
|
|
88
|
+
captureScreenshot(el?: Element): Promise<TaskAttachment | null>;
|
|
89
|
+
/** Daemon RPC over the whitelisted channel (e.g. `tasks.mine`). Undefined if unavailable. */
|
|
90
|
+
query?: <TResult = unknown>(method: string, args?: unknown) => Promise<TResult>;
|
|
91
|
+
/** Copy text to the clipboard (best-effort). */
|
|
92
|
+
copyToClipboard(text: string): Promise<void>;
|
|
93
|
+
/** Show a brief feedback toast on the overlay. */
|
|
94
|
+
toast(message: string, kind?: 'ok' | 'error'): void;
|
|
95
|
+
}
|
|
96
|
+
export interface OverlayPlugin {
|
|
97
|
+
/** Unique id. Re-registering the same id replaces the previous plugin. */
|
|
98
|
+
id: string;
|
|
99
|
+
/** Button label shown in the info card. */
|
|
100
|
+
label: string;
|
|
101
|
+
/** Optional leading icon (emoji or single char). */
|
|
102
|
+
icon?: string;
|
|
103
|
+
/**
|
|
104
|
+
* When `true`, clicking the button first enters element-picker mode; the
|
|
105
|
+
* picked element is then available as `ctx.selectedElement` in `onClick`.
|
|
106
|
+
*/
|
|
107
|
+
requiresElement?: boolean;
|
|
108
|
+
/** Invoked when the button is clicked. May be async; rejections are toasted. */
|
|
109
|
+
onClick(ctx: OverlayPluginContext): void | Promise<void>;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Register an overlay plugin. Returns an unregister function (handy for HMR
|
|
113
|
+
* cleanup). Registering an id that already exists replaces it.
|
|
114
|
+
*/
|
|
115
|
+
export declare function registerOverlayPlugin(plugin: OverlayPlugin): () => void;
|
|
116
|
+
/** Current plugins, in registration order. */
|
|
117
|
+
export declare function getOverlayPlugins(): OverlayPlugin[];
|
|
118
|
+
/** Subscribe to registry changes (add / replace / remove). Returns an unsubscribe fn. */
|
|
119
|
+
export declare function subscribeOverlayPlugins(fn: () => void): () => void;
|
|
120
|
+
/**
|
|
121
|
+
* Drain a pre-boot queue of plugins. Developers whose script may run before the
|
|
122
|
+
* runtime loads can push plain plugin objects onto
|
|
123
|
+
* `window.__HARNESS_FE_PLUGINS__`; `index.ts` calls this once on boot.
|
|
124
|
+
*/
|
|
125
|
+
export declare function drainPluginQueue(queue: unknown): void;
|
|
126
|
+
/** Test-only: clear all plugins and listeners. */
|
|
127
|
+
export declare function __resetOverlayPlugins(): void;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlay plugin registry.
|
|
3
|
+
*
|
|
4
|
+
* Lets developers extend the in-page "H" overlay with their own action buttons
|
|
5
|
+
* — e.g. "send this scene to Slack" / "create a Jira issue" — without forking
|
|
6
|
+
* `@harness-fe/runtime`. A plugin is just a button label + an `onClick(ctx)`
|
|
7
|
+
* handler; the handler receives an {@link OverlayPluginContext} with on-demand,
|
|
8
|
+
* redaction-aware access to the current scene, logs, screenshot, and selected
|
|
9
|
+
* element.
|
|
10
|
+
*
|
|
11
|
+
* Registration order doesn't matter: plugins registered before the overlay
|
|
12
|
+
* mounts are buffered here, and the overlay subscribes so anything registered
|
|
13
|
+
* later renders immediately. See `index.ts` for the `window.HarnessFE` global
|
|
14
|
+
* and the `window.__HARNESS_FE_PLUGINS__` pre-boot queue.
|
|
15
|
+
*/
|
|
16
|
+
const plugins = new Map();
|
|
17
|
+
const listeners = new Set();
|
|
18
|
+
function notify() {
|
|
19
|
+
for (const fn of listeners) {
|
|
20
|
+
try {
|
|
21
|
+
fn();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
/* a bad listener must not break registration */
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Register an overlay plugin. Returns an unregister function (handy for HMR
|
|
30
|
+
* cleanup). Registering an id that already exists replaces it.
|
|
31
|
+
*/
|
|
32
|
+
export function registerOverlayPlugin(plugin) {
|
|
33
|
+
if (!plugin || typeof plugin.id !== 'string' || plugin.id === '') {
|
|
34
|
+
throw new Error('registerOverlayPlugin: plugin.id is required');
|
|
35
|
+
}
|
|
36
|
+
if (typeof plugin.onClick !== 'function') {
|
|
37
|
+
throw new Error(`registerOverlayPlugin: plugin "${plugin.id}" needs an onClick handler`);
|
|
38
|
+
}
|
|
39
|
+
plugins.set(plugin.id, plugin);
|
|
40
|
+
notify();
|
|
41
|
+
return () => {
|
|
42
|
+
if (plugins.get(plugin.id) === plugin) {
|
|
43
|
+
plugins.delete(plugin.id);
|
|
44
|
+
notify();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Current plugins, in registration order. */
|
|
49
|
+
export function getOverlayPlugins() {
|
|
50
|
+
return [...plugins.values()];
|
|
51
|
+
}
|
|
52
|
+
/** Subscribe to registry changes (add / replace / remove). Returns an unsubscribe fn. */
|
|
53
|
+
export function subscribeOverlayPlugins(fn) {
|
|
54
|
+
listeners.add(fn);
|
|
55
|
+
return () => {
|
|
56
|
+
listeners.delete(fn);
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Drain a pre-boot queue of plugins. Developers whose script may run before the
|
|
61
|
+
* runtime loads can push plain plugin objects onto
|
|
62
|
+
* `window.__HARNESS_FE_PLUGINS__`; `index.ts` calls this once on boot.
|
|
63
|
+
*/
|
|
64
|
+
export function drainPluginQueue(queue) {
|
|
65
|
+
if (!Array.isArray(queue))
|
|
66
|
+
return;
|
|
67
|
+
for (const p of queue) {
|
|
68
|
+
try {
|
|
69
|
+
registerOverlayPlugin(p);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
/* skip malformed queued entries */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Test-only: clear all plugins and listeners. */
|
|
77
|
+
export function __resetOverlayPlugins() {
|
|
78
|
+
plugins.clear();
|
|
79
|
+
listeners.clear();
|
|
80
|
+
}
|