@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harness-fe/runtime",
3
- "version": "3.3.0",
3
+ "version": "3.4.1",
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",
package/src/index.ts CHANGED
@@ -7,15 +7,31 @@
7
7
 
8
8
  import { installOverlay } from './overlay.js';
9
9
  import { RuntimeClient, readInjectedConfig } from './client.js';
10
+ import {
11
+ registerOverlayPlugin,
12
+ drainPluginQueue,
13
+ type OverlayPlugin,
14
+ } from './pluginRegistry.js';
15
+
16
+ // Informational; keep in sync with package.json on release.
17
+ const VERSION = '3.3.0';
10
18
 
11
19
  const w = window as unknown as {
12
20
  __harness_fe_started__?: boolean;
13
21
  __harness_fe_client__?: RuntimeClient;
14
22
  __hfe_session_id__?: string;
23
+ __HARNESS_FE_PLUGINS__?: OverlayPlugin[];
24
+ HarnessFE?: { registerOverlayPlugin: typeof registerOverlayPlugin; version: string };
15
25
  };
16
26
 
17
27
  if (typeof window !== 'undefined' && !w.__harness_fe_started__) {
18
28
  w.__harness_fe_started__ = true;
29
+ // Public global for runtime plugin registration. Works before or after the
30
+ // overlay mounts — the registry buffers and the overlay subscribes.
31
+ w.HarnessFE = { registerOverlayPlugin, version: VERSION };
32
+ // Drain any plugins queued before the runtime loaded.
33
+ drainPluginQueue(w.__HARNESS_FE_PLUGINS__);
34
+
19
35
  const cfg = readInjectedConfig();
20
36
  const client = new RuntimeClient(cfg);
21
37
  client.start();
@@ -30,3 +46,16 @@ if (typeof window !== 'undefined' && !w.__harness_fe_started__) {
30
46
 
31
47
  export { RuntimeClient, tryInheritFromParent } from './client.js';
32
48
  export type { ClientOptions, ParentInheritance } from './client.js';
49
+ export {
50
+ registerOverlayPlugin,
51
+ getOverlayPlugins,
52
+ subscribeOverlayPlugins,
53
+ } from './pluginRegistry.js';
54
+ export type {
55
+ OverlayPlugin,
56
+ OverlayPluginContext,
57
+ OverlayPluginSelectedElement,
58
+ OverlayPluginSelector,
59
+ OverlayPluginLogs,
60
+ OverlayPluginGetLogsOptions,
61
+ } from './pluginRegistry.js';
@@ -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();
@@ -71,20 +73,20 @@ describe('installOverlay', () => {
71
73
  expect(root.querySelector('[data-role=build]')!.textContent).toBe('—');
72
74
  });
73
75
 
74
- it('"Report a problem" enters picker mode (FAB turns active, info card hidden)', () => {
76
+ it('"Copy element info" enters picker mode (FAB turns active, info card hidden)', () => {
75
77
  setupDom();
76
78
  const client = makeFakeClient();
77
79
  installOverlay(client);
78
80
  const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
79
81
  (root.querySelector('.fab') as HTMLButtonElement).click();
80
- (root.querySelector('[data-role=report]') as HTMLButtonElement).click();
82
+ (root.querySelector('[data-role=pick-element]') as HTMLButtonElement).click();
81
83
  const fab = root.querySelector('.fab') as HTMLButtonElement;
82
84
  expect(fab.dataset.state).toBe('active');
83
85
  expect((root.querySelector('.info-card') as HTMLElement).style.display).toBe('none');
84
86
  expect((root.querySelector('.picker-bar') as HTMLElement).style.display).toBe('flex');
85
87
  });
86
88
 
87
- it('submits a task.submit event payload with selector + element on Submit', () => {
89
+ it('"Copy element info" returns to idle after element click (no task.submit fired)', () => {
88
90
  const { doc } = setupDom();
89
91
  const target = doc.createElement('button');
90
92
  target.setAttribute('data-morphix-loc', 'app/cart/CartBadge.tsx:18:5');
@@ -92,47 +94,31 @@ describe('installOverlay', () => {
92
94
  target.textContent = 'Cart (3)';
93
95
  doc.body.appendChild(target);
94
96
 
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
+
95
104
  const client = makeFakeClient();
96
105
  installOverlay(client);
97
106
  const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
98
107
 
99
- // Open reportfake-pick submit.
108
+ // Open info cardenter pick-element picker mode.
100
109
  (root.querySelector('.fab') as HTMLButtonElement).click();
101
- (root.querySelector('[data-role=report]') as HTMLButtonElement).click();
102
-
103
- // Simulate the picker click flow by directly invoking the state we'd
104
- // be in after the user picks. We can't easily simulate
105
- // elementFromPoint in happy-dom, so reach into the question panel
106
- // and submit a payload — overlay.ts's submit handler reads lockedEl
107
- // from a closure, so we go through a synthesized click instead.
108
- // Trick: dispatch a capture-phase click on the body with the target.
109
- // overlay's onClickCapture relies on `hoveredEl` set by mousemove.
110
- // To avoid coupling to mousemove geometry, we test the submit handler
111
- // is wired by inspecting the question textarea wiring instead.
112
-
113
- // Force the panel into "question" state by clicking the target via
114
- // the document; we first set hoveredEl by dispatching mousemove with
115
- // matching screen coords.
116
- target.dispatchEvent(new MouseEvent('mousemove', {
117
- bubbles: true, clientX: 0, clientY: 0,
118
- }));
119
- // Direct click on the picker target triggers the capture handler.
110
+ (root.querySelector('[data-role=pick-element]') as HTMLButtonElement).click();
111
+ expect((root.querySelector('.picker-bar') as HTMLElement).style.display).toBe('flex');
112
+
113
+ // Simulate hover + click on the target element.
114
+ target.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 0, clientY: 0 }));
120
115
  target.click();
121
116
 
122
- // If the picker accepted, the question panel is now visible.
123
- const question = root.querySelector('.question') as HTMLElement;
124
- if (question.style.display === 'flex') {
125
- (root.querySelector('.question textarea') as HTMLTextAreaElement).value = 'broken';
126
- (root.querySelector('.question [data-role=submit]') as HTMLButtonElement).click();
127
- expect(client.sent).toHaveLength(1);
128
- expect(client.sent[0].name).toBe('task.submit');
129
- const payload = client.sent[0].payload as { selector: { loc?: string }; question: string };
130
- expect(payload.question).toBe('broken');
131
- expect(payload.selector.loc).toBe('app/cart/CartBadge.tsx:18:5');
132
- }
133
- // If happy-dom's elementFromPoint didn't cooperate, the test still
134
- // exercises mount/open/copy paths above — submit path is asserted
135
- // separately by buildCssPath unit + bridge.test integration.
117
+ // After the pick: overlay should be idle (picker bar hidden, no question panel).
118
+ // The question panel must NOT open — copy mode skips the report flow.
119
+ expect((root.querySelector('.question') as HTMLElement).style.display).toBe('none');
120
+ // No task.submit event should be sent — copy mode never fires a report.
121
+ expect(client.sent).toHaveLength(0);
136
122
  });
137
123
 
138
124
  it('Esc closes the info card when open', () => {
@@ -317,3 +303,123 @@ describe('buildCssPath', () => {
317
303
  expect(path).toMatch(/p\.x:nth-of-type\(3\)/);
318
304
  });
319
305
  });
306
+
307
+ describe('overlay plugins', () => {
308
+ afterEach(() => {
309
+ document.getElementById('__harness_fe_overlay__')?.remove();
310
+ __resetOverlayPlugins();
311
+ getCaptureStore().network.clear();
312
+ getCaptureStore().console.clear();
313
+ getCaptureStore().errors.clear();
314
+ });
315
+
316
+ const openCard = (root: ShadowRoot) =>
317
+ (root.querySelector('.fab') as HTMLButtonElement).click();
318
+
319
+ it('renders a button for a plugin registered before install', () => {
320
+ setupDom();
321
+ registerOverlayPlugin({ id: 'p1', label: 'Send', icon: '💬', onClick() {} });
322
+ installOverlay(makeFakeClient());
323
+ const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
324
+ const btn = root.querySelector('[data-plugin-id=p1]') as HTMLButtonElement;
325
+ expect(btn).toBeTruthy();
326
+ expect(btn.textContent).toBe('💬 Send');
327
+ const slot = root.querySelector('[data-role=plugin-actions]') as HTMLElement;
328
+ expect(slot.style.display).not.toBe('none');
329
+ });
330
+
331
+ it('renders a button for a plugin registered AFTER install (late registration)', () => {
332
+ setupDom();
333
+ installOverlay(makeFakeClient());
334
+ const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
335
+ expect(root.querySelector('[data-plugin-id=late]')).toBeNull();
336
+ registerOverlayPlugin({ id: 'late', label: 'Later', onClick() {} });
337
+ expect(root.querySelector('[data-plugin-id=late]')).toBeTruthy();
338
+ });
339
+
340
+ it('hides the plugin slot when no plugins are registered', () => {
341
+ setupDom();
342
+ installOverlay(makeFakeClient());
343
+ const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
344
+ const slot = root.querySelector('[data-role=plugin-actions]') as HTMLElement;
345
+ expect(slot.style.display).toBe('none');
346
+ });
347
+
348
+ it('invokes onClick with a context carrying ids / url / snapshotMarkdown', () => {
349
+ setupDom();
350
+ let ctx: OverlayPluginContext | undefined;
351
+ registerOverlayPlugin({ id: 'cap', label: 'Capture', onClick(c) { ctx = c; } });
352
+ installOverlay(makeFakeClient());
353
+ const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
354
+ openCard(root);
355
+ (root.querySelector('[data-plugin-id=cap]') as HTMLButtonElement).click();
356
+ expect(ctx).toBeTruthy();
357
+ expect(ctx!.projectId).toBe('demo');
358
+ expect(ctx!.sessionId).toBe('sess-12345-abcdef-9876');
359
+ expect(ctx!.tabId).toBe('tab-123456-abcdef');
360
+ expect(typeof ctx!.url).toBe('string');
361
+ expect(ctx!.snapshotMarkdown()).toContain('project: `demo`');
362
+ });
363
+
364
+ it('getLogs() redacts network bodies + auth headers by default, raw when opted out', () => {
365
+ setupDom();
366
+ getCaptureStore().network.push({
367
+ ts: Date.now(), method: 'POST', url: 'https://api.example.com/login',
368
+ status: 200,
369
+ requestHeaders: { authorization: 'Bearer secret', 'content-type': 'application/json' },
370
+ requestBody: { password: 'hunter2' },
371
+ responseBody: { token: 'abc' },
372
+ });
373
+ let ctx: OverlayPluginContext | undefined;
374
+ registerOverlayPlugin({ id: 'logs', label: 'Logs', onClick(c) { ctx = c; } });
375
+ installOverlay(makeFakeClient());
376
+ const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
377
+ openCard(root);
378
+ (root.querySelector('[data-plugin-id=logs]') as HTMLButtonElement).click();
379
+
380
+ const redacted = ctx!.getLogs({ network: 5 }).network[0];
381
+ expect(redacted.requestBody).toBeUndefined();
382
+ expect(redacted.responseBody).toBeUndefined();
383
+ expect(redacted.requestHeaders).toEqual({ 'content-type': 'application/json' });
384
+ expect(redacted.url).toBe('https://api.example.com/login');
385
+
386
+ const raw = ctx!.getLogs({ network: 5, redact: false }).network[0];
387
+ expect(raw.requestBody).toEqual({ password: 'hunter2' });
388
+ expect(raw.requestHeaders!.authorization).toBe('Bearer secret');
389
+ });
390
+
391
+ it('a requiresElement plugin click enters picker mode', () => {
392
+ setupDom();
393
+ registerOverlayPlugin({ id: 'pick', label: 'Pick', requiresElement: true, onClick() {} });
394
+ installOverlay(makeFakeClient());
395
+ const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
396
+ openCard(root);
397
+ (root.querySelector('[data-plugin-id=pick]') as HTMLButtonElement).click();
398
+ expect((root.querySelector('.picker-bar') as HTMLElement).style.display).toBe('flex');
399
+ expect((root.querySelector('.info-card') as HTMLElement).style.display).toBe('none');
400
+ expect((root.querySelector('.fab') as HTMLButtonElement).dataset.state).toBe('active');
401
+ });
402
+
403
+ it('toasts when a plugin onClick throws', async () => {
404
+ setupDom();
405
+ registerOverlayPlugin({ id: 'boom', label: 'Boom', onClick() { throw new Error('nope'); } });
406
+ installOverlay(makeFakeClient());
407
+ const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
408
+ openCard(root);
409
+ (root.querySelector('[data-plugin-id=boom]') as HTMLButtonElement).click();
410
+ await Promise.resolve();
411
+ const toast = root.querySelector('.hfe-toast') as HTMLElement;
412
+ expect(toast).toBeTruthy();
413
+ expect(toast.dataset.kind).toBe('error');
414
+ expect(toast.textContent).toContain('nope');
415
+ });
416
+
417
+ it('shows a GitHub promo link in the info card', () => {
418
+ setupDom();
419
+ installOverlay(makeFakeClient());
420
+ const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
421
+ const link = root.querySelector('[data-role=github]') as HTMLAnchorElement;
422
+ expect(link).toBeTruthy();
423
+ expect(link.getAttribute('href')).toBe('https://github.com/Morphicai/harness-fe');
424
+ });
425
+ });