@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/package.json
CHANGED
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';
|
package/src/overlay.test.ts
CHANGED
|
@@ -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('"
|
|
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=
|
|
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('
|
|
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
|
|
108
|
+
// Open info card → enter pick-element picker mode.
|
|
100
109
|
(root.querySelector('.fab') as HTMLButtonElement).click();
|
|
101
|
-
(root.querySelector('[data-role=
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
|
|
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
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
});
|