@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/src/overlay.ts
CHANGED
|
@@ -14,9 +14,27 @@
|
|
|
14
14
|
* or out. State machine: idle → info → (picker → question) → flash → idle.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
EVENT_NAME,
|
|
19
|
+
type TaskSubmitPayload,
|
|
20
|
+
type TaskAttachment,
|
|
21
|
+
type NetworkEntry,
|
|
22
|
+
} from '@harness-fe/protocol';
|
|
18
23
|
import { snapdom } from '@zumer/snapdom';
|
|
19
24
|
import { deriveDashboardUrl } from './dashboardUrl.js';
|
|
25
|
+
import { getCaptureStore } from './capture.js';
|
|
26
|
+
import { collectPageLoadSnapshot } from './snapshot.js';
|
|
27
|
+
import {
|
|
28
|
+
getOverlayPlugins,
|
|
29
|
+
subscribeOverlayPlugins,
|
|
30
|
+
type OverlayPlugin,
|
|
31
|
+
type OverlayPluginContext,
|
|
32
|
+
type OverlayPluginLogs,
|
|
33
|
+
type OverlayPluginGetLogsOptions,
|
|
34
|
+
} from './pluginRegistry.js';
|
|
35
|
+
|
|
36
|
+
/** Repo URL surfaced in the overlay footer. */
|
|
37
|
+
const GITHUB_URL = 'https://github.com/Morphicai/harness-fe';
|
|
20
38
|
|
|
21
39
|
const HOST_ID = '__harness_fe_overlay__';
|
|
22
40
|
const MAX_OUTER_HTML = 2048;
|
|
@@ -289,6 +307,14 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
289
307
|
let statusPollTimer: number | undefined;
|
|
290
308
|
/** Flattened PNG from the annotate step; null if user skipped. */
|
|
291
309
|
let pendingAttachment: TaskAttachment | null = null;
|
|
310
|
+
/** Set while the picker is collecting an element for a `requiresElement` plugin. */
|
|
311
|
+
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';
|
|
292
318
|
|
|
293
319
|
const setState = (next: State) => {
|
|
294
320
|
state = next;
|
|
@@ -360,9 +386,29 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
360
386
|
if (!hoveredEl) return;
|
|
361
387
|
lockedEl = hoveredEl;
|
|
362
388
|
setHighlight(lockedEl);
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
389
|
+
// A plugin requested the element — hand it straight to its onClick and
|
|
390
|
+
// skip all other flows.
|
|
391
|
+
if (pluginAwaitingElement) {
|
|
392
|
+
const plugin = pluginAwaitingElement;
|
|
393
|
+
pluginAwaitingElement = null;
|
|
394
|
+
const el = lockedEl;
|
|
395
|
+
lockedEl = null;
|
|
396
|
+
setState('idle');
|
|
397
|
+
void invokePlugin(plugin, el);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
// Copy mode: build element info and copy to clipboard for agent use.
|
|
401
|
+
if (pickerPurpose === 'copy') {
|
|
402
|
+
const el = lockedEl;
|
|
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).
|
|
366
412
|
pendingAttachment = null;
|
|
367
413
|
const info = questionPanel.querySelector<HTMLElement>('[data-role=info]')!;
|
|
368
414
|
info.textContent = describeElement(lockedEl);
|
|
@@ -385,6 +431,8 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
385
431
|
} else if (state === 'picker' || state === 'question') {
|
|
386
432
|
lockedEl = null;
|
|
387
433
|
pendingAttachment = null;
|
|
434
|
+
pluginAwaitingElement = null;
|
|
435
|
+
pickerPurpose = 'copy';
|
|
388
436
|
setState('info');
|
|
389
437
|
} else if (state === 'info') {
|
|
390
438
|
setState('idle');
|
|
@@ -454,6 +502,30 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
454
502
|
}
|
|
455
503
|
};
|
|
456
504
|
|
|
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
|
+
|
|
457
529
|
const buildSnapshot = (): string => {
|
|
458
530
|
const lines: string[] = [];
|
|
459
531
|
lines.push(`### Harness-FE snapshot`);
|
|
@@ -469,6 +541,109 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
469
541
|
return lines.join('\n') + '\n';
|
|
470
542
|
};
|
|
471
543
|
|
|
544
|
+
// ─── Plugin support ──────────────────────────────────────────────────
|
|
545
|
+
const dashboardUrl = client.mcpUrl
|
|
546
|
+
? deriveDashboardUrl({ mcpUrl: client.mcpUrl, sessionId: client.sessionId })
|
|
547
|
+
: undefined;
|
|
548
|
+
|
|
549
|
+
/** Brief transient toast anchored near the FAB. */
|
|
550
|
+
const showToast = (message: string, kind: 'ok' | 'error' = 'ok'): void => {
|
|
551
|
+
const el = document.createElement('div');
|
|
552
|
+
el.className = 'hfe-toast';
|
|
553
|
+
el.dataset.kind = kind;
|
|
554
|
+
el.textContent = message;
|
|
555
|
+
root.appendChild(el);
|
|
556
|
+
requestAnimationFrame(() => { el.dataset.show = '1'; });
|
|
557
|
+
setTimeout(() => {
|
|
558
|
+
el.dataset.show = '';
|
|
559
|
+
setTimeout(() => el.remove(), 250);
|
|
560
|
+
}, 2400);
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const buildPluginContext = (selectedEl?: Element): OverlayPluginContext => {
|
|
564
|
+
const selectedElement = selectedEl
|
|
565
|
+
? {
|
|
566
|
+
el: selectedEl,
|
|
567
|
+
selector: {
|
|
568
|
+
comp: selectedEl.getAttribute('data-morphix-comp') ?? undefined,
|
|
569
|
+
loc: selectedEl.getAttribute('data-morphix-loc') ?? undefined,
|
|
570
|
+
css: buildCssPath(selectedEl),
|
|
571
|
+
},
|
|
572
|
+
outerHTML: truncate(stripInternalAttrs(selectedEl.outerHTML), MAX_OUTER_HTML),
|
|
573
|
+
rect: (() => {
|
|
574
|
+
const r = selectedEl.getBoundingClientRect();
|
|
575
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
576
|
+
})(),
|
|
577
|
+
}
|
|
578
|
+
: undefined;
|
|
579
|
+
return {
|
|
580
|
+
projectId: client.projectId,
|
|
581
|
+
displayName: client.displayName,
|
|
582
|
+
buildId: client.buildId,
|
|
583
|
+
parentProjectId: client.parentProjectId,
|
|
584
|
+
sessionId: client.sessionId,
|
|
585
|
+
tabId: client.tabId,
|
|
586
|
+
visitorId: client.visitorId,
|
|
587
|
+
userId: client.userId,
|
|
588
|
+
url: location.href,
|
|
589
|
+
connectionState: client.getConnectionState(),
|
|
590
|
+
dashboardUrl,
|
|
591
|
+
selectedElement,
|
|
592
|
+
snapshotMarkdown: buildSnapshot,
|
|
593
|
+
snapshot: () => collectPageLoadSnapshot(client.sessionId),
|
|
594
|
+
getLogs: (opts) => collectLogs(opts),
|
|
595
|
+
captureScreenshot: (el) => captureElementPng(el ?? selectedEl ?? document.body),
|
|
596
|
+
query: client.query ? client.query.bind(client) : undefined,
|
|
597
|
+
copyToClipboard: (text) => copyText(text),
|
|
598
|
+
toast: showToast,
|
|
599
|
+
};
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
const invokePlugin = async (plugin: OverlayPlugin, selectedEl?: Element): Promise<void> => {
|
|
603
|
+
try {
|
|
604
|
+
await plugin.onClick(buildPluginContext(selectedEl));
|
|
605
|
+
} catch (err) {
|
|
606
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
607
|
+
showToast(`${plugin.label}: ${msg}`, 'error');
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
/** (Re)render the plugin button group in the info card. */
|
|
612
|
+
const renderPluginButtons = (): void => {
|
|
613
|
+
const slot = infoCard.querySelector<HTMLElement>('[data-role=plugin-actions]');
|
|
614
|
+
if (!slot) return;
|
|
615
|
+
const list = getOverlayPlugins();
|
|
616
|
+
slot.innerHTML = '';
|
|
617
|
+
slot.style.display = list.length ? '' : 'none';
|
|
618
|
+
for (const plugin of list) {
|
|
619
|
+
const btn = document.createElement('button');
|
|
620
|
+
btn.className = 'secondary';
|
|
621
|
+
btn.type = 'button';
|
|
622
|
+
btn.dataset.pluginId = plugin.id;
|
|
623
|
+
btn.textContent = `${plugin.icon ? plugin.icon + ' ' : ''}${plugin.label}`;
|
|
624
|
+
btn.addEventListener('click', () => {
|
|
625
|
+
if (plugin.requiresElement) {
|
|
626
|
+
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
|
+
setState('picker');
|
|
631
|
+
} else {
|
|
632
|
+
setState('idle');
|
|
633
|
+
void invokePlugin(plugin);
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
slot.appendChild(btn);
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
renderPluginButtons();
|
|
641
|
+
const unsubscribePlugins = subscribeOverlayPlugins(() => {
|
|
642
|
+
// Only the info card shows plugin buttons; re-render whenever the set changes.
|
|
643
|
+
renderPluginButtons();
|
|
644
|
+
});
|
|
645
|
+
void unsubscribePlugins; // overlay lives for the page lifetime; no teardown path
|
|
646
|
+
|
|
472
647
|
// ─── Reports rendering ───────────────────────────────────────────────
|
|
473
648
|
let editingTaskId: string | null = null;
|
|
474
649
|
let deleteConfirmId: string | null = null;
|
|
@@ -671,7 +846,10 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
671
846
|
|
|
672
847
|
infoCard.querySelector('[data-role=close]')!.addEventListener('click', () => setState('idle'));
|
|
673
848
|
|
|
674
|
-
infoCard.querySelector('[data-role=
|
|
849
|
+
infoCard.querySelector('[data-role=pick-element]')!.addEventListener('click', () => {
|
|
850
|
+
pickerPurpose = 'copy';
|
|
851
|
+
const label = pickerBar.querySelector<HTMLElement>('[data-role=picker-label]');
|
|
852
|
+
if (label) label.textContent = '🔍 Click element to copy info';
|
|
675
853
|
setState('picker');
|
|
676
854
|
});
|
|
677
855
|
|
|
@@ -680,19 +858,12 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
680
858
|
void copyText(buildSnapshot(), btn);
|
|
681
859
|
});
|
|
682
860
|
|
|
683
|
-
infoCard.querySelector('[data-role=view-reports]')!.addEventListener('click', () => {
|
|
684
|
-
setState('reports');
|
|
685
|
-
});
|
|
686
|
-
|
|
687
861
|
// "Open dashboard" — derive the daemon's dashboard URL from mcpUrl and
|
|
688
862
|
// pop it in a new tab, deep-linked to this session. Show the button
|
|
689
863
|
// only when we actually know the daemon address (mcpUrl was supplied by
|
|
690
864
|
// the plugin / runtime config).
|
|
691
865
|
{
|
|
692
866
|
const dashboardBtn = infoCard.querySelector<HTMLButtonElement>('[data-role=open-dashboard]')!;
|
|
693
|
-
const dashboardUrl = client.mcpUrl
|
|
694
|
-
? deriveDashboardUrl({ mcpUrl: client.mcpUrl, sessionId: client.sessionId })
|
|
695
|
-
: undefined;
|
|
696
867
|
if (dashboardUrl) {
|
|
697
868
|
dashboardBtn.style.display = '';
|
|
698
869
|
dashboardBtn.title = `Open ${dashboardUrl} in a new tab`;
|
|
@@ -708,6 +879,18 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
708
879
|
}
|
|
709
880
|
}
|
|
710
881
|
|
|
882
|
+
// GitHub promo link — anchor navigates natively; this fallback covers
|
|
883
|
+
// sandboxed iframes / popup blockers by copying the URL instead.
|
|
884
|
+
infoCard.querySelector<HTMLAnchorElement>('[data-role=github]')!.addEventListener('click', (ev) => {
|
|
885
|
+
try {
|
|
886
|
+
const opened = window.open(GITHUB_URL, '_blank', 'noopener,noreferrer');
|
|
887
|
+
if (opened) ev.preventDefault();
|
|
888
|
+
} catch {
|
|
889
|
+
ev.preventDefault();
|
|
890
|
+
void copyText(GITHUB_URL, ev.currentTarget as HTMLElement);
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
|
|
711
894
|
reportsCard.querySelector('[data-role=back]')!.addEventListener('click', () => setState('info'));
|
|
712
895
|
reportsCard.querySelector('[data-role=close]')!.addEventListener('click', () => setState('idle'));
|
|
713
896
|
reportsCard.querySelector('[data-role=refresh]')!.addEventListener('click', () => void refreshReports());
|
|
@@ -727,6 +910,8 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
727
910
|
|
|
728
911
|
pickerBar.querySelector('[data-role=cancel]')!.addEventListener('click', () => {
|
|
729
912
|
lockedEl = null;
|
|
913
|
+
pluginAwaitingElement = null;
|
|
914
|
+
pickerPurpose = 'copy';
|
|
730
915
|
setState('info');
|
|
731
916
|
});
|
|
732
917
|
|
|
@@ -1148,6 +1333,65 @@ export async function finalizeAnnotation(): Promise<TaskAttachment | null> {
|
|
|
1148
1333
|
return att;
|
|
1149
1334
|
}
|
|
1150
1335
|
|
|
1336
|
+
// ─── Plugin helpers (screenshot / logs) ───────────────────────────────────
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Rasterize an element to a PNG TaskAttachment via snapdom. Returns null on
|
|
1340
|
+
* failure (cross-origin, test env, …) so plugins can degrade gracefully.
|
|
1341
|
+
* Exported for testing.
|
|
1342
|
+
*/
|
|
1343
|
+
export async function captureElementPng(el: Element): Promise<TaskAttachment | null> {
|
|
1344
|
+
try {
|
|
1345
|
+
const result = await snapdom(el as HTMLElement, { fast: true });
|
|
1346
|
+
const canvas = await result.toCanvas();
|
|
1347
|
+
const dataUrl = canvas.toDataURL('image/png', 0.85);
|
|
1348
|
+
const base64 = dataUrl.replace(/^data:image\/png;base64,/, '');
|
|
1349
|
+
return {
|
|
1350
|
+
id: `att_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
|
1351
|
+
kind: 'screenshot',
|
|
1352
|
+
data: base64,
|
|
1353
|
+
width: canvas.width || 1,
|
|
1354
|
+
height: canvas.height || 1,
|
|
1355
|
+
};
|
|
1356
|
+
} catch {
|
|
1357
|
+
return null;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const SENSITIVE_HEADER_RE = /^(authorization|cookie|set-cookie|proxy-authorization)$/i;
|
|
1362
|
+
|
|
1363
|
+
/** Strip bodies + auth/cookie headers from a network entry. */
|
|
1364
|
+
function redactNetworkEntry(e: NetworkEntry): NetworkEntry {
|
|
1365
|
+
const out: NetworkEntry = { ...e };
|
|
1366
|
+
delete out.requestBody;
|
|
1367
|
+
delete out.responseBody;
|
|
1368
|
+
delete out.requestBodyTruncated;
|
|
1369
|
+
delete out.responseBodyTruncated;
|
|
1370
|
+
for (const key of ['requestHeaders', 'responseHeaders'] as const) {
|
|
1371
|
+
const h = out[key];
|
|
1372
|
+
if (h) {
|
|
1373
|
+
const safe: Record<string, string> = {};
|
|
1374
|
+
for (const [k, v] of Object.entries(h)) {
|
|
1375
|
+
if (!SENSITIVE_HEADER_RE.test(k)) safe[k] = v;
|
|
1376
|
+
}
|
|
1377
|
+
out[key] = safe;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
return out;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/** Read recent buffered logs for the plugin context. Redacts network by default. */
|
|
1384
|
+
export function collectLogs(opts: OverlayPluginGetLogsOptions = {}): OverlayPluginLogs {
|
|
1385
|
+
const store = getCaptureStore();
|
|
1386
|
+
const redact = opts.redact !== false;
|
|
1387
|
+
const network = store.network.tail(opts.network ?? 0);
|
|
1388
|
+
return {
|
|
1389
|
+
console: store.console.tail(opts.console ?? 0),
|
|
1390
|
+
errors: store.errors.tail(opts.errors ?? 0),
|
|
1391
|
+
network: redact ? network.map(redactNetworkEntry) : network,
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1151
1395
|
// ─── DOM builders ────────────────────────────────────────────────────────
|
|
1152
1396
|
|
|
1153
1397
|
function buildStyle(): HTMLStyleElement {
|
|
@@ -1375,6 +1619,47 @@ function buildStyle(): HTMLStyleElement {
|
|
|
1375
1619
|
border-color: rgba(52, 211, 153, 0.3);
|
|
1376
1620
|
}
|
|
1377
1621
|
|
|
1622
|
+
.info-card .plugin-actions {
|
|
1623
|
+
display: flex;
|
|
1624
|
+
flex-direction: column;
|
|
1625
|
+
gap: 8px;
|
|
1626
|
+
margin-top: 8px;
|
|
1627
|
+
padding-top: 8px;
|
|
1628
|
+
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
1629
|
+
}
|
|
1630
|
+
.info-card .promo {
|
|
1631
|
+
margin-top: 8px;
|
|
1632
|
+
text-align: center;
|
|
1633
|
+
}
|
|
1634
|
+
.info-card .promo-link {
|
|
1635
|
+
color: #a1a1aa;
|
|
1636
|
+
font-size: 11px;
|
|
1637
|
+
text-decoration: none;
|
|
1638
|
+
opacity: 0.7;
|
|
1639
|
+
}
|
|
1640
|
+
.info-card .promo-link:hover { color: #f4f4f5; opacity: 1; }
|
|
1641
|
+
|
|
1642
|
+
.hfe-toast {
|
|
1643
|
+
position: fixed;
|
|
1644
|
+
bottom: 64px;
|
|
1645
|
+
left: 50%;
|
|
1646
|
+
transform: translate(-50%, 8px);
|
|
1647
|
+
max-width: 320px;
|
|
1648
|
+
padding: 8px 14px;
|
|
1649
|
+
background: #111827;
|
|
1650
|
+
color: #f4f4f5;
|
|
1651
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
1652
|
+
border-radius: 10px;
|
|
1653
|
+
font: 500 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1654
|
+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
|
|
1655
|
+
opacity: 0;
|
|
1656
|
+
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
1657
|
+
z-index: 2147483647;
|
|
1658
|
+
pointer-events: none;
|
|
1659
|
+
}
|
|
1660
|
+
.hfe-toast[data-show="1"] { opacity: 1; transform: translate(-50%, 0); }
|
|
1661
|
+
.hfe-toast[data-kind="error"] { border-color: rgba(248, 113, 113, 0.4); color: #fca5a5; }
|
|
1662
|
+
|
|
1378
1663
|
.picker-bar {
|
|
1379
1664
|
position: fixed;
|
|
1380
1665
|
top: 12px;
|
|
@@ -1829,18 +2114,21 @@ function buildInfoCard(): HTMLDivElement {
|
|
|
1829
2114
|
<div class="row"><span class="key">url</span><span class="pill url" data-role="url"></span></div>
|
|
1830
2115
|
</div>
|
|
1831
2116
|
<div class="actions">
|
|
1832
|
-
<button class="primary" data-role="
|
|
1833
|
-
<span class="icon"
|
|
1834
|
-
<span class="label">
|
|
1835
|
-
<span class="hint">
|
|
2117
|
+
<button class="primary" data-role="pick-element" type="button">
|
|
2118
|
+
<span class="icon">🔍</span>
|
|
2119
|
+
<span class="label">Copy element info</span>
|
|
2120
|
+
<span class="hint">pick element →</span>
|
|
1836
2121
|
</button>
|
|
1837
2122
|
<button class="secondary" data-role="open-dashboard" type="button" style="display:none">
|
|
1838
2123
|
<span class="icon">↗</span>
|
|
1839
2124
|
<span>Open dashboard</span>
|
|
1840
2125
|
</button>
|
|
1841
|
-
<button class="secondary" data-role="view-reports" type="button">📁 My reports</button>
|
|
1842
2126
|
<button class="secondary" data-role="copy-snapshot" type="button">📋 Copy snapshot</button>
|
|
1843
2127
|
</div>
|
|
2128
|
+
<div class="plugin-actions" data-role="plugin-actions" style="display:none"></div>
|
|
2129
|
+
<div class="promo">
|
|
2130
|
+
<a class="promo-link" data-role="github" href="${GITHUB_URL}" target="_blank" rel="noopener noreferrer">⭐ Harness-FE on GitHub</a>
|
|
2131
|
+
</div>
|
|
1844
2132
|
`;
|
|
1845
2133
|
return card;
|
|
1846
2134
|
}
|
|
@@ -1867,7 +2155,7 @@ function buildPickerBar(): HTMLDivElement {
|
|
|
1867
2155
|
const bar = document.createElement('div');
|
|
1868
2156
|
bar.className = 'picker-bar';
|
|
1869
2157
|
bar.innerHTML = `
|
|
1870
|
-
<span class="label"
|
|
2158
|
+
<span class="label" data-role="picker-label">🔍 Click element to copy info</span>
|
|
1871
2159
|
<span class="hint">esc to cancel</span>
|
|
1872
2160
|
<button data-role="cancel" type="button">Cancel</button>
|
|
1873
2161
|
`;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
registerOverlayPlugin,
|
|
4
|
+
getOverlayPlugins,
|
|
5
|
+
subscribeOverlayPlugins,
|
|
6
|
+
drainPluginQueue,
|
|
7
|
+
__resetOverlayPlugins,
|
|
8
|
+
type OverlayPlugin,
|
|
9
|
+
} from './pluginRegistry.js';
|
|
10
|
+
|
|
11
|
+
const noop = () => {};
|
|
12
|
+
|
|
13
|
+
function plugin(id: string, over: Partial<OverlayPlugin> = {}): OverlayPlugin {
|
|
14
|
+
return { id, label: id, onClick: noop, ...over };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => __resetOverlayPlugins());
|
|
18
|
+
|
|
19
|
+
describe('pluginRegistry', () => {
|
|
20
|
+
it('registers and lists in order', () => {
|
|
21
|
+
registerOverlayPlugin(plugin('a'));
|
|
22
|
+
registerOverlayPlugin(plugin('b'));
|
|
23
|
+
expect(getOverlayPlugins().map((p) => p.id)).toEqual(['a', 'b']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('re-registering an id replaces, keeps position', () => {
|
|
27
|
+
registerOverlayPlugin(plugin('a', { label: 'first' }));
|
|
28
|
+
registerOverlayPlugin(plugin('b'));
|
|
29
|
+
registerOverlayPlugin(plugin('a', { label: 'second' }));
|
|
30
|
+
const ids = getOverlayPlugins().map((p) => p.id);
|
|
31
|
+
expect(ids).toEqual(['a', 'b']);
|
|
32
|
+
expect(getOverlayPlugins().find((p) => p.id === 'a')!.label).toBe('second');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('unregister removes the plugin', () => {
|
|
36
|
+
const off = registerOverlayPlugin(plugin('a'));
|
|
37
|
+
expect(getOverlayPlugins()).toHaveLength(1);
|
|
38
|
+
off();
|
|
39
|
+
expect(getOverlayPlugins()).toHaveLength(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('unregister is a no-op once the id was replaced', () => {
|
|
43
|
+
const off = registerOverlayPlugin(plugin('a', { label: 'old' }));
|
|
44
|
+
registerOverlayPlugin(plugin('a', { label: 'new' })); // replaces
|
|
45
|
+
off(); // should NOT remove the new one
|
|
46
|
+
expect(getOverlayPlugins().map((p) => p.id)).toEqual(['a']);
|
|
47
|
+
expect(getOverlayPlugins()[0].label).toBe('new');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('rejects a plugin with no id or no onClick', () => {
|
|
51
|
+
expect(() => registerOverlayPlugin({ label: 'x' } as unknown as OverlayPlugin)).toThrow();
|
|
52
|
+
expect(() =>
|
|
53
|
+
registerOverlayPlugin({ id: 'x', label: 'x' } as unknown as OverlayPlugin),
|
|
54
|
+
).toThrow();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('notifies subscribers on add / replace / remove (late registration)', () => {
|
|
58
|
+
const fn = vi.fn();
|
|
59
|
+
const unsub = subscribeOverlayPlugins(fn);
|
|
60
|
+
const off = registerOverlayPlugin(plugin('a'));
|
|
61
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
62
|
+
off();
|
|
63
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
64
|
+
unsub();
|
|
65
|
+
registerOverlayPlugin(plugin('b'));
|
|
66
|
+
expect(fn).toHaveBeenCalledTimes(2); // no longer listening
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('drainPluginQueue registers an array and ignores junk', () => {
|
|
70
|
+
drainPluginQueue([plugin('a'), plugin('b'), null, 42, { id: '', label: 'bad' }]);
|
|
71
|
+
expect(getOverlayPlugins().map((p) => p.id)).toEqual(['a', 'b']);
|
|
72
|
+
drainPluginQueue(undefined); // no throw
|
|
73
|
+
drainPluginQueue('nope' as unknown);
|
|
74
|
+
expect(getOverlayPlugins()).toHaveLength(2);
|
|
75
|
+
});
|
|
76
|
+
});
|