@harness-fe/runtime 3.2.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.
- package/dist/client.d.ts +7 -0
- package/dist/client.js +5 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9 -0
- package/dist/overlay.d.ts +9 -0
- package/dist/overlay.js +232 -4
- package/dist/pluginRegistry.d.ts +127 -0
- package/dist/pluginRegistry.js +80 -0
- package/dist/recording.d.ts +16 -1
- package/dist/recording.js +15 -1
- package/package.json +3 -3
- package/src/client.ts +15 -1
- package/src/index.ts +29 -0
- package/src/overlay.test.ts +122 -0
- package/src/overlay.ts +250 -4
- package/src/pluginRegistry.test.ts +76 -0
- package/src/pluginRegistry.ts +189 -0
- package/src/recording.test.ts +57 -0
- package/src/recording.ts +31 -1
package/dist/client.d.ts
CHANGED
|
@@ -28,6 +28,13 @@ export interface ClientOptions {
|
|
|
28
28
|
* by visitorId). Propagated by HarnessScript via window.__HARNESS_FE__.userId.
|
|
29
29
|
*/
|
|
30
30
|
userId?: string;
|
|
31
|
+
/**
|
|
32
|
+
* How often (in ms) rrweb should emit a fresh FullSnapshot baseline.
|
|
33
|
+
* Defaults to 30 minutes. Set to 0 to disable periodic baselines (the
|
|
34
|
+
* recorder still emits one at start() and one per ws reconnect).
|
|
35
|
+
* See {@link RrwebRecorderOptions.checkoutEveryNms} for the trade-off.
|
|
36
|
+
*/
|
|
37
|
+
rrwebCheckoutEveryNms?: number;
|
|
31
38
|
}
|
|
32
39
|
export { tryInheritFromParent } from './parent-inherit.js';
|
|
33
40
|
export type { ParentInheritance } from './parent-inherit.js';
|
package/dist/client.js
CHANGED
|
@@ -90,7 +90,10 @@ export class RuntimeClient {
|
|
|
90
90
|
}
|
|
91
91
|
pageLoadSent = false;
|
|
92
92
|
ctx = { capture: getCaptureStore() };
|
|
93
|
-
|
|
93
|
+
// Initialized in constructor (parameter property `opts` isn't readable at
|
|
94
|
+
// class-field-initializer time — field initializers run before parameter
|
|
95
|
+
// property assignment).
|
|
96
|
+
recorder;
|
|
94
97
|
reconnectAttempts = 0;
|
|
95
98
|
closed = false;
|
|
96
99
|
static MAX_OUTBOX_FRAMES = 500;
|
|
@@ -109,6 +112,7 @@ export class RuntimeClient {
|
|
|
109
112
|
const inheritedVisitor = tryInheritVisitorFromParent();
|
|
110
113
|
this.visitorId = inheritedVisitor ?? getOrCreateVisitorId();
|
|
111
114
|
publishVisitorIdToWindow(this.visitorId);
|
|
115
|
+
this.recorder = new RrwebRecorder((chunk) => this.sendEvent(EVENT_NAME.RRWEB, chunk), { checkoutEveryNms: opts.rrwebCheckoutEveryNms });
|
|
112
116
|
}
|
|
113
117
|
start() {
|
|
114
118
|
const daemonUrl = this.opts.mcpUrl ?? `ws://127.0.0.1:${DEFAULT_WS_PORT}`;
|
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,8 @@ 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;
|
|
245
252
|
const setState = (next) => {
|
|
246
253
|
state = next;
|
|
247
254
|
infoCard.style.display = next === 'info' ? 'flex' : 'none';
|
|
@@ -314,6 +321,17 @@ export function installOverlay(client) {
|
|
|
314
321
|
return;
|
|
315
322
|
lockedEl = hoveredEl;
|
|
316
323
|
setHighlight(lockedEl);
|
|
324
|
+
// A plugin requested the element — hand it straight to its onClick and
|
|
325
|
+
// skip the report/question flow entirely.
|
|
326
|
+
if (pluginAwaitingElement) {
|
|
327
|
+
const plugin = pluginAwaitingElement;
|
|
328
|
+
pluginAwaitingElement = null;
|
|
329
|
+
const el = lockedEl;
|
|
330
|
+
lockedEl = null;
|
|
331
|
+
setState('idle');
|
|
332
|
+
void invokePlugin(plugin, el);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
317
335
|
// Go straight to the question step. Screenshots are now opt-in via
|
|
318
336
|
// the "Add screenshot" button inside the question panel — users
|
|
319
337
|
// shouldn't have to draw on every report.
|
|
@@ -339,6 +357,7 @@ export function installOverlay(client) {
|
|
|
339
357
|
else if (state === 'picker' || state === 'question') {
|
|
340
358
|
lockedEl = null;
|
|
341
359
|
pendingAttachment = null;
|
|
360
|
+
pluginAwaitingElement = null;
|
|
342
361
|
setState('info');
|
|
343
362
|
}
|
|
344
363
|
else if (state === 'info') {
|
|
@@ -425,6 +444,103 @@ export function installOverlay(client) {
|
|
|
425
444
|
lines.push(`- daemon: ${client.getConnectionState()}`);
|
|
426
445
|
return lines.join('\n') + '\n';
|
|
427
446
|
};
|
|
447
|
+
// ─── Plugin support ──────────────────────────────────────────────────
|
|
448
|
+
const dashboardUrl = client.mcpUrl
|
|
449
|
+
? deriveDashboardUrl({ mcpUrl: client.mcpUrl, sessionId: client.sessionId })
|
|
450
|
+
: undefined;
|
|
451
|
+
/** Brief transient toast anchored near the FAB. */
|
|
452
|
+
const showToast = (message, kind = 'ok') => {
|
|
453
|
+
const el = document.createElement('div');
|
|
454
|
+
el.className = 'hfe-toast';
|
|
455
|
+
el.dataset.kind = kind;
|
|
456
|
+
el.textContent = message;
|
|
457
|
+
root.appendChild(el);
|
|
458
|
+
requestAnimationFrame(() => { el.dataset.show = '1'; });
|
|
459
|
+
setTimeout(() => {
|
|
460
|
+
el.dataset.show = '';
|
|
461
|
+
setTimeout(() => el.remove(), 250);
|
|
462
|
+
}, 2400);
|
|
463
|
+
};
|
|
464
|
+
const buildPluginContext = (selectedEl) => {
|
|
465
|
+
const selectedElement = selectedEl
|
|
466
|
+
? {
|
|
467
|
+
el: selectedEl,
|
|
468
|
+
selector: {
|
|
469
|
+
comp: selectedEl.getAttribute('data-morphix-comp') ?? undefined,
|
|
470
|
+
loc: selectedEl.getAttribute('data-morphix-loc') ?? undefined,
|
|
471
|
+
css: buildCssPath(selectedEl),
|
|
472
|
+
},
|
|
473
|
+
outerHTML: truncate(stripInternalAttrs(selectedEl.outerHTML), MAX_OUTER_HTML),
|
|
474
|
+
rect: (() => {
|
|
475
|
+
const r = selectedEl.getBoundingClientRect();
|
|
476
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
477
|
+
})(),
|
|
478
|
+
}
|
|
479
|
+
: undefined;
|
|
480
|
+
return {
|
|
481
|
+
projectId: client.projectId,
|
|
482
|
+
displayName: client.displayName,
|
|
483
|
+
buildId: client.buildId,
|
|
484
|
+
parentProjectId: client.parentProjectId,
|
|
485
|
+
sessionId: client.sessionId,
|
|
486
|
+
tabId: client.tabId,
|
|
487
|
+
visitorId: client.visitorId,
|
|
488
|
+
userId: client.userId,
|
|
489
|
+
url: location.href,
|
|
490
|
+
connectionState: client.getConnectionState(),
|
|
491
|
+
dashboardUrl,
|
|
492
|
+
selectedElement,
|
|
493
|
+
snapshotMarkdown: buildSnapshot,
|
|
494
|
+
snapshot: () => collectPageLoadSnapshot(client.sessionId),
|
|
495
|
+
getLogs: (opts) => collectLogs(opts),
|
|
496
|
+
captureScreenshot: (el) => captureElementPng(el ?? selectedEl ?? document.body),
|
|
497
|
+
query: client.query ? client.query.bind(client) : undefined,
|
|
498
|
+
copyToClipboard: (text) => copyText(text),
|
|
499
|
+
toast: showToast,
|
|
500
|
+
};
|
|
501
|
+
};
|
|
502
|
+
const invokePlugin = async (plugin, selectedEl) => {
|
|
503
|
+
try {
|
|
504
|
+
await plugin.onClick(buildPluginContext(selectedEl));
|
|
505
|
+
}
|
|
506
|
+
catch (err) {
|
|
507
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
508
|
+
showToast(`${plugin.label}: ${msg}`, 'error');
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
/** (Re)render the plugin button group in the info card. */
|
|
512
|
+
const renderPluginButtons = () => {
|
|
513
|
+
const slot = infoCard.querySelector('[data-role=plugin-actions]');
|
|
514
|
+
if (!slot)
|
|
515
|
+
return;
|
|
516
|
+
const list = getOverlayPlugins();
|
|
517
|
+
slot.innerHTML = '';
|
|
518
|
+
slot.style.display = list.length ? '' : 'none';
|
|
519
|
+
for (const plugin of list) {
|
|
520
|
+
const btn = document.createElement('button');
|
|
521
|
+
btn.className = 'secondary';
|
|
522
|
+
btn.type = 'button';
|
|
523
|
+
btn.dataset.pluginId = plugin.id;
|
|
524
|
+
btn.textContent = `${plugin.icon ? plugin.icon + ' ' : ''}${plugin.label}`;
|
|
525
|
+
btn.addEventListener('click', () => {
|
|
526
|
+
if (plugin.requiresElement) {
|
|
527
|
+
pluginAwaitingElement = plugin;
|
|
528
|
+
setState('picker');
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
setState('idle');
|
|
532
|
+
void invokePlugin(plugin);
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
slot.appendChild(btn);
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
renderPluginButtons();
|
|
539
|
+
const unsubscribePlugins = subscribeOverlayPlugins(() => {
|
|
540
|
+
// Only the info card shows plugin buttons; re-render whenever the set changes.
|
|
541
|
+
renderPluginButtons();
|
|
542
|
+
});
|
|
543
|
+
void unsubscribePlugins; // overlay lives for the page lifetime; no teardown path
|
|
428
544
|
// ─── Reports rendering ───────────────────────────────────────────────
|
|
429
545
|
let editingTaskId = null;
|
|
430
546
|
let deleteConfirmId = null;
|
|
@@ -653,9 +769,6 @@ export function installOverlay(client) {
|
|
|
653
769
|
// the plugin / runtime config).
|
|
654
770
|
{
|
|
655
771
|
const dashboardBtn = infoCard.querySelector('[data-role=open-dashboard]');
|
|
656
|
-
const dashboardUrl = client.mcpUrl
|
|
657
|
-
? deriveDashboardUrl({ mcpUrl: client.mcpUrl, sessionId: client.sessionId })
|
|
658
|
-
: undefined;
|
|
659
772
|
if (dashboardUrl) {
|
|
660
773
|
dashboardBtn.style.display = '';
|
|
661
774
|
dashboardBtn.title = `Open ${dashboardUrl} in a new tab`;
|
|
@@ -671,6 +784,19 @@ export function installOverlay(client) {
|
|
|
671
784
|
});
|
|
672
785
|
}
|
|
673
786
|
}
|
|
787
|
+
// GitHub promo link — anchor navigates natively; this fallback covers
|
|
788
|
+
// sandboxed iframes / popup blockers by copying the URL instead.
|
|
789
|
+
infoCard.querySelector('[data-role=github]').addEventListener('click', (ev) => {
|
|
790
|
+
try {
|
|
791
|
+
const opened = window.open(GITHUB_URL, '_blank', 'noopener,noreferrer');
|
|
792
|
+
if (opened)
|
|
793
|
+
ev.preventDefault();
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
ev.preventDefault();
|
|
797
|
+
void copyText(GITHUB_URL, ev.currentTarget);
|
|
798
|
+
}
|
|
799
|
+
});
|
|
674
800
|
reportsCard.querySelector('[data-role=back]').addEventListener('click', () => setState('info'));
|
|
675
801
|
reportsCard.querySelector('[data-role=close]').addEventListener('click', () => setState('idle'));
|
|
676
802
|
reportsCard.querySelector('[data-role=refresh]').addEventListener('click', () => void refreshReports());
|
|
@@ -688,6 +814,7 @@ export function installOverlay(client) {
|
|
|
688
814
|
}
|
|
689
815
|
pickerBar.querySelector('[data-role=cancel]').addEventListener('click', () => {
|
|
690
816
|
lockedEl = null;
|
|
817
|
+
pluginAwaitingElement = null;
|
|
691
818
|
setState('info');
|
|
692
819
|
});
|
|
693
820
|
questionPanel.querySelector('[data-role=cancel]').addEventListener('click', () => {
|
|
@@ -1037,6 +1164,62 @@ export async function finalizeAnnotation() {
|
|
|
1037
1164
|
resetAnnotateStrokes();
|
|
1038
1165
|
return att;
|
|
1039
1166
|
}
|
|
1167
|
+
// ─── Plugin helpers (screenshot / logs) ───────────────────────────────────
|
|
1168
|
+
/**
|
|
1169
|
+
* Rasterize an element to a PNG TaskAttachment via snapdom. Returns null on
|
|
1170
|
+
* failure (cross-origin, test env, …) so plugins can degrade gracefully.
|
|
1171
|
+
* Exported for testing.
|
|
1172
|
+
*/
|
|
1173
|
+
export async function captureElementPng(el) {
|
|
1174
|
+
try {
|
|
1175
|
+
const result = await snapdom(el, { fast: true });
|
|
1176
|
+
const canvas = await result.toCanvas();
|
|
1177
|
+
const dataUrl = canvas.toDataURL('image/png', 0.85);
|
|
1178
|
+
const base64 = dataUrl.replace(/^data:image\/png;base64,/, '');
|
|
1179
|
+
return {
|
|
1180
|
+
id: `att_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
|
1181
|
+
kind: 'screenshot',
|
|
1182
|
+
data: base64,
|
|
1183
|
+
width: canvas.width || 1,
|
|
1184
|
+
height: canvas.height || 1,
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
catch {
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
const SENSITIVE_HEADER_RE = /^(authorization|cookie|set-cookie|proxy-authorization)$/i;
|
|
1192
|
+
/** Strip bodies + auth/cookie headers from a network entry. */
|
|
1193
|
+
function redactNetworkEntry(e) {
|
|
1194
|
+
const out = { ...e };
|
|
1195
|
+
delete out.requestBody;
|
|
1196
|
+
delete out.responseBody;
|
|
1197
|
+
delete out.requestBodyTruncated;
|
|
1198
|
+
delete out.responseBodyTruncated;
|
|
1199
|
+
for (const key of ['requestHeaders', 'responseHeaders']) {
|
|
1200
|
+
const h = out[key];
|
|
1201
|
+
if (h) {
|
|
1202
|
+
const safe = {};
|
|
1203
|
+
for (const [k, v] of Object.entries(h)) {
|
|
1204
|
+
if (!SENSITIVE_HEADER_RE.test(k))
|
|
1205
|
+
safe[k] = v;
|
|
1206
|
+
}
|
|
1207
|
+
out[key] = safe;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
return out;
|
|
1211
|
+
}
|
|
1212
|
+
/** Read recent buffered logs for the plugin context. Redacts network by default. */
|
|
1213
|
+
export function collectLogs(opts = {}) {
|
|
1214
|
+
const store = getCaptureStore();
|
|
1215
|
+
const redact = opts.redact !== false;
|
|
1216
|
+
const network = store.network.tail(opts.network ?? 0);
|
|
1217
|
+
return {
|
|
1218
|
+
console: store.console.tail(opts.console ?? 0),
|
|
1219
|
+
errors: store.errors.tail(opts.errors ?? 0),
|
|
1220
|
+
network: redact ? network.map(redactNetworkEntry) : network,
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1040
1223
|
// ─── DOM builders ────────────────────────────────────────────────────────
|
|
1041
1224
|
function buildStyle() {
|
|
1042
1225
|
const style = document.createElement('style');
|
|
@@ -1263,6 +1446,47 @@ function buildStyle() {
|
|
|
1263
1446
|
border-color: rgba(52, 211, 153, 0.3);
|
|
1264
1447
|
}
|
|
1265
1448
|
|
|
1449
|
+
.info-card .plugin-actions {
|
|
1450
|
+
display: flex;
|
|
1451
|
+
flex-direction: column;
|
|
1452
|
+
gap: 8px;
|
|
1453
|
+
margin-top: 8px;
|
|
1454
|
+
padding-top: 8px;
|
|
1455
|
+
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
1456
|
+
}
|
|
1457
|
+
.info-card .promo {
|
|
1458
|
+
margin-top: 8px;
|
|
1459
|
+
text-align: center;
|
|
1460
|
+
}
|
|
1461
|
+
.info-card .promo-link {
|
|
1462
|
+
color: #a1a1aa;
|
|
1463
|
+
font-size: 11px;
|
|
1464
|
+
text-decoration: none;
|
|
1465
|
+
opacity: 0.7;
|
|
1466
|
+
}
|
|
1467
|
+
.info-card .promo-link:hover { color: #f4f4f5; opacity: 1; }
|
|
1468
|
+
|
|
1469
|
+
.hfe-toast {
|
|
1470
|
+
position: fixed;
|
|
1471
|
+
bottom: 64px;
|
|
1472
|
+
left: 50%;
|
|
1473
|
+
transform: translate(-50%, 8px);
|
|
1474
|
+
max-width: 320px;
|
|
1475
|
+
padding: 8px 14px;
|
|
1476
|
+
background: #111827;
|
|
1477
|
+
color: #f4f4f5;
|
|
1478
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
1479
|
+
border-radius: 10px;
|
|
1480
|
+
font: 500 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1481
|
+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
|
|
1482
|
+
opacity: 0;
|
|
1483
|
+
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
1484
|
+
z-index: 2147483647;
|
|
1485
|
+
pointer-events: none;
|
|
1486
|
+
}
|
|
1487
|
+
.hfe-toast[data-show="1"] { opacity: 1; transform: translate(-50%, 0); }
|
|
1488
|
+
.hfe-toast[data-kind="error"] { border-color: rgba(248, 113, 113, 0.4); color: #fca5a5; }
|
|
1489
|
+
|
|
1266
1490
|
.picker-bar {
|
|
1267
1491
|
position: fixed;
|
|
1268
1492
|
top: 12px;
|
|
@@ -1727,6 +1951,10 @@ function buildInfoCard() {
|
|
|
1727
1951
|
<button class="secondary" data-role="view-reports" type="button">📁 My reports</button>
|
|
1728
1952
|
<button class="secondary" data-role="copy-snapshot" type="button">📋 Copy snapshot</button>
|
|
1729
1953
|
</div>
|
|
1954
|
+
<div class="plugin-actions" data-role="plugin-actions" style="display:none"></div>
|
|
1955
|
+
<div class="promo">
|
|
1956
|
+
<a class="promo-link" data-role="github" href="${GITHUB_URL}" target="_blank" rel="noopener noreferrer">⭐ Harness-FE on GitHub</a>
|
|
1957
|
+
</div>
|
|
1730
1958
|
`;
|
|
1731
1959
|
return card;
|
|
1732
1960
|
}
|
|
@@ -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
|
+
}
|
package/dist/recording.d.ts
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import type { RrwebChunkPayload } from '@harness-fe/protocol';
|
|
2
2
|
export { RRWEB_FULL_SNAPSHOT_TYPE, chunkHasFullSnapshot } from './rrweb-types.js';
|
|
3
|
+
export interface RrwebRecorderOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Force rrweb to emit a fresh FullSnapshot every N milliseconds. Caps how
|
|
6
|
+
* stale the most recent baseline can be, so window replays mid-session
|
|
7
|
+
* don't have to roll forward from a baseline that's potentially hours old.
|
|
8
|
+
*
|
|
9
|
+
* Set to `0` (or a negative number) to disable periodic baselines and
|
|
10
|
+
* rely solely on the start() baseline + reconnect baselines. Useful for
|
|
11
|
+
* extremely bandwidth-constrained deployments.
|
|
12
|
+
*
|
|
13
|
+
* @default 30 * 60 * 1000 (30 minutes)
|
|
14
|
+
*/
|
|
15
|
+
checkoutEveryNms?: number;
|
|
16
|
+
}
|
|
3
17
|
export declare class RrwebRecorder {
|
|
4
18
|
private readonly onChunk;
|
|
19
|
+
private readonly opts;
|
|
5
20
|
private stopRecording?;
|
|
6
21
|
private flushTimer?;
|
|
7
22
|
private chunkSeq;
|
|
8
23
|
private buffer;
|
|
9
|
-
constructor(onChunk: (chunk: RrwebChunkPayload) => void);
|
|
24
|
+
constructor(onChunk: (chunk: RrwebChunkPayload) => void, opts?: RrwebRecorderOptions);
|
|
10
25
|
start(): void;
|
|
11
26
|
stop(): void;
|
|
12
27
|
/**
|
package/dist/recording.js
CHANGED
|
@@ -2,24 +2,38 @@ import { record } from 'rrweb';
|
|
|
2
2
|
export { RRWEB_FULL_SNAPSHOT_TYPE, chunkHasFullSnapshot } from './rrweb-types.js';
|
|
3
3
|
const FLUSH_MS = 5_000;
|
|
4
4
|
const MAX_EVENTS = 200;
|
|
5
|
+
// Default periodic-baseline cadence. Long-running sessions otherwise rely on
|
|
6
|
+
// a single FullSnapshot at start() + one per ws reconnect, which makes
|
|
7
|
+
// mid-session window replays expensive (rrweb has to roll forward all
|
|
8
|
+
// incremental events back to the original baseline) and leaves a window of
|
|
9
|
+
// vulnerability if the original baseline is ever evicted from the outbox.
|
|
10
|
+
// 30 min is a deliberate middle ground: ~16 baselines per 8h session at
|
|
11
|
+
// ~500KB each ≈ 8MB extra storage, which is acceptable for a dev tool.
|
|
12
|
+
const DEFAULT_CHECKOUT_EVERY_MS = 30 * 60 * 1000;
|
|
5
13
|
export class RrwebRecorder {
|
|
6
14
|
onChunk;
|
|
15
|
+
opts;
|
|
7
16
|
stopRecording;
|
|
8
17
|
flushTimer;
|
|
9
18
|
chunkSeq = 0;
|
|
10
19
|
buffer = [];
|
|
11
|
-
constructor(onChunk) {
|
|
20
|
+
constructor(onChunk, opts = {}) {
|
|
12
21
|
this.onChunk = onChunk;
|
|
22
|
+
this.opts = opts;
|
|
13
23
|
}
|
|
14
24
|
start() {
|
|
15
25
|
if (this.stopRecording)
|
|
16
26
|
return;
|
|
27
|
+
const checkoutEveryNms = this.opts.checkoutEveryNms ?? DEFAULT_CHECKOUT_EVERY_MS;
|
|
28
|
+
// rrweb interprets `checkoutEveryNms` falsy / undefined as "off".
|
|
29
|
+
// Pass undefined when disabled so we get the native off-path.
|
|
17
30
|
this.stopRecording = record({
|
|
18
31
|
emit: (event) => this.push(event),
|
|
19
32
|
inlineImages: false,
|
|
20
33
|
recordCanvas: false,
|
|
21
34
|
collectFonts: false,
|
|
22
35
|
maskAllInputs: false,
|
|
36
|
+
checkoutEveryNms: checkoutEveryNms > 0 ? checkoutEveryNms : undefined,
|
|
23
37
|
});
|
|
24
38
|
this.flushTimer = window.setInterval(() => this.flush(), FLUSH_MS);
|
|
25
39
|
}
|