@harness-fe/mcp-server 4.0.0-next.2 → 4.0.0-next.4
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/bin.d.ts +2 -0
- package/dist/bin.js +15 -0
- package/dist/daemon.d.ts +3 -3
- package/dist/daemon.js +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +65 -19
- package/dist/mcpHttp.d.ts +2 -2
- package/dist/mcpHttp.js +88 -18
- package/package.json +5 -7
- package/src/bin.ts +19 -0
- package/src/daemon.ts +3 -3
- package/src/experimental.test.ts +2 -2
- package/src/index.ts +4 -4
- package/src/mcp.ts +67 -23
- package/src/mcpHttp.test.ts +52 -3
- package/src/mcpHttp.ts +102 -23
- package/src/mcpLayer.e2e.test.ts +2 -2
- package/src/newCapabilities.e2e.test.ts +3 -3
- package/dist/auth.d.ts +0 -53
- package/dist/auth.js +0 -212
- package/dist/bridge.d.ts +0 -323
- package/dist/bridge.js +0 -1618
- package/dist/cli.d.ts +0 -18
- package/dist/cli.js +0 -293
- package/dist/dashboardApi.d.ts +0 -40
- package/dist/dashboardApi.js +0 -142
- package/dist/dashboardSpa.d.ts +0 -18
- package/dist/dashboardSpa.js +0 -180
- package/dist/dashboardUrl.d.ts +0 -13
- package/dist/dashboardUrl.js +0 -18
- package/dist/eventsHandler.d.ts +0 -24
- package/dist/eventsHandler.js +0 -114
- package/dist/identity.d.ts +0 -90
- package/dist/identity.js +0 -123
- package/dist/openBrowser.d.ts +0 -33
- package/dist/openBrowser.js +0 -63
- package/dist/remoteBridge.d.ts +0 -61
- package/dist/remoteBridge.js +0 -307
- package/dist/replayCreate.d.ts +0 -36
- package/dist/replayCreate.js +0 -156
- package/dist/replayViewer.d.ts +0 -20
- package/dist/replayViewer.js +0 -168
- package/dist/sessionRouter.d.ts +0 -45
- package/dist/sessionRouter.js +0 -88
- package/dist/store/JsonMemoryStore.d.ts +0 -52
- package/dist/store/JsonMemoryStore.js +0 -119
- package/dist/store/JsonTaskStore.d.ts +0 -21
- package/dist/store/JsonTaskStore.js +0 -53
- package/dist/store/JsonlStore.d.ts +0 -128
- package/dist/store/JsonlStore.js +0 -1172
- package/dist/store/MemoryEventStore.d.ts +0 -47
- package/dist/store/MemoryEventStore.js +0 -111
- package/dist/store/WriteQueue.d.ts +0 -51
- package/dist/store/WriteQueue.js +0 -142
- package/dist/store/index.d.ts +0 -6
- package/dist/store/index.js +0 -5
- package/dist/store/types.d.ts +0 -427
- package/dist/store/types.js +0 -19
- package/dist/visitorTimeline.d.ts +0 -24
- package/dist/visitorTimeline.js +0 -68
- package/src/auth.test.ts +0 -90
- package/src/auth.ts +0 -248
- package/src/bridge-auth.test.ts +0 -196
- package/src/bridge.test.ts +0 -1708
- package/src/bridge.ts +0 -1854
- package/src/cli.ts +0 -338
- package/src/dashboardApi.test.ts +0 -235
- package/src/dashboardApi.ts +0 -184
- package/src/dashboardSpa.test.ts +0 -239
- package/src/dashboardSpa.ts +0 -195
- package/src/dashboardUrl.test.ts +0 -46
- package/src/dashboardUrl.ts +0 -28
- package/src/eventsHandler.test.ts +0 -247
- package/src/eventsHandler.ts +0 -136
- package/src/identity.test.ts +0 -109
- package/src/identity.ts +0 -137
- package/src/openBrowser.test.ts +0 -103
- package/src/openBrowser.ts +0 -81
- package/src/remoteBridge.test.ts +0 -119
- package/src/remoteBridge.ts +0 -404
- package/src/replay.test.ts +0 -271
- package/src/replayCreate.ts +0 -194
- package/src/replayViewer.ts +0 -173
- package/src/sessionRouter.ts +0 -119
- package/src/store/JsonMemoryStore.test.ts +0 -175
- package/src/store/JsonMemoryStore.ts +0 -128
- package/src/store/JsonTaskStore.test.ts +0 -212
- package/src/store/JsonTaskStore.ts +0 -59
- package/src/store/JsonlStore.test.ts +0 -1538
- package/src/store/JsonlStore.ts +0 -1325
- package/src/store/MemoryEventStore.test.ts +0 -119
- package/src/store/MemoryEventStore.ts +0 -151
- package/src/store/WriteQueue.ts +0 -165
- package/src/store/identityTagging.test.ts +0 -67
- package/src/store/index.ts +0 -29
- package/src/store/types.ts +0 -532
- package/src/visitorTimeline.test.ts +0 -197
- package/src/visitorTimeline.ts +0 -89
package/src/replayCreate.ts
DELETED
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared replay-export logic used by both the leader's MCP tool handler and
|
|
3
|
-
* the leader's mcp.call dispatcher (for follower proxy calls).
|
|
4
|
-
*
|
|
5
|
-
* Takes a time window (or a center timestamp), pulls the overlapping rrweb
|
|
6
|
-
* chunks for a single tab, concatenates the events, persists them as an
|
|
7
|
-
* export, and returns the metadata + viewerUrl.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { IStore } from './store/index.js';
|
|
11
|
-
|
|
12
|
-
/** rrweb event types: 2 = FullSnapshot baseline. Replay needs at least one. */
|
|
13
|
-
function isFullSnapshotEvent(ev: unknown): boolean {
|
|
14
|
-
return (
|
|
15
|
-
typeof ev === 'object' &&
|
|
16
|
-
ev !== null &&
|
|
17
|
-
(ev as { type?: unknown }).type === 2
|
|
18
|
-
);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface ReplayCreateArgs {
|
|
22
|
-
sessionId: string;
|
|
23
|
-
tabId?: string;
|
|
24
|
-
ts?: number;
|
|
25
|
-
windowMs?: number;
|
|
26
|
-
since?: number;
|
|
27
|
-
until?: number;
|
|
28
|
-
label?: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface ReplayCreateResult {
|
|
32
|
-
exportId?: string;
|
|
33
|
-
viewerUrl?: string;
|
|
34
|
-
sessionId: string;
|
|
35
|
-
tabId?: string;
|
|
36
|
-
since: number;
|
|
37
|
-
until: number;
|
|
38
|
-
startTs?: number;
|
|
39
|
-
endTs?: number;
|
|
40
|
-
durationMs?: number;
|
|
41
|
-
eventCount?: number;
|
|
42
|
-
chunkCount?: number;
|
|
43
|
-
bytes?: number;
|
|
44
|
-
createdAt?: number;
|
|
45
|
-
label?: string;
|
|
46
|
-
error?: string;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function createReplayExport(
|
|
50
|
-
store: IStore,
|
|
51
|
-
baseUrl: string | undefined,
|
|
52
|
-
input: ReplayCreateArgs,
|
|
53
|
-
): ReplayCreateResult {
|
|
54
|
-
const { sessionId, tabId, ts, windowMs, since, until, label } = input;
|
|
55
|
-
|
|
56
|
-
const session = store.getSession(sessionId);
|
|
57
|
-
if (!session) {
|
|
58
|
-
return { error: 'session not found', sessionId, since: since ?? 0, until: until ?? 0 };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
let resolvedSince: number;
|
|
62
|
-
let resolvedUntil: number;
|
|
63
|
-
if (typeof since === 'number' && typeof until === 'number') {
|
|
64
|
-
if (until <= since) {
|
|
65
|
-
return { error: 'until must be greater than since', sessionId, since, until };
|
|
66
|
-
}
|
|
67
|
-
resolvedSince = since;
|
|
68
|
-
resolvedUntil = until;
|
|
69
|
-
} else if (typeof ts === 'number') {
|
|
70
|
-
const radius = windowMs ?? 15_000;
|
|
71
|
-
resolvedSince = ts - radius;
|
|
72
|
-
resolvedUntil = ts + radius;
|
|
73
|
-
} else {
|
|
74
|
-
return {
|
|
75
|
-
error: 'must provide either ts (with optional windowMs) or both since and until',
|
|
76
|
-
sessionId,
|
|
77
|
-
since: 0,
|
|
78
|
-
until: 0,
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const chunks = store.sliceRecordings(sessionId, resolvedSince, resolvedUntil);
|
|
83
|
-
if (chunks.length === 0) {
|
|
84
|
-
return {
|
|
85
|
-
error: 'no rrweb chunks found in window',
|
|
86
|
-
sessionId,
|
|
87
|
-
tabId,
|
|
88
|
-
since: resolvedSince,
|
|
89
|
-
until: resolvedUntil,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
let scopedTabId = tabId;
|
|
94
|
-
if (!scopedTabId) {
|
|
95
|
-
const byTab = new Map<string, number>();
|
|
96
|
-
for (const c of chunks) byTab.set(c.tabId, (byTab.get(c.tabId) ?? 0) + c.eventCount);
|
|
97
|
-
let best = '';
|
|
98
|
-
let bestEvents = -1;
|
|
99
|
-
for (const [t, count] of byTab) {
|
|
100
|
-
if (count > bestEvents) { best = t; bestEvents = count; }
|
|
101
|
-
}
|
|
102
|
-
scopedTabId = best;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const tabChunks = chunks.filter((c) => c.tabId === scopedTabId);
|
|
106
|
-
const events: unknown[] = [];
|
|
107
|
-
let startTs = Infinity;
|
|
108
|
-
let endTs = -Infinity;
|
|
109
|
-
for (const c of tabChunks) {
|
|
110
|
-
for (const ev of c.events) events.push(ev);
|
|
111
|
-
if (c.startTs < startTs) startTs = c.startTs;
|
|
112
|
-
if (c.endTs > endTs) endTs = c.endTs;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// rrweb replay requires a baseline pair — type:4 (Meta) + type:2
|
|
116
|
-
// (FullSnapshot) — before any type:3 (IncrementalSnapshot) is meaningful.
|
|
117
|
-
// If the window only contains incremental mutations (e.g. user picked a
|
|
118
|
-
// narrow window long after the page loaded, or the very first chunk was
|
|
119
|
-
// lost during a daemon restart), look back across earlier chunks for the
|
|
120
|
-
// most recent baseline and prepend it. Replay will then start from that
|
|
121
|
-
// earlier DOM state and roll mutations forward into the window.
|
|
122
|
-
if (!events.some(isFullSnapshotEvent) && resolvedSince > 0) {
|
|
123
|
-
const priorChunks = store
|
|
124
|
-
.sliceRecordings(sessionId, 0, resolvedSince - 1)
|
|
125
|
-
.filter((c) => c.tabId === scopedTabId)
|
|
126
|
-
.sort((a, b) => a.startTs - b.startTs);
|
|
127
|
-
// Walk backwards from the chunk closest to window start; the first
|
|
128
|
-
// chunk that has a FullSnapshot becomes our baseline.
|
|
129
|
-
for (let i = priorChunks.length - 1; i >= 0; i--) {
|
|
130
|
-
const baseline = priorChunks[i];
|
|
131
|
-
if (!baseline) continue;
|
|
132
|
-
if (baseline.events.some(isFullSnapshotEvent)) {
|
|
133
|
-
// Prepend baseline events (full chunk — preserves Meta + FS
|
|
134
|
-
// ordering rrweb emitted them in). startTs widens to baseline.
|
|
135
|
-
events.unshift(...baseline.events);
|
|
136
|
-
if (baseline.startTs < startTs) startTs = baseline.startTs;
|
|
137
|
-
break;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
if (events.length < 2) {
|
|
143
|
-
return {
|
|
144
|
-
error: 'window contains fewer than 2 rrweb events — not enough to replay',
|
|
145
|
-
sessionId,
|
|
146
|
-
tabId: scopedTabId,
|
|
147
|
-
since: resolvedSince,
|
|
148
|
-
until: resolvedUntil,
|
|
149
|
-
eventCount: events.length,
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
if (!events.some(isFullSnapshotEvent)) {
|
|
153
|
-
return {
|
|
154
|
-
error:
|
|
155
|
-
'window contains no rrweb FullSnapshot (type:2) baseline, and no earlier baseline could be found — replay would be blank',
|
|
156
|
-
sessionId,
|
|
157
|
-
tabId: scopedTabId,
|
|
158
|
-
since: resolvedSince,
|
|
159
|
-
until: resolvedUntil,
|
|
160
|
-
eventCount: events.length,
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const meta = store.writeExport({
|
|
165
|
-
sessionId,
|
|
166
|
-
tabId: scopedTabId,
|
|
167
|
-
since: resolvedSince,
|
|
168
|
-
until: resolvedUntil,
|
|
169
|
-
label,
|
|
170
|
-
events,
|
|
171
|
-
startTs,
|
|
172
|
-
endTs,
|
|
173
|
-
chunkCount: tabChunks.length,
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
const viewerUrl = baseUrl ? `${baseUrl}/replay/${meta.exportId}` : undefined;
|
|
177
|
-
|
|
178
|
-
return {
|
|
179
|
-
exportId: meta.exportId,
|
|
180
|
-
viewerUrl,
|
|
181
|
-
sessionId,
|
|
182
|
-
tabId: scopedTabId,
|
|
183
|
-
since: resolvedSince,
|
|
184
|
-
until: resolvedUntil,
|
|
185
|
-
startTs,
|
|
186
|
-
endTs,
|
|
187
|
-
durationMs: endTs - startTs,
|
|
188
|
-
eventCount: meta.eventCount,
|
|
189
|
-
chunkCount: meta.chunkCount,
|
|
190
|
-
bytes: meta.bytes,
|
|
191
|
-
createdAt: meta.createdAt,
|
|
192
|
-
label,
|
|
193
|
-
};
|
|
194
|
-
}
|
package/src/replayViewer.ts
DELETED
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Replay viewer — HTTP routes for serving exported rrweb recordings.
|
|
3
|
-
*
|
|
4
|
-
* Routes (all served on the same port as the WS bridge):
|
|
5
|
-
* GET /replay/:id HTML viewer page (rrweb-player UI)
|
|
6
|
-
* GET /replay/:id.json Raw events array for the player to fetch
|
|
7
|
-
* GET /replay/static/player.js Bundled rrweb-player (UMD)
|
|
8
|
-
* GET /replay/static/player.css Bundled rrweb-player styles
|
|
9
|
-
*
|
|
10
|
-
* The HTML page loads the static assets relatively and then calls
|
|
11
|
-
* /replay/:id.json to hydrate the player. This keeps the viewer self-contained
|
|
12
|
-
* and offline-capable — no CDN dependencies.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
16
|
-
import { createRequire } from 'node:module';
|
|
17
|
-
import { dirname, join } from 'node:path';
|
|
18
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
19
|
-
import type { IStore } from './store/index.js';
|
|
20
|
-
|
|
21
|
-
const require = createRequire(import.meta.url);
|
|
22
|
-
|
|
23
|
-
/** Locate the bundled rrweb-player dist directory. */
|
|
24
|
-
function resolvePlayerDist(): string {
|
|
25
|
-
// rrweb-player exposes package.json; use it to find the dist folder.
|
|
26
|
-
const pkgPath = require.resolve('rrweb-player/package.json');
|
|
27
|
-
return join(dirname(pkgPath), 'dist');
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const PLAYER_DIST = (() => {
|
|
31
|
-
try { return resolvePlayerDist(); } catch { return ''; }
|
|
32
|
-
})();
|
|
33
|
-
|
|
34
|
-
const VIEWER_HTML = (exportId: string, meta: { sessionId: string; tabId?: string; durationMs: number; eventCount: number }): string => `<!doctype html>
|
|
35
|
-
<html lang="en">
|
|
36
|
-
<head>
|
|
37
|
-
<meta charset="utf-8" />
|
|
38
|
-
<title>Harness replay · ${escapeHtml(exportId)}</title>
|
|
39
|
-
<link rel="stylesheet" href="/replay/static/player.css" />
|
|
40
|
-
<style>
|
|
41
|
-
html, body { margin: 0; padding: 0; background: #1a1a1a; color: #eee; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
|
42
|
-
.meta { padding: 12px 16px; font-size: 13px; color: #aaa; border-bottom: 1px solid #333; }
|
|
43
|
-
.meta code { color: #6cf; }
|
|
44
|
-
.stage { padding: 16px; display: flex; justify-content: center; }
|
|
45
|
-
.error { padding: 24px; color: #f66; }
|
|
46
|
-
</style>
|
|
47
|
-
</head>
|
|
48
|
-
<body>
|
|
49
|
-
<div class="meta">
|
|
50
|
-
Replay <code>${escapeHtml(exportId)}</code> · session <code>${escapeHtml(meta.sessionId)}</code>${meta.tabId ? ` · tab <code>${escapeHtml(meta.tabId)}</code>` : ''} · ${meta.eventCount} events · ${Math.round(meta.durationMs / 100) / 10}s
|
|
51
|
-
</div>
|
|
52
|
-
<div class="stage" id="stage"></div>
|
|
53
|
-
<script src="/replay/static/player.js"></script>
|
|
54
|
-
<script>
|
|
55
|
-
(async function() {
|
|
56
|
-
const stage = document.getElementById('stage');
|
|
57
|
-
try {
|
|
58
|
-
const resp = await fetch(${JSON.stringify(`/replay/${exportId}.json`)});
|
|
59
|
-
if (!resp.ok) throw new Error('failed to fetch events: ' + resp.status);
|
|
60
|
-
const events = await resp.json();
|
|
61
|
-
if (!Array.isArray(events) || events.length < 2) {
|
|
62
|
-
throw new Error('export has fewer than 2 events — cannot play back');
|
|
63
|
-
}
|
|
64
|
-
// rrwebPlayer is the UMD global.
|
|
65
|
-
new rrwebPlayer({
|
|
66
|
-
target: stage,
|
|
67
|
-
props: {
|
|
68
|
-
events,
|
|
69
|
-
autoPlay: true,
|
|
70
|
-
showController: true,
|
|
71
|
-
width: Math.min(1280, window.innerWidth - 64),
|
|
72
|
-
height: Math.min(720, window.innerHeight - 120),
|
|
73
|
-
},
|
|
74
|
-
});
|
|
75
|
-
} catch (err) {
|
|
76
|
-
stage.innerHTML = '<div class="error">Replay failed: ' + (err && err.message ? String(err.message).replace(/[<>&]/g, '') : 'unknown error') + '</div>';
|
|
77
|
-
console.error('[harness replay]', err);
|
|
78
|
-
}
|
|
79
|
-
})();
|
|
80
|
-
</script>
|
|
81
|
-
</body>
|
|
82
|
-
</html>`;
|
|
83
|
-
|
|
84
|
-
function escapeHtml(s: string): string {
|
|
85
|
-
return s.replace(/[&<>"']/g, (ch) => {
|
|
86
|
-
switch (ch) {
|
|
87
|
-
case '&': return '&';
|
|
88
|
-
case '<': return '<';
|
|
89
|
-
case '>': return '>';
|
|
90
|
-
case '"': return '"';
|
|
91
|
-
default: return ''';
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function send(res: ServerResponse, status: number, contentType: string, body: string | Buffer): void {
|
|
97
|
-
res.statusCode = status;
|
|
98
|
-
res.setHeader('content-type', contentType);
|
|
99
|
-
res.setHeader('cache-control', 'no-store');
|
|
100
|
-
res.end(body);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Build a handler that dispatches /replay/* requests. Returns undefined for
|
|
105
|
-
* non-replay paths so the caller can chain or 404.
|
|
106
|
-
*/
|
|
107
|
-
export function createReplayHandler(store: IStore): (req: IncomingMessage, res: ServerResponse) => boolean {
|
|
108
|
-
return (req, res) => {
|
|
109
|
-
if (!req.url) return false;
|
|
110
|
-
const url = new URL(req.url, 'http://localhost');
|
|
111
|
-
const path = url.pathname;
|
|
112
|
-
if (!path.startsWith('/replay/')) return false;
|
|
113
|
-
|
|
114
|
-
// Static assets
|
|
115
|
-
if (path === '/replay/static/player.js') {
|
|
116
|
-
if (!PLAYER_DIST) return reply500(res, 'rrweb-player not installed');
|
|
117
|
-
const file = join(PLAYER_DIST, 'index.js');
|
|
118
|
-
if (!existsSync(file)) return reply500(res, 'rrweb-player bundle missing');
|
|
119
|
-
send(res, 200, 'application/javascript; charset=utf-8', readFileSync(file));
|
|
120
|
-
return true;
|
|
121
|
-
}
|
|
122
|
-
if (path === '/replay/static/player.css') {
|
|
123
|
-
if (!PLAYER_DIST) return reply500(res, 'rrweb-player not installed');
|
|
124
|
-
const file = join(PLAYER_DIST, 'style.css');
|
|
125
|
-
if (!existsSync(file)) return reply500(res, 'rrweb-player styles missing');
|
|
126
|
-
send(res, 200, 'text/css; charset=utf-8', readFileSync(file));
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// /replay/:id or /replay/:id.json
|
|
131
|
-
const tail = path.slice('/replay/'.length);
|
|
132
|
-
if (!tail || tail.includes('/')) {
|
|
133
|
-
send(res, 404, 'text/plain; charset=utf-8', 'Not Found');
|
|
134
|
-
return true;
|
|
135
|
-
}
|
|
136
|
-
const isJson = tail.endsWith('.json');
|
|
137
|
-
const exportId = isJson ? tail.slice(0, -'.json'.length) : tail;
|
|
138
|
-
if (!/^[A-Za-z0-9_-]+$/.test(exportId)) {
|
|
139
|
-
send(res, 400, 'text/plain; charset=utf-8', 'Invalid export id');
|
|
140
|
-
return true;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const meta = store.getExport(exportId);
|
|
144
|
-
if (!meta) {
|
|
145
|
-
send(res, 404, 'text/plain; charset=utf-8', `Unknown export: ${exportId}`);
|
|
146
|
-
return true;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (isJson) {
|
|
150
|
-
const events = store.readExportEvents(exportId);
|
|
151
|
-
if (!events) {
|
|
152
|
-
send(res, 404, 'application/json; charset=utf-8', '{"error":"export events missing"}');
|
|
153
|
-
return true;
|
|
154
|
-
}
|
|
155
|
-
send(res, 200, 'application/json; charset=utf-8', JSON.stringify(events));
|
|
156
|
-
return true;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const html = VIEWER_HTML(exportId, {
|
|
160
|
-
sessionId: meta.sessionId,
|
|
161
|
-
tabId: meta.tabId,
|
|
162
|
-
durationMs: Math.max(0, meta.endTs - meta.startTs),
|
|
163
|
-
eventCount: meta.eventCount,
|
|
164
|
-
});
|
|
165
|
-
send(res, 200, 'text/html; charset=utf-8', html);
|
|
166
|
-
return true;
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
function reply500(res: ServerResponse, msg: string): true {
|
|
170
|
-
send(res, 500, 'text/plain; charset=utf-8', msg);
|
|
171
|
-
return true;
|
|
172
|
-
}
|
|
173
|
-
}
|
package/src/sessionRouter.ts
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SessionRouter — registry of connected peers (vite-plugin + runtime-client).
|
|
3
|
-
*
|
|
4
|
-
* - Project: 1 vite-plugin per project (uniqued by projectId)
|
|
5
|
-
* - Tab : N runtime-clients per project (each browser tab is one)
|
|
6
|
-
*
|
|
7
|
-
* Active tab heuristic: most recently active (last command or last event).
|
|
8
|
-
* Caller can override via explicit tabId on every command.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import type {
|
|
12
|
-
PeerRole,
|
|
13
|
-
TabInfo,
|
|
14
|
-
} from '@harness-fe/protocol';
|
|
15
|
-
import type { Principal } from './identity.js';
|
|
16
|
-
|
|
17
|
-
export interface PeerSession {
|
|
18
|
-
role: PeerRole;
|
|
19
|
-
projectId: string;
|
|
20
|
-
tabId?: string;
|
|
21
|
-
/** Caller identity behind this connection (4.0 · P1). Defaults to `local`. */
|
|
22
|
-
principal?: Principal;
|
|
23
|
-
/** Runtime-client only: identifies the page load (sessionId) this connection belongs to. */
|
|
24
|
-
sessionId?: string;
|
|
25
|
-
/** Runtime-client only: stable per-browser visitor identifier. */
|
|
26
|
-
visitorId?: string;
|
|
27
|
-
/** App-supplied user identifier propagated from HarnessScript userId prop. */
|
|
28
|
-
userId?: string;
|
|
29
|
-
/** Opaque identifier for the underlying connection. */
|
|
30
|
-
connectionId: string;
|
|
31
|
-
lastActive: number;
|
|
32
|
-
page?: { url?: string; title?: string; userAgent?: string };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export class SessionRouter {
|
|
36
|
-
private peers = new Map<string, PeerSession>(); // key = connectionId
|
|
37
|
-
private mostRecentTabId?: string;
|
|
38
|
-
|
|
39
|
-
register(session: Omit<PeerSession, 'lastActive'>): PeerSession {
|
|
40
|
-
const stored: PeerSession = { ...session, lastActive: Date.now() };
|
|
41
|
-
this.peers.set(session.connectionId, stored);
|
|
42
|
-
if (session.role === 'runtime-client' && session.tabId) {
|
|
43
|
-
this.mostRecentTabId = session.tabId;
|
|
44
|
-
}
|
|
45
|
-
return stored;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
unregister(connectionId: string): void {
|
|
49
|
-
const peer = this.peers.get(connectionId);
|
|
50
|
-
this.peers.delete(connectionId);
|
|
51
|
-
if (peer?.tabId && this.mostRecentTabId === peer.tabId) {
|
|
52
|
-
this.mostRecentTabId = this.findFallbackTab()?.tabId;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
touch(connectionId: string): void {
|
|
57
|
-
const peer = this.peers.get(connectionId);
|
|
58
|
-
if (!peer) return;
|
|
59
|
-
peer.lastActive = Date.now();
|
|
60
|
-
if (peer.role === 'runtime-client' && peer.tabId) {
|
|
61
|
-
this.mostRecentTabId = peer.tabId;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
getByConnectionId(connectionId: string): PeerSession | undefined {
|
|
66
|
-
return this.peers.get(connectionId);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
findVitePlugin(projectId?: string): PeerSession | undefined {
|
|
70
|
-
const candidates = [...this.peers.values()].filter(
|
|
71
|
-
(p) => p.role === 'vite-plugin' || p.role === 'webpack-plugin',
|
|
72
|
-
);
|
|
73
|
-
if (!candidates.length) return undefined;
|
|
74
|
-
if (projectId) return candidates.find((c) => c.projectId === projectId);
|
|
75
|
-
// No projectId filter — return the most recent.
|
|
76
|
-
return candidates.sort((a, b) => b.lastActive - a.lastActive)[0];
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
findTab(tabId?: string): PeerSession | undefined {
|
|
80
|
-
if (tabId) {
|
|
81
|
-
for (const p of this.peers.values()) {
|
|
82
|
-
if (p.role === 'runtime-client' && p.tabId === tabId) return p;
|
|
83
|
-
}
|
|
84
|
-
return undefined;
|
|
85
|
-
}
|
|
86
|
-
if (this.mostRecentTabId) {
|
|
87
|
-
return this.findTab(this.mostRecentTabId);
|
|
88
|
-
}
|
|
89
|
-
return this.findFallbackTab();
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
private findFallbackTab(): PeerSession | undefined {
|
|
93
|
-
const tabs = [...this.peers.values()]
|
|
94
|
-
.filter((p) => p.role === 'runtime-client' && p.tabId)
|
|
95
|
-
.sort((a, b) => b.lastActive - a.lastActive);
|
|
96
|
-
return tabs[0];
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
listTabs(): TabInfo[] {
|
|
100
|
-
return [...this.peers.values()]
|
|
101
|
-
.filter((p): p is PeerSession & { tabId: string } => p.role === 'runtime-client' && !!p.tabId)
|
|
102
|
-
.map((p) => ({
|
|
103
|
-
tabId: p.tabId,
|
|
104
|
-
projectId: p.projectId,
|
|
105
|
-
url: p.page?.url,
|
|
106
|
-
title: p.page?.title,
|
|
107
|
-
userAgent: p.page?.userAgent,
|
|
108
|
-
connectedAt: p.lastActive,
|
|
109
|
-
}));
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
listProjects(): string[] {
|
|
113
|
-
const ids = new Set<string>();
|
|
114
|
-
for (const p of this.peers.values()) {
|
|
115
|
-
if (p.role === 'vite-plugin' || p.role === 'webpack-plugin') ids.add(p.projectId);
|
|
116
|
-
}
|
|
117
|
-
return [...ids];
|
|
118
|
-
}
|
|
119
|
-
}
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import * as fc from 'fast-check';
|
|
6
|
-
import { JsonMemoryStore } from './JsonMemoryStore.js';
|
|
7
|
-
|
|
8
|
-
function makeStore() {
|
|
9
|
-
const dir = mkdtempSync(join(tmpdir(), 'harness-memory-test-'));
|
|
10
|
-
const store = new JsonMemoryStore(dir);
|
|
11
|
-
return { store, dir };
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function cleanup(dir: string) {
|
|
15
|
-
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// ── Unit Tests ───────────────────────────────────────────────────────────────
|
|
19
|
-
|
|
20
|
-
describe('JsonMemoryStore — unit tests', () => {
|
|
21
|
-
let dir: string;
|
|
22
|
-
let store: JsonMemoryStore;
|
|
23
|
-
|
|
24
|
-
beforeEach(() => {
|
|
25
|
-
({ store, dir } = makeStore());
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
afterEach(() => {
|
|
29
|
-
cleanup(dir);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('get returns undefined for a missing key', () => {
|
|
33
|
-
const result = store.get('proj', 'nonexistent-key');
|
|
34
|
-
expect(result).toBeUndefined();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('list returns empty array when no entries exist', () => {
|
|
38
|
-
const result = store.list('proj');
|
|
39
|
-
expect(result).toEqual([]);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('set stores a value and get retrieves it', () => {
|
|
43
|
-
store.set('proj', 'my-key', 'my-value');
|
|
44
|
-
const entry = store.get('proj', 'my-key');
|
|
45
|
-
expect(entry).toBeDefined();
|
|
46
|
-
expect(entry!.key).toBe('my-key');
|
|
47
|
-
expect(entry!.value).toBe('my-value');
|
|
48
|
-
expect(typeof entry!.updatedAt).toBe('number');
|
|
49
|
-
expect(entry!.updatedAt).toBeGreaterThan(0);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('delete existing key returns { deleted: true }', () => {
|
|
53
|
-
store.set('proj', 'to-delete', 'some-value');
|
|
54
|
-
const deleted = store.delete('proj', 'to-delete');
|
|
55
|
-
expect(deleted).toBe(true);
|
|
56
|
-
expect(store.get('proj', 'to-delete')).toBeUndefined();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('delete missing key returns { deleted: false }', () => {
|
|
60
|
-
const deleted = store.delete('proj', 'does-not-exist');
|
|
61
|
-
expect(deleted).toBe(false);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('list returns all entries after multiple sets', () => {
|
|
65
|
-
store.set('proj', 'key-a', 'val-a');
|
|
66
|
-
store.set('proj', 'key-b', 'val-b');
|
|
67
|
-
store.set('proj', 'key-c', 'val-c');
|
|
68
|
-
const entries = store.list('proj');
|
|
69
|
-
expect(entries).toHaveLength(3);
|
|
70
|
-
const keys = entries.map((e) => e.key);
|
|
71
|
-
expect(keys).toContain('key-a');
|
|
72
|
-
expect(keys).toContain('key-b');
|
|
73
|
-
expect(keys).toContain('key-c');
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('set overwrites an existing key', () => {
|
|
77
|
-
store.set('proj', 'key', 'original');
|
|
78
|
-
store.set('proj', 'key', 'updated');
|
|
79
|
-
const entry = store.get('proj', 'key');
|
|
80
|
-
expect(entry!.value).toBe('updated');
|
|
81
|
-
const entries = store.list('proj');
|
|
82
|
-
expect(entries).toHaveLength(1);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('entries are isolated per projectId', () => {
|
|
86
|
-
store.set('proj-a', 'key', 'value-a');
|
|
87
|
-
store.set('proj-b', 'key', 'value-b');
|
|
88
|
-
expect(store.get('proj-a', 'key')!.value).toBe('value-a');
|
|
89
|
-
expect(store.get('proj-b', 'key')!.value).toBe('value-b');
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
// ── Property-Based Tests ─────────────────────────────────────────────────────
|
|
94
|
-
|
|
95
|
-
// Feature: persistence, Property 15: Memory set/get round-trip
|
|
96
|
-
describe('Property 15: Memory set/get round-trip', () => {
|
|
97
|
-
it('for any key-value pair, get after set returns the written value with a valid updatedAt timestamp', () => {
|
|
98
|
-
// Validates: Requirements 7.1, 7.2, 9.1, 9.2
|
|
99
|
-
fc.assert(
|
|
100
|
-
fc.property(
|
|
101
|
-
fc.tuple(fc.string({ minLength: 1 }), fc.string()),
|
|
102
|
-
([key, value]) => {
|
|
103
|
-
const dir = mkdtempSync(join(tmpdir(), 'pbt-p15-'));
|
|
104
|
-
try {
|
|
105
|
-
const store = new JsonMemoryStore(dir);
|
|
106
|
-
const before = Date.now();
|
|
107
|
-
store.set('proj', key, value);
|
|
108
|
-
const entry = store.get('proj', key);
|
|
109
|
-
|
|
110
|
-
expect(entry).toBeDefined();
|
|
111
|
-
expect(entry!.key).toBe(key);
|
|
112
|
-
expect(entry!.value).toBe(value);
|
|
113
|
-
expect(typeof entry!.updatedAt).toBe('number');
|
|
114
|
-
expect(entry!.updatedAt).toBeGreaterThanOrEqual(before);
|
|
115
|
-
} finally {
|
|
116
|
-
cleanup(dir);
|
|
117
|
-
}
|
|
118
|
-
},
|
|
119
|
-
),
|
|
120
|
-
{ numRuns: 100 },
|
|
121
|
-
);
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
// Feature: persistence, Property 14: Memory list sort order
|
|
126
|
-
describe('Property 14: Memory list sort order', () => {
|
|
127
|
-
it('list returns entries sorted by updatedAt in strictly descending order', async () => {
|
|
128
|
-
// Validates: Requirements 7.4, 9.3
|
|
129
|
-
await fc.assert(
|
|
130
|
-
fc.asyncProperty(
|
|
131
|
-
fc.array(
|
|
132
|
-
fc.tuple(fc.string({ minLength: 1 }), fc.string()),
|
|
133
|
-
{ minLength: 2 },
|
|
134
|
-
),
|
|
135
|
-
async (pairs) => {
|
|
136
|
-
const dir = mkdtempSync(join(tmpdir(), 'pbt-p14-'));
|
|
137
|
-
try {
|
|
138
|
-
const store = new JsonMemoryStore(dir);
|
|
139
|
-
|
|
140
|
-
// Deduplicate keys to ensure distinct entries
|
|
141
|
-
const seen = new Set<string>();
|
|
142
|
-
const uniquePairs: Array<[string, string]> = [];
|
|
143
|
-
for (const [k, v] of pairs) {
|
|
144
|
-
if (!seen.has(k)) {
|
|
145
|
-
seen.add(k);
|
|
146
|
-
uniquePairs.push([k, v]);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Need at least 2 distinct keys to test sort order
|
|
151
|
-
if (uniquePairs.length < 2) return;
|
|
152
|
-
|
|
153
|
-
// Write entries with small delays to ensure distinct updatedAt values
|
|
154
|
-
for (const [key, value] of uniquePairs) {
|
|
155
|
-
store.set('proj', key, value);
|
|
156
|
-
// Introduce a tiny delay to ensure distinct timestamps
|
|
157
|
-
await new Promise((r) => setTimeout(r, 1));
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const entries = store.list('proj');
|
|
161
|
-
expect(entries.length).toBe(uniquePairs.length);
|
|
162
|
-
|
|
163
|
-
// Verify strictly descending order
|
|
164
|
-
for (let i = 1; i < entries.length; i++) {
|
|
165
|
-
expect(entries[i - 1].updatedAt).toBeGreaterThanOrEqual(entries[i].updatedAt);
|
|
166
|
-
}
|
|
167
|
-
} finally {
|
|
168
|
-
cleanup(dir);
|
|
169
|
-
}
|
|
170
|
-
},
|
|
171
|
-
),
|
|
172
|
-
{ numRuns: 50 },
|
|
173
|
-
);
|
|
174
|
-
});
|
|
175
|
-
});
|