@harness-fe/runtime 3.3.0 → 3.4.0

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.
@@ -2,6 +2,8 @@
2
2
  import { afterEach, describe, expect, it } from 'vitest';
3
3
  import { Window } from 'happy-dom';
4
4
  import { installOverlay, buildCssPath, replayStrokes, finalizeAnnotation, type OverlayClient } from './overlay.js';
5
+ import { registerOverlayPlugin, __resetOverlayPlugins, type OverlayPluginContext } from './pluginRegistry.js';
6
+ import { getCaptureStore } from './capture.js';
5
7
 
6
8
  function setupDom(): { win: Window; doc: Document } {
7
9
  const win = new Window();
@@ -317,3 +319,123 @@ describe('buildCssPath', () => {
317
319
  expect(path).toMatch(/p\.x:nth-of-type\(3\)/);
318
320
  });
319
321
  });
322
+
323
+ describe('overlay plugins', () => {
324
+ afterEach(() => {
325
+ document.getElementById('__harness_fe_overlay__')?.remove();
326
+ __resetOverlayPlugins();
327
+ getCaptureStore().network.clear();
328
+ getCaptureStore().console.clear();
329
+ getCaptureStore().errors.clear();
330
+ });
331
+
332
+ const openCard = (root: ShadowRoot) =>
333
+ (root.querySelector('.fab') as HTMLButtonElement).click();
334
+
335
+ it('renders a button for a plugin registered before install', () => {
336
+ setupDom();
337
+ registerOverlayPlugin({ id: 'p1', label: 'Send', icon: '💬', onClick() {} });
338
+ installOverlay(makeFakeClient());
339
+ const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
340
+ const btn = root.querySelector('[data-plugin-id=p1]') as HTMLButtonElement;
341
+ expect(btn).toBeTruthy();
342
+ expect(btn.textContent).toBe('💬 Send');
343
+ const slot = root.querySelector('[data-role=plugin-actions]') as HTMLElement;
344
+ expect(slot.style.display).not.toBe('none');
345
+ });
346
+
347
+ it('renders a button for a plugin registered AFTER install (late registration)', () => {
348
+ setupDom();
349
+ installOverlay(makeFakeClient());
350
+ const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
351
+ expect(root.querySelector('[data-plugin-id=late]')).toBeNull();
352
+ registerOverlayPlugin({ id: 'late', label: 'Later', onClick() {} });
353
+ expect(root.querySelector('[data-plugin-id=late]')).toBeTruthy();
354
+ });
355
+
356
+ it('hides the plugin slot when no plugins are registered', () => {
357
+ setupDom();
358
+ installOverlay(makeFakeClient());
359
+ const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
360
+ const slot = root.querySelector('[data-role=plugin-actions]') as HTMLElement;
361
+ expect(slot.style.display).toBe('none');
362
+ });
363
+
364
+ it('invokes onClick with a context carrying ids / url / snapshotMarkdown', () => {
365
+ setupDom();
366
+ let ctx: OverlayPluginContext | undefined;
367
+ registerOverlayPlugin({ id: 'cap', label: 'Capture', onClick(c) { ctx = c; } });
368
+ installOverlay(makeFakeClient());
369
+ const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
370
+ openCard(root);
371
+ (root.querySelector('[data-plugin-id=cap]') as HTMLButtonElement).click();
372
+ expect(ctx).toBeTruthy();
373
+ expect(ctx!.projectId).toBe('demo');
374
+ expect(ctx!.sessionId).toBe('sess-12345-abcdef-9876');
375
+ expect(ctx!.tabId).toBe('tab-123456-abcdef');
376
+ expect(typeof ctx!.url).toBe('string');
377
+ expect(ctx!.snapshotMarkdown()).toContain('project: `demo`');
378
+ });
379
+
380
+ it('getLogs() redacts network bodies + auth headers by default, raw when opted out', () => {
381
+ setupDom();
382
+ getCaptureStore().network.push({
383
+ ts: Date.now(), method: 'POST', url: 'https://api.example.com/login',
384
+ status: 200,
385
+ requestHeaders: { authorization: 'Bearer secret', 'content-type': 'application/json' },
386
+ requestBody: { password: 'hunter2' },
387
+ responseBody: { token: 'abc' },
388
+ });
389
+ let ctx: OverlayPluginContext | undefined;
390
+ registerOverlayPlugin({ id: 'logs', label: 'Logs', onClick(c) { ctx = c; } });
391
+ installOverlay(makeFakeClient());
392
+ const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
393
+ openCard(root);
394
+ (root.querySelector('[data-plugin-id=logs]') as HTMLButtonElement).click();
395
+
396
+ const redacted = ctx!.getLogs({ network: 5 }).network[0];
397
+ expect(redacted.requestBody).toBeUndefined();
398
+ expect(redacted.responseBody).toBeUndefined();
399
+ expect(redacted.requestHeaders).toEqual({ 'content-type': 'application/json' });
400
+ expect(redacted.url).toBe('https://api.example.com/login');
401
+
402
+ const raw = ctx!.getLogs({ network: 5, redact: false }).network[0];
403
+ expect(raw.requestBody).toEqual({ password: 'hunter2' });
404
+ expect(raw.requestHeaders!.authorization).toBe('Bearer secret');
405
+ });
406
+
407
+ it('a requiresElement plugin click enters picker mode', () => {
408
+ setupDom();
409
+ registerOverlayPlugin({ id: 'pick', label: 'Pick', requiresElement: true, onClick() {} });
410
+ installOverlay(makeFakeClient());
411
+ const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
412
+ openCard(root);
413
+ (root.querySelector('[data-plugin-id=pick]') as HTMLButtonElement).click();
414
+ expect((root.querySelector('.picker-bar') as HTMLElement).style.display).toBe('flex');
415
+ expect((root.querySelector('.info-card') as HTMLElement).style.display).toBe('none');
416
+ expect((root.querySelector('.fab') as HTMLButtonElement).dataset.state).toBe('active');
417
+ });
418
+
419
+ it('toasts when a plugin onClick throws', async () => {
420
+ setupDom();
421
+ registerOverlayPlugin({ id: 'boom', label: 'Boom', onClick() { throw new Error('nope'); } });
422
+ installOverlay(makeFakeClient());
423
+ const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
424
+ openCard(root);
425
+ (root.querySelector('[data-plugin-id=boom]') as HTMLButtonElement).click();
426
+ await Promise.resolve();
427
+ const toast = root.querySelector('.hfe-toast') as HTMLElement;
428
+ expect(toast).toBeTruthy();
429
+ expect(toast.dataset.kind).toBe('error');
430
+ expect(toast.textContent).toContain('nope');
431
+ });
432
+
433
+ it('shows a GitHub promo link in the info card', () => {
434
+ setupDom();
435
+ installOverlay(makeFakeClient());
436
+ const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
437
+ const link = root.querySelector('[data-role=github]') as HTMLAnchorElement;
438
+ expect(link).toBeTruthy();
439
+ expect(link.getAttribute('href')).toBe('https://github.com/Morphicai/harness-fe');
440
+ });
441
+ });
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 { EVENT_NAME, type TaskSubmitPayload, type TaskAttachment } from '@harness-fe/protocol';
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,8 @@ 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;
292
312
 
293
313
  const setState = (next: State) => {
294
314
  state = next;
@@ -360,6 +380,17 @@ export function installOverlay(client: OverlayClient): void {
360
380
  if (!hoveredEl) return;
361
381
  lockedEl = hoveredEl;
362
382
  setHighlight(lockedEl);
383
+ // A plugin requested the element — hand it straight to its onClick and
384
+ // skip the report/question flow entirely.
385
+ if (pluginAwaitingElement) {
386
+ const plugin = pluginAwaitingElement;
387
+ pluginAwaitingElement = null;
388
+ const el = lockedEl;
389
+ lockedEl = null;
390
+ setState('idle');
391
+ void invokePlugin(plugin, el);
392
+ return;
393
+ }
363
394
  // Go straight to the question step. Screenshots are now opt-in via
364
395
  // the "Add screenshot" button inside the question panel — users
365
396
  // shouldn't have to draw on every report.
@@ -385,6 +416,7 @@ export function installOverlay(client: OverlayClient): void {
385
416
  } else if (state === 'picker' || state === 'question') {
386
417
  lockedEl = null;
387
418
  pendingAttachment = null;
419
+ pluginAwaitingElement = null;
388
420
  setState('info');
389
421
  } else if (state === 'info') {
390
422
  setState('idle');
@@ -469,6 +501,106 @@ export function installOverlay(client: OverlayClient): void {
469
501
  return lines.join('\n') + '\n';
470
502
  };
471
503
 
504
+ // ─── Plugin support ──────────────────────────────────────────────────
505
+ const dashboardUrl = client.mcpUrl
506
+ ? deriveDashboardUrl({ mcpUrl: client.mcpUrl, sessionId: client.sessionId })
507
+ : undefined;
508
+
509
+ /** Brief transient toast anchored near the FAB. */
510
+ const showToast = (message: string, kind: 'ok' | 'error' = 'ok'): void => {
511
+ const el = document.createElement('div');
512
+ el.className = 'hfe-toast';
513
+ el.dataset.kind = kind;
514
+ el.textContent = message;
515
+ root.appendChild(el);
516
+ requestAnimationFrame(() => { el.dataset.show = '1'; });
517
+ setTimeout(() => {
518
+ el.dataset.show = '';
519
+ setTimeout(() => el.remove(), 250);
520
+ }, 2400);
521
+ };
522
+
523
+ const buildPluginContext = (selectedEl?: Element): OverlayPluginContext => {
524
+ const selectedElement = selectedEl
525
+ ? {
526
+ el: selectedEl,
527
+ selector: {
528
+ comp: selectedEl.getAttribute('data-morphix-comp') ?? undefined,
529
+ loc: selectedEl.getAttribute('data-morphix-loc') ?? undefined,
530
+ css: buildCssPath(selectedEl),
531
+ },
532
+ outerHTML: truncate(stripInternalAttrs(selectedEl.outerHTML), MAX_OUTER_HTML),
533
+ rect: (() => {
534
+ const r = selectedEl.getBoundingClientRect();
535
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
536
+ })(),
537
+ }
538
+ : undefined;
539
+ return {
540
+ projectId: client.projectId,
541
+ displayName: client.displayName,
542
+ buildId: client.buildId,
543
+ parentProjectId: client.parentProjectId,
544
+ sessionId: client.sessionId,
545
+ tabId: client.tabId,
546
+ visitorId: client.visitorId,
547
+ userId: client.userId,
548
+ url: location.href,
549
+ connectionState: client.getConnectionState(),
550
+ dashboardUrl,
551
+ selectedElement,
552
+ snapshotMarkdown: buildSnapshot,
553
+ snapshot: () => collectPageLoadSnapshot(client.sessionId),
554
+ getLogs: (opts) => collectLogs(opts),
555
+ captureScreenshot: (el) => captureElementPng(el ?? selectedEl ?? document.body),
556
+ query: client.query ? client.query.bind(client) : undefined,
557
+ copyToClipboard: (text) => copyText(text),
558
+ toast: showToast,
559
+ };
560
+ };
561
+
562
+ const invokePlugin = async (plugin: OverlayPlugin, selectedEl?: Element): Promise<void> => {
563
+ try {
564
+ await plugin.onClick(buildPluginContext(selectedEl));
565
+ } catch (err) {
566
+ const msg = err instanceof Error ? err.message : String(err);
567
+ showToast(`${plugin.label}: ${msg}`, 'error');
568
+ }
569
+ };
570
+
571
+ /** (Re)render the plugin button group in the info card. */
572
+ const renderPluginButtons = (): void => {
573
+ const slot = infoCard.querySelector<HTMLElement>('[data-role=plugin-actions]');
574
+ if (!slot) return;
575
+ const list = getOverlayPlugins();
576
+ slot.innerHTML = '';
577
+ slot.style.display = list.length ? '' : 'none';
578
+ for (const plugin of list) {
579
+ const btn = document.createElement('button');
580
+ btn.className = 'secondary';
581
+ btn.type = 'button';
582
+ btn.dataset.pluginId = plugin.id;
583
+ btn.textContent = `${plugin.icon ? plugin.icon + ' ' : ''}${plugin.label}`;
584
+ btn.addEventListener('click', () => {
585
+ if (plugin.requiresElement) {
586
+ pluginAwaitingElement = plugin;
587
+ setState('picker');
588
+ } else {
589
+ setState('idle');
590
+ void invokePlugin(plugin);
591
+ }
592
+ });
593
+ slot.appendChild(btn);
594
+ }
595
+ };
596
+
597
+ renderPluginButtons();
598
+ const unsubscribePlugins = subscribeOverlayPlugins(() => {
599
+ // Only the info card shows plugin buttons; re-render whenever the set changes.
600
+ renderPluginButtons();
601
+ });
602
+ void unsubscribePlugins; // overlay lives for the page lifetime; no teardown path
603
+
472
604
  // ─── Reports rendering ───────────────────────────────────────────────
473
605
  let editingTaskId: string | null = null;
474
606
  let deleteConfirmId: string | null = null;
@@ -690,9 +822,6 @@ export function installOverlay(client: OverlayClient): void {
690
822
  // the plugin / runtime config).
691
823
  {
692
824
  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
825
  if (dashboardUrl) {
697
826
  dashboardBtn.style.display = '';
698
827
  dashboardBtn.title = `Open ${dashboardUrl} in a new tab`;
@@ -708,6 +837,18 @@ export function installOverlay(client: OverlayClient): void {
708
837
  }
709
838
  }
710
839
 
840
+ // GitHub promo link — anchor navigates natively; this fallback covers
841
+ // sandboxed iframes / popup blockers by copying the URL instead.
842
+ infoCard.querySelector<HTMLAnchorElement>('[data-role=github]')!.addEventListener('click', (ev) => {
843
+ try {
844
+ const opened = window.open(GITHUB_URL, '_blank', 'noopener,noreferrer');
845
+ if (opened) ev.preventDefault();
846
+ } catch {
847
+ ev.preventDefault();
848
+ void copyText(GITHUB_URL, ev.currentTarget as HTMLElement);
849
+ }
850
+ });
851
+
711
852
  reportsCard.querySelector('[data-role=back]')!.addEventListener('click', () => setState('info'));
712
853
  reportsCard.querySelector('[data-role=close]')!.addEventListener('click', () => setState('idle'));
713
854
  reportsCard.querySelector('[data-role=refresh]')!.addEventListener('click', () => void refreshReports());
@@ -727,6 +868,7 @@ export function installOverlay(client: OverlayClient): void {
727
868
 
728
869
  pickerBar.querySelector('[data-role=cancel]')!.addEventListener('click', () => {
729
870
  lockedEl = null;
871
+ pluginAwaitingElement = null;
730
872
  setState('info');
731
873
  });
732
874
 
@@ -1148,6 +1290,65 @@ export async function finalizeAnnotation(): Promise<TaskAttachment | null> {
1148
1290
  return att;
1149
1291
  }
1150
1292
 
1293
+ // ─── Plugin helpers (screenshot / logs) ───────────────────────────────────
1294
+
1295
+ /**
1296
+ * Rasterize an element to a PNG TaskAttachment via snapdom. Returns null on
1297
+ * failure (cross-origin, test env, …) so plugins can degrade gracefully.
1298
+ * Exported for testing.
1299
+ */
1300
+ export async function captureElementPng(el: Element): Promise<TaskAttachment | null> {
1301
+ try {
1302
+ const result = await snapdom(el as HTMLElement, { fast: true });
1303
+ const canvas = await result.toCanvas();
1304
+ const dataUrl = canvas.toDataURL('image/png', 0.85);
1305
+ const base64 = dataUrl.replace(/^data:image\/png;base64,/, '');
1306
+ return {
1307
+ id: `att_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
1308
+ kind: 'screenshot',
1309
+ data: base64,
1310
+ width: canvas.width || 1,
1311
+ height: canvas.height || 1,
1312
+ };
1313
+ } catch {
1314
+ return null;
1315
+ }
1316
+ }
1317
+
1318
+ const SENSITIVE_HEADER_RE = /^(authorization|cookie|set-cookie|proxy-authorization)$/i;
1319
+
1320
+ /** Strip bodies + auth/cookie headers from a network entry. */
1321
+ function redactNetworkEntry(e: NetworkEntry): NetworkEntry {
1322
+ const out: NetworkEntry = { ...e };
1323
+ delete out.requestBody;
1324
+ delete out.responseBody;
1325
+ delete out.requestBodyTruncated;
1326
+ delete out.responseBodyTruncated;
1327
+ for (const key of ['requestHeaders', 'responseHeaders'] as const) {
1328
+ const h = out[key];
1329
+ if (h) {
1330
+ const safe: Record<string, string> = {};
1331
+ for (const [k, v] of Object.entries(h)) {
1332
+ if (!SENSITIVE_HEADER_RE.test(k)) safe[k] = v;
1333
+ }
1334
+ out[key] = safe;
1335
+ }
1336
+ }
1337
+ return out;
1338
+ }
1339
+
1340
+ /** Read recent buffered logs for the plugin context. Redacts network by default. */
1341
+ export function collectLogs(opts: OverlayPluginGetLogsOptions = {}): OverlayPluginLogs {
1342
+ const store = getCaptureStore();
1343
+ const redact = opts.redact !== false;
1344
+ const network = store.network.tail(opts.network ?? 0);
1345
+ return {
1346
+ console: store.console.tail(opts.console ?? 0),
1347
+ errors: store.errors.tail(opts.errors ?? 0),
1348
+ network: redact ? network.map(redactNetworkEntry) : network,
1349
+ };
1350
+ }
1351
+
1151
1352
  // ─── DOM builders ────────────────────────────────────────────────────────
1152
1353
 
1153
1354
  function buildStyle(): HTMLStyleElement {
@@ -1375,6 +1576,47 @@ function buildStyle(): HTMLStyleElement {
1375
1576
  border-color: rgba(52, 211, 153, 0.3);
1376
1577
  }
1377
1578
 
1579
+ .info-card .plugin-actions {
1580
+ display: flex;
1581
+ flex-direction: column;
1582
+ gap: 8px;
1583
+ margin-top: 8px;
1584
+ padding-top: 8px;
1585
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
1586
+ }
1587
+ .info-card .promo {
1588
+ margin-top: 8px;
1589
+ text-align: center;
1590
+ }
1591
+ .info-card .promo-link {
1592
+ color: #a1a1aa;
1593
+ font-size: 11px;
1594
+ text-decoration: none;
1595
+ opacity: 0.7;
1596
+ }
1597
+ .info-card .promo-link:hover { color: #f4f4f5; opacity: 1; }
1598
+
1599
+ .hfe-toast {
1600
+ position: fixed;
1601
+ bottom: 64px;
1602
+ left: 50%;
1603
+ transform: translate(-50%, 8px);
1604
+ max-width: 320px;
1605
+ padding: 8px 14px;
1606
+ background: #111827;
1607
+ color: #f4f4f5;
1608
+ border: 1px solid rgba(255, 255, 255, 0.12);
1609
+ border-radius: 10px;
1610
+ font: 500 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1611
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
1612
+ opacity: 0;
1613
+ transition: opacity 0.2s ease, transform 0.2s ease;
1614
+ z-index: 2147483647;
1615
+ pointer-events: none;
1616
+ }
1617
+ .hfe-toast[data-show="1"] { opacity: 1; transform: translate(-50%, 0); }
1618
+ .hfe-toast[data-kind="error"] { border-color: rgba(248, 113, 113, 0.4); color: #fca5a5; }
1619
+
1378
1620
  .picker-bar {
1379
1621
  position: fixed;
1380
1622
  top: 12px;
@@ -1841,6 +2083,10 @@ function buildInfoCard(): HTMLDivElement {
1841
2083
  <button class="secondary" data-role="view-reports" type="button">📁 My reports</button>
1842
2084
  <button class="secondary" data-role="copy-snapshot" type="button">📋 Copy snapshot</button>
1843
2085
  </div>
2086
+ <div class="plugin-actions" data-role="plugin-actions" style="display:none"></div>
2087
+ <div class="promo">
2088
+ <a class="promo-link" data-role="github" href="${GITHUB_URL}" target="_blank" rel="noopener noreferrer">⭐ Harness-FE on GitHub</a>
2089
+ </div>
1844
2090
  `;
1845
2091
  return card;
1846
2092
  }
@@ -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
+ });