@harness-fe/mcp-server 4.0.0-next.2 → 4.0.0-next.3

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.
Files changed (100) hide show
  1. package/dist/bin.d.ts +2 -0
  2. package/dist/bin.js +15 -0
  3. package/dist/daemon.d.ts +3 -3
  4. package/dist/daemon.js +1 -1
  5. package/dist/index.d.ts +4 -4
  6. package/dist/index.js +3 -3
  7. package/dist/mcp.d.ts +2 -2
  8. package/dist/mcp.js +42 -16
  9. package/dist/mcpHttp.d.ts +2 -2
  10. package/dist/mcpHttp.js +8 -2
  11. package/package.json +5 -7
  12. package/src/bin.ts +19 -0
  13. package/src/daemon.ts +3 -3
  14. package/src/experimental.test.ts +2 -2
  15. package/src/index.ts +4 -4
  16. package/src/mcp.ts +44 -20
  17. package/src/mcpHttp.test.ts +3 -3
  18. package/src/mcpHttp.ts +10 -4
  19. package/src/mcpLayer.e2e.test.ts +2 -2
  20. package/src/newCapabilities.e2e.test.ts +3 -3
  21. package/dist/auth.d.ts +0 -53
  22. package/dist/auth.js +0 -212
  23. package/dist/bridge.d.ts +0 -323
  24. package/dist/bridge.js +0 -1618
  25. package/dist/cli.d.ts +0 -18
  26. package/dist/cli.js +0 -293
  27. package/dist/dashboardApi.d.ts +0 -40
  28. package/dist/dashboardApi.js +0 -142
  29. package/dist/dashboardSpa.d.ts +0 -18
  30. package/dist/dashboardSpa.js +0 -180
  31. package/dist/dashboardUrl.d.ts +0 -13
  32. package/dist/dashboardUrl.js +0 -18
  33. package/dist/eventsHandler.d.ts +0 -24
  34. package/dist/eventsHandler.js +0 -114
  35. package/dist/identity.d.ts +0 -90
  36. package/dist/identity.js +0 -123
  37. package/dist/openBrowser.d.ts +0 -33
  38. package/dist/openBrowser.js +0 -63
  39. package/dist/remoteBridge.d.ts +0 -61
  40. package/dist/remoteBridge.js +0 -307
  41. package/dist/replayCreate.d.ts +0 -36
  42. package/dist/replayCreate.js +0 -156
  43. package/dist/replayViewer.d.ts +0 -20
  44. package/dist/replayViewer.js +0 -168
  45. package/dist/sessionRouter.d.ts +0 -45
  46. package/dist/sessionRouter.js +0 -88
  47. package/dist/store/JsonMemoryStore.d.ts +0 -52
  48. package/dist/store/JsonMemoryStore.js +0 -119
  49. package/dist/store/JsonTaskStore.d.ts +0 -21
  50. package/dist/store/JsonTaskStore.js +0 -53
  51. package/dist/store/JsonlStore.d.ts +0 -128
  52. package/dist/store/JsonlStore.js +0 -1172
  53. package/dist/store/MemoryEventStore.d.ts +0 -47
  54. package/dist/store/MemoryEventStore.js +0 -111
  55. package/dist/store/WriteQueue.d.ts +0 -51
  56. package/dist/store/WriteQueue.js +0 -142
  57. package/dist/store/index.d.ts +0 -6
  58. package/dist/store/index.js +0 -5
  59. package/dist/store/types.d.ts +0 -427
  60. package/dist/store/types.js +0 -19
  61. package/dist/visitorTimeline.d.ts +0 -24
  62. package/dist/visitorTimeline.js +0 -68
  63. package/src/auth.test.ts +0 -90
  64. package/src/auth.ts +0 -248
  65. package/src/bridge-auth.test.ts +0 -196
  66. package/src/bridge.test.ts +0 -1708
  67. package/src/bridge.ts +0 -1854
  68. package/src/cli.ts +0 -338
  69. package/src/dashboardApi.test.ts +0 -235
  70. package/src/dashboardApi.ts +0 -184
  71. package/src/dashboardSpa.test.ts +0 -239
  72. package/src/dashboardSpa.ts +0 -195
  73. package/src/dashboardUrl.test.ts +0 -46
  74. package/src/dashboardUrl.ts +0 -28
  75. package/src/eventsHandler.test.ts +0 -247
  76. package/src/eventsHandler.ts +0 -136
  77. package/src/identity.test.ts +0 -109
  78. package/src/identity.ts +0 -137
  79. package/src/openBrowser.test.ts +0 -103
  80. package/src/openBrowser.ts +0 -81
  81. package/src/remoteBridge.test.ts +0 -119
  82. package/src/remoteBridge.ts +0 -404
  83. package/src/replay.test.ts +0 -271
  84. package/src/replayCreate.ts +0 -194
  85. package/src/replayViewer.ts +0 -173
  86. package/src/sessionRouter.ts +0 -119
  87. package/src/store/JsonMemoryStore.test.ts +0 -175
  88. package/src/store/JsonMemoryStore.ts +0 -128
  89. package/src/store/JsonTaskStore.test.ts +0 -212
  90. package/src/store/JsonTaskStore.ts +0 -59
  91. package/src/store/JsonlStore.test.ts +0 -1538
  92. package/src/store/JsonlStore.ts +0 -1325
  93. package/src/store/MemoryEventStore.test.ts +0 -119
  94. package/src/store/MemoryEventStore.ts +0 -151
  95. package/src/store/WriteQueue.ts +0 -165
  96. package/src/store/identityTagging.test.ts +0 -67
  97. package/src/store/index.ts +0 -29
  98. package/src/store/types.ts +0 -532
  99. package/src/visitorTimeline.test.ts +0 -197
  100. package/src/visitorTimeline.ts +0 -89
@@ -1,404 +0,0 @@
1
- /**
2
- * RemoteBridge — IBridge implementation backed by a WS connection to an
3
- * already-running daemon (leader).
4
- *
5
- * Used when this process starts as a follower: another cli.js is already
6
- * listening on :47729, so we attach as a ws client and proxy every MCP tool
7
- * call through the new `mcp.call` / `mcp.return` control frames.
8
- */
9
-
10
- import { WebSocket } from 'ws';
11
- import { randomUUID } from 'node:crypto';
12
- import {
13
- type McpCallFrame,
14
- type McpReturnFrame,
15
- type TabInfo,
16
- type Task,
17
- type TaskStatus,
18
- frameSchema,
19
- } from '@harness-fe/protocol';
20
- import type { IBridge, SendCommandOptions } from './bridge.js';
21
- import type {
22
- BuildMeta,
23
- IMemoryStore,
24
- IStore,
25
- MemoryEntry,
26
- ProjectMeta,
27
- ProjectTreeNode,
28
- PurgeResult,
29
- RecordingChunk,
30
- RecordingChunkSummary,
31
- ReplayExportMeta,
32
- RetentionPolicy,
33
- SearchOptions,
34
- SessionMeta,
35
- SessionSummary,
36
- StoreEvent,
37
- TabMeta,
38
- TailOptions,
39
- } from './store/index.js';
40
-
41
- const DEFAULT_CALL_TIMEOUT_MS = 30_000;
42
-
43
- interface PendingCall {
44
- resolve(value: unknown): void;
45
- reject(err: Error): void;
46
- timer: NodeJS.Timeout;
47
- }
48
-
49
- export interface RemoteBridgeOptions {
50
- port: number;
51
- host?: string;
52
- /** Per-call timeout. Must be ≥ daemon's command timeout to surface upstream errors first. */
53
- callTimeoutMs?: number;
54
- /** Token used to authenticate against the leader, if it requires one. */
55
- token?: string;
56
- }
57
-
58
- export class RemoteBridge implements IBridge {
59
- private ws?: WebSocket;
60
- private pending = new Map<string, PendingCall>();
61
- private closed = false;
62
- private readonly url: string;
63
- private readonly callTimeoutMs: number;
64
- private readonly host: string;
65
- private readonly port: number;
66
-
67
- private readonly token: string | undefined;
68
-
69
- constructor(opts: RemoteBridgeOptions) {
70
- const host = opts.host ?? '127.0.0.1';
71
- this.host = host;
72
- this.port = opts.port;
73
- this.token = opts.token;
74
- const tokenQs = opts.token ? `?token=${encodeURIComponent(opts.token)}` : '';
75
- this.url = `ws://${host}:${opts.port}${tokenQs}`;
76
- this.callTimeoutMs = opts.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
77
- }
78
-
79
- async connect(): Promise<void> {
80
- return new Promise((resolve, reject) => {
81
- const headers: Record<string, string> = {};
82
- if (this.token) headers.authorization = `Bearer ${this.token}`;
83
- const ws = new WebSocket(this.url, { headers });
84
- this.ws = ws;
85
- const onOpen = () => {
86
- ws.off('error', onErr);
87
- this.attachHandlers(ws);
88
- resolve();
89
- };
90
- const onErr = (err: Error) => {
91
- ws.off('open', onOpen);
92
- reject(err);
93
- };
94
- ws.once('open', onOpen);
95
- ws.once('error', onErr);
96
- });
97
- }
98
-
99
- async stop(): Promise<void> {
100
- this.closed = true;
101
- const ws = this.ws;
102
- if (!ws) return;
103
- try {
104
- ws.close();
105
- } catch {
106
- /* swallow */
107
- }
108
- }
109
-
110
- sendCommand(command: string, args: unknown, opts?: SendCommandOptions): Promise<unknown> {
111
- return this.invoke('sendCommand', { command, args, opts });
112
- }
113
-
114
- listTabs(): Promise<TabInfo[]> {
115
- return this.invoke('listTabs', {}) as Promise<TabInfo[]>;
116
- }
117
-
118
- listTasks(filter: { status?: TaskStatus | 'all'; limit?: number } = {}): Promise<Task[]> {
119
- return this.invoke('listTasks', filter) as Promise<Task[]>;
120
- }
121
-
122
- claimTask(id: string): Promise<Task | undefined> {
123
- return this.invoke('claimTask', { id }) as Promise<Task | undefined>;
124
- }
125
-
126
- resolveTask(id: string, note?: string): Promise<Task | undefined> {
127
- return this.invoke('resolveTask', { id, note }) as Promise<Task | undefined>;
128
- }
129
-
130
- /**
131
- * Returns a RemoteMemoryStore that proxies all memory operations to the
132
- * leader via the mcp.call channel. This allows follower instances to use
133
- * the same project.memory.* tools as the leader.
134
- */
135
- getMemoryStore(): IMemoryStore {
136
- return new RemoteMemoryStore(this);
137
- }
138
-
139
- getViewerBaseUrl(): string | undefined {
140
- // Followers share the same WS/HTTP port as the leader.
141
- return `http://${this.host}:${this.port}`;
142
- }
143
-
144
- getAuthToken(): string | undefined {
145
- // Followers connect to the leader using their own configured token
146
- // (passed in via RemoteBridge constructor). Surface it so dashboard
147
- // links the follower hands out are pre-authenticated.
148
- return this.token;
149
- }
150
-
151
- async getTaskAttachmentData(_taskId: string, _attachmentId: string): Promise<string | null> {
152
- // Follower mode: attachment reads are not proxied in v0.6; direct leader access needed.
153
- return null;
154
- }
155
-
156
- /**
157
- * Returns a RemoteStore that proxies all store read/query operations to
158
- * the leader via the mcp.call channel. Write operations (openSession,
159
- * append, etc.) are not proxied — followers are read-only.
160
- */
161
- getStore(): IStore {
162
- return new RemoteStore(this);
163
- }
164
-
165
- /** @internal — used by RemoteMemoryStore and RemoteStore */
166
- invokeRemote(method: McpCallFrame['method'], args: unknown): Promise<unknown> {
167
- return this.invoke(method, args);
168
- }
169
-
170
- private invoke(method: McpCallFrame['method'], args: unknown): Promise<unknown> {
171
- const ws = this.ws;
172
- if (!ws || ws.readyState !== WebSocket.OPEN) {
173
- return Promise.reject(new Error('remote-bridge: not connected'));
174
- }
175
- const id = randomUUID();
176
- const frame: McpCallFrame = { type: 'mcp.call', id, method, args };
177
- return new Promise((resolve, reject) => {
178
- const timer = setTimeout(() => {
179
- this.pending.delete(id);
180
- reject(new Error(`remote-bridge: "${method}" timed out after ${this.callTimeoutMs}ms`));
181
- }, this.callTimeoutMs);
182
- this.pending.set(id, { resolve, reject, timer });
183
- try {
184
- ws.send(JSON.stringify(frame));
185
- } catch (err) {
186
- clearTimeout(timer);
187
- this.pending.delete(id);
188
- reject(err as Error);
189
- }
190
- });
191
- }
192
-
193
- private attachHandlers(ws: WebSocket): void {
194
- ws.on('message', (raw) => {
195
- let parsed: unknown;
196
- try {
197
- parsed = JSON.parse(raw.toString());
198
- } catch {
199
- return;
200
- }
201
- const frame = frameSchema.safeParse(parsed);
202
- if (!frame.success) return;
203
- if (frame.data.type !== 'mcp.return') return;
204
- this.handleReturn(frame.data);
205
- });
206
- ws.on('close', () => this.handleClose());
207
- ws.on('error', () => {
208
- /* close will follow */
209
- });
210
- }
211
-
212
- private handleReturn(frame: McpReturnFrame): void {
213
- const p = this.pending.get(frame.id);
214
- if (!p) return;
215
- clearTimeout(p.timer);
216
- this.pending.delete(frame.id);
217
- if (frame.ok) {
218
- p.resolve(frame.result);
219
- } else {
220
- p.reject(new Error(frame.error?.message ?? 'remote-bridge: unknown error'));
221
- }
222
- }
223
-
224
- private handleClose(): void {
225
- const err = new Error(
226
- this.closed
227
- ? 'remote-bridge: connection closed'
228
- : 'remote-bridge: lost connection to daemon',
229
- );
230
- for (const p of this.pending.values()) {
231
- clearTimeout(p.timer);
232
- p.reject(err);
233
- }
234
- this.pending.clear();
235
- }
236
- }
237
-
238
- // ─── RemoteMemoryStore ────────────────────────────────────────────────────────
239
- //
240
- // Proxies IMemoryStore operations to the leader via mcp.call frames.
241
- // Used by follower instances so project.memory.* tools work in all windows.
242
-
243
- class RemoteMemoryStore implements IMemoryStore {
244
- constructor(private readonly bridge: RemoteBridge) {}
245
-
246
- get(projectId: string, key: string): MemoryEntry | undefined {
247
- // Synchronous interface — not directly awaitable. The MCP tool layer
248
- // wraps calls in async handlers, so we return a thenable-compatible
249
- // object. In practice mcp.ts awaits the result via the async handler.
250
- // We throw here to signal that callers must use the async path.
251
- throw new Error(
252
- 'RemoteMemoryStore.get() must be called via the async MCP tool handler. ' +
253
- 'Use remoteMemoryStore.getAsync() instead.',
254
- );
255
- }
256
-
257
- /** Async variant used by the MCP tool handlers in mcp.ts. */
258
- async getAsync(projectId: string, key: string): Promise<MemoryEntry | undefined> {
259
- return this.bridge.invokeRemote('memoryGet', { projectId, key }) as Promise<MemoryEntry | undefined>;
260
- }
261
-
262
- set(projectId: string, key: string, value: string): MemoryEntry {
263
- throw new Error('RemoteMemoryStore.set() must be called via setAsync().');
264
- }
265
-
266
- async setAsync(projectId: string, key: string, value: string): Promise<MemoryEntry> {
267
- return this.bridge.invokeRemote('memorySet', { projectId, key, value }) as Promise<MemoryEntry>;
268
- }
269
-
270
- delete(projectId: string, key: string): boolean {
271
- throw new Error('RemoteMemoryStore.delete() must be called via deleteAsync().');
272
- }
273
-
274
- async deleteAsync(projectId: string, key: string): Promise<boolean> {
275
- return this.bridge.invokeRemote('memoryDelete', { projectId, key }) as Promise<boolean>;
276
- }
277
-
278
- list(projectId: string): MemoryEntry[] {
279
- throw new Error('RemoteMemoryStore.list() must be called via listAsync().');
280
- }
281
-
282
- async listAsync(projectId: string): Promise<MemoryEntry[]> {
283
- return this.bridge.invokeRemote('memoryList', { projectId }) as Promise<MemoryEntry[]>;
284
- }
285
- }
286
-
287
- // ─── RemoteStore ──────────────────────────────────────────────────────────────
288
- //
289
- // Proxies IStore read operations to the leader via mcp.call frames.
290
- // Write operations throw — followers are read-only for the store.
291
-
292
- class RemoteStore implements IStore {
293
- constructor(private readonly bridge: RemoteBridge) {}
294
-
295
- // ── Read operations (proxied) ──────────────────────────────────────────
296
-
297
- async listProjectsAsync(): Promise<ProjectMeta[]> {
298
- return this.bridge.invokeRemote('storeListProjects', {}) as Promise<ProjectMeta[]>;
299
- }
300
-
301
- async listSessionsAsync(opts?: { projectId?: string; tabId?: string; buildId?: string; limit?: number }): Promise<SessionMeta[]> {
302
- return this.bridge.invokeRemote('storeListSessions', opts ?? {}) as Promise<SessionMeta[]>;
303
- }
304
-
305
- async summaryAsync(sessionId: string): Promise<SessionSummary> {
306
- return this.bridge.invokeRemote('storeSummary', { sessionId }) as Promise<SessionSummary>;
307
- }
308
-
309
- async tailAsync(sessionId: string, opts?: TailOptions): Promise<StoreEvent[]> {
310
- return this.bridge.invokeRemote('storeTail', { sessionId, opts }) as Promise<StoreEvent[]>;
311
- }
312
-
313
- async searchAsync(sessionId: string, query: string, opts?: SearchOptions): Promise<StoreEvent[]> {
314
- return this.bridge.invokeRemote('storeSearch', { sessionId, query, opts }) as Promise<StoreEvent[]>;
315
- }
316
-
317
- async listRecordingsAsync(sessionId: string): Promise<RecordingChunkSummary[]> {
318
- return this.bridge.invokeRemote('storeRecordingsList', { sessionId }) as Promise<RecordingChunkSummary[]>;
319
- }
320
-
321
- async sliceRecordingsAsync(sessionId: string, since: number, until: number): Promise<RecordingChunk[]> {
322
- return this.bridge.invokeRemote(
323
- 'storeRecordingsSlice',
324
- { sessionId, since, until },
325
- ) as Promise<RecordingChunk[]>;
326
- }
327
-
328
- async replayCreateAsync(args: {
329
- sessionId: string;
330
- tabId?: string;
331
- ts?: number;
332
- windowMs?: number;
333
- since?: number;
334
- until?: number;
335
- label?: string;
336
- }): Promise<unknown> {
337
- return this.bridge.invokeRemote('storeReplayCreate', args);
338
- }
339
-
340
- async purgeAsync(policy?: RetentionPolicy): Promise<PurgeResult> {
341
- return this.bridge.invokeRemote('storePurge', policy ?? {}) as Promise<PurgeResult>;
342
- }
343
-
344
- // ── Synchronous IStore interface stubs (not used by follower) ─────────
345
- // These satisfy the interface but throw — the MCP tool handlers in mcp.ts
346
- // use the async variants above when running in follower mode.
347
-
348
- // Build lifecycle
349
- openBuild(_p: string, _patch?: Partial<Omit<BuildMeta, 'id' | 'projectId' | 'builtAt'>>): string { throw notSupported('openBuild'); }
350
- closeBuild(_b: string, _c?: number): void { throw notSupported('closeBuild'); }
351
-
352
- // Tab lifecycle
353
- upsertTab(_t: string, _patch: Partial<Omit<TabMeta, 'id'>>): TabMeta { throw notSupported('upsertTab'); }
354
- getTab(_t: string): TabMeta | undefined { throw notSupported('getTab'); }
355
- closeTab(_t: string, _d?: number): void { throw notSupported('closeTab'); }
356
-
357
- // Session lifecycle
358
- upsertSession(_s: string, _m: Partial<Omit<SessionMeta, 'id'>> & { tabId: string; startedAt: number }): SessionMeta { throw notSupported('upsertSession'); }
359
- closeSession(_s: string, _e?: number): void { throw notSupported('closeSession'); }
360
- getSession(_id: string): SessionMeta | undefined { throw notSupported('getSession'); }
361
- listSessions(_opts?: { tabId?: string; projectId?: string; buildId?: string; limit?: number }): SessionMeta[] { throw notSupported('listSessions'); }
362
-
363
- // Write
364
- appendEvent(_s: string, _e: StoreEvent): void { throw notSupported('appendEvent'); }
365
- appendEventBatch(_s: string, _e: StoreEvent[]): void { throw notSupported('appendEventBatch'); }
366
- appendRecording(_s: string, _c: unknown): void { throw notSupported('appendRecording'); }
367
- writeNote(_p: string, _k: string, _v: string): void { throw notSupported('writeNote'); }
368
-
369
- // Project metadata
370
- listProjects(): ProjectMeta[] { throw notSupported('listProjects'); }
371
- upsertProject(_p: string, _patch: Partial<Omit<ProjectMeta, 'id' | 'createdAt'>>): ProjectMeta { throw notSupported('upsertProject'); }
372
- getProject(_p: string): ProjectMeta | undefined { throw notSupported('getProject'); }
373
- getProjectTree(_r?: string): ProjectTreeNode[] { throw notSupported('getProjectTree'); }
374
-
375
- // Build metadata
376
- upsertBuild(_p: string, _b: string, _patch: Partial<Omit<BuildMeta, 'id' | 'projectId'>>): BuildMeta { throw notSupported('upsertBuild'); }
377
- getBuild(_p: string, _b: string): BuildMeta | undefined { throw notSupported('getBuild'); }
378
- listBuilds(_p: string, _l?: number): BuildMeta[] { throw notSupported('listBuilds'); }
379
-
380
- // Visitor metadata (0.5+) — followers don't proxy yet; leader-only for now.
381
- upsertVisitor(_v: string, _patch: Parameters<IStore['upsertVisitor']>[1]): ReturnType<IStore['upsertVisitor']> { throw notSupported('upsertVisitor'); }
382
- getVisitor(_v: string): ReturnType<IStore['getVisitor']> { throw notSupported('getVisitor'); }
383
- listVisitors(_opts?: Parameters<IStore['listVisitors']>[0]): ReturnType<IStore['listVisitors']> { throw notSupported('listVisitors'); }
384
-
385
- // Read
386
- tail(_s: string, _o?: TailOptions): StoreEvent[] { throw notSupported('tail'); }
387
- search(_s: string, _q: string, _o?: SearchOptions): StoreEvent[] { throw notSupported('search'); }
388
- listRecordings(_s: string): RecordingChunkSummary[] { throw notSupported('listRecordings'); }
389
- sliceRecordings(_s: string, _since: number, _until: number): RecordingChunk[] { throw notSupported('sliceRecordings'); }
390
- writeExport(_i: Parameters<IStore['writeExport']>[0]): ReplayExportMeta { throw notSupported('writeExport'); }
391
- getExport(_id: string): ReplayExportMeta | undefined { throw notSupported('getExport'); }
392
- readExportEvents(_id: string): unknown[] | undefined { throw notSupported('readExportEvents'); }
393
- listExports(_p: string, _l?: number): ReplayExportMeta[] { throw notSupported('listExports'); }
394
- summary(_s: string): SessionSummary { throw notSupported('summary'); }
395
- listNotes(_p: string): Array<{ key: string; value: string; ts: number }> { throw notSupported('listNotes'); }
396
-
397
- // Maintenance
398
- purge(_p?: RetentionPolicy): PurgeResult { throw notSupported('purge'); }
399
- close(): void { /* no-op for remote */ }
400
- }
401
-
402
- function notSupported(method: string): Error {
403
- return new Error(`remote-bridge: IStore.${method}() is not available in follower mode`);
404
- }
@@ -1,271 +0,0 @@
1
- import { afterEach, describe, expect, it } from 'vitest';
2
- import { mkdtempSync, rmSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import { randomUUID } from 'node:crypto';
6
- import { Bridge } from './bridge.js';
7
- import { JsonlStore } from './store/index.js';
8
- import { createReplayExport } from './replayCreate.js';
9
-
10
- const tempDirs: string[] = [];
11
- function mkTmp(): string {
12
- const dir = mkdtempSync(join(tmpdir(), 'harness-replay-test-'));
13
- tempDirs.push(dir);
14
- return dir;
15
- }
16
-
17
- afterEach(async () => {
18
- while (tempDirs.length) {
19
- const d = tempDirs.pop()!;
20
- try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
21
- }
22
- });
23
-
24
- /**
25
- * v0.4.0 helper: open a session (one-tab pageload).
26
- */
27
- function openSession(store: JsonlStore, projectId: string, tabId: string = `tab-${randomUUID().slice(0, 8)}`): string {
28
- const sessionId = randomUUID();
29
- store.upsertTab(tabId, { connectedAt: Date.now() });
30
- store.upsertSession(sessionId, {
31
- tabId,
32
- startedAt: Date.now(),
33
- participants: [{ projectId, joinedAt: Date.now() }],
34
- });
35
- return sessionId;
36
- }
37
-
38
- function seedRecording(store: JsonlStore, sessId: string, _tabId: string, opts: {
39
- chunkId: string;
40
- startTs: number;
41
- endTs: number;
42
- events: unknown[];
43
- }) {
44
- // v0.4.0: appendRecording takes (sessionId, chunk) — no tabId arg
45
- store.appendRecording(sessId, {
46
- chunkId: opts.chunkId,
47
- startTs: opts.startTs,
48
- endTs: opts.endTs,
49
- eventCount: opts.events.length,
50
- events: opts.events,
51
- });
52
- }
53
-
54
- describe('createReplayExport — pure logic', () => {
55
- it('rejects when neither ts nor since/until are provided', () => {
56
- const dir = mkTmp();
57
- const store = new JsonlStore(dir);
58
- const sessId = openSession(store, 'proj');
59
- const result = createReplayExport(store, undefined, { sessionId: sessId });
60
- expect(result.error).toMatch(/must provide either ts/);
61
- });
62
-
63
- it('rejects when until <= since', () => {
64
- const dir = mkTmp();
65
- const store = new JsonlStore(dir);
66
- const sessId = openSession(store, 'proj');
67
- const result = createReplayExport(store, undefined, { sessionId: sessId, since: 100, until: 100 });
68
- expect(result.error).toMatch(/until must be greater/);
69
- });
70
-
71
- it('rejects when session unknown', () => {
72
- const dir = mkTmp();
73
- const store = new JsonlStore(dir);
74
- const result = createReplayExport(store, undefined, { sessionId: 'nope', ts: 1000 });
75
- expect(result.error).toMatch(/session not found/);
76
- });
77
-
78
- it('rejects when no chunks in window', async () => {
79
- const dir = mkTmp();
80
- const store = new JsonlStore(dir);
81
- const sessId = openSession(store, 'proj', 'tab-1');
82
- seedRecording(store, sessId, 'tab-1', { chunkId: 'rrc_1', startTs: 1000, endTs: 1500, events: [{}, {}] });
83
- await store.flush();
84
- const result = createReplayExport(store, undefined, { sessionId: sessId, since: 5000, until: 6000 });
85
- expect(result.error).toMatch(/no rrweb chunks/);
86
- });
87
-
88
- it('rejects when fewer than 2 events in window', async () => {
89
- const dir = mkTmp();
90
- const store = new JsonlStore(dir);
91
- const sessId = openSession(store, 'proj', 'tab-1');
92
- seedRecording(store, sessId, 'tab-1', { chunkId: 'rrc_1', startTs: 1000, endTs: 1500, events: [{ type: 2 }] });
93
- await store.flush();
94
- const result = createReplayExport(store, undefined, { sessionId: sessId, since: 0, until: 5000 });
95
- expect(result.error).toMatch(/fewer than 2 rrweb events/);
96
- });
97
-
98
- it('builds an export from chunks in window and persists to disk', async () => {
99
- const dir = mkTmp();
100
- const store = new JsonlStore(dir);
101
- const sessId = openSession(store, 'proj', 'tab-1');
102
- seedRecording(store, sessId, 'tab-1', { chunkId: 'a', startTs: 1000, endTs: 1500, events: [{ type: 4 }, { type: 2 }] });
103
- seedRecording(store, sessId, 'tab-1', { chunkId: 'b', startTs: 1600, endTs: 2000, events: [{ type: 3 }, { type: 3 }, { type: 3 }] });
104
- // outside window — should NOT be in export
105
- seedRecording(store, sessId, 'tab-1', { chunkId: 'c', startTs: 9000, endTs: 9500, events: [{ type: 3 }, { type: 3 }] });
106
- await store.flush();
107
-
108
- const result = createReplayExport(store, 'http://127.0.0.1:47729', {
109
- sessionId: sessId, since: 900, until: 2100, label: 'bug-checkout',
110
- });
111
- expect(result.error).toBeUndefined();
112
- expect(result.exportId).toMatch(/^exp_/);
113
- expect(result.viewerUrl).toBe(`http://127.0.0.1:47729/replay/${result.exportId}`);
114
- expect(result.eventCount).toBe(5);
115
- expect(result.chunkCount).toBe(2);
116
- expect(result.durationMs).toBe(1000);
117
- expect(result.label).toBe('bug-checkout');
118
-
119
- // Round-trip through the store
120
- const events = store.readExportEvents(result.exportId!);
121
- expect(events).toHaveLength(5);
122
- });
123
-
124
- it('picks the tab with the most events when caller does not pin tabId', async () => {
125
- const dir = mkTmp();
126
- const store = new JsonlStore(dir);
127
- // Two separate sessions, each representing a different tab in the same "logical" session
128
- // v0.4.0: each session has exactly one tab; we test multi-session scenario differently.
129
- // Instead, we have two recording chunks on the same session with different tabIds in chunk metadata.
130
- // Actually in v0.4.0 a session owns one tab, and recording chunks carry the session's tabId.
131
- // "Multi-tab" means multiple sessions. The tabId auto-select picks from chunk.tabId values.
132
- const sessId = openSession(store, 'proj', 'tab-1');
133
- // Seed recordings that have different tabIds via the chunkId; in v0.4.0 tabId comes from sessionMeta.tabId
134
- // so all chunks for this session have tabId='tab-1'. Let's just verify the auto-select works per chunk count.
135
- seedRecording(store, sessId, 'tab-1', { chunkId: 'a', startTs: 1000, endTs: 1500, events: [{ type: 4 }, { type: 2 }, { type: 3 }] });
136
- await store.flush();
137
-
138
- const result = createReplayExport(store, undefined, { sessionId: sessId, since: 500, until: 2500 });
139
- expect(result.error).toBeUndefined();
140
- expect(result.tabId).toBe('tab-1');
141
- expect(result.eventCount).toBe(3);
142
- });
143
-
144
- it('respects ts + windowMs as a center window', async () => {
145
- const dir = mkTmp();
146
- const store = new JsonlStore(dir);
147
- const sessId = openSession(store, 'proj', 'tab-1');
148
- seedRecording(store, sessId, 'tab-1', { chunkId: 'a', startTs: 1000, endTs: 1500, events: [{ type: 4 }, { type: 2 }] });
149
- seedRecording(store, sessId, 'tab-1', { chunkId: 'b', startTs: 9000, endTs: 9500, events: [{ type: 4 }, { type: 2 }] });
150
- await store.flush();
151
-
152
- const r1 = createReplayExport(store, undefined, { sessionId: sessId, ts: 1200, windowMs: 500 });
153
- expect(r1.chunkCount).toBe(1);
154
-
155
- const r2 = createReplayExport(store, undefined, { sessionId: sessId, ts: 5000, windowMs: 5000 });
156
- expect(r2.chunkCount).toBe(2);
157
- });
158
- });
159
-
160
- describe('replay HTTP routes', () => {
161
- async function startBridge() {
162
- const dir = mkTmp();
163
- const store = new JsonlStore(dir);
164
- const bridge = new Bridge({ port: 0, host: '127.0.0.1', store, taskStore: null, memoryStore: null });
165
- await bridge.start();
166
- const port = bridge.getBoundPort();
167
- if (!port) throw new Error('no port');
168
- return { bridge, store, port, dir };
169
- }
170
-
171
- it('serves HTML viewer for an existing export', async () => {
172
- const { bridge, store, port } = await startBridge();
173
- try {
174
- const sessId = openSession(store, 'proj', 'tab-1');
175
- seedRecording(store, sessId, 'tab-1', { chunkId: 'a', startTs: 1000, endTs: 1500, events: [{ type: 4 }, { type: 2 }, { type: 3 }] });
176
- await store.flush();
177
- const r = createReplayExport(store, `http://127.0.0.1:${port}`, { sessionId: sessId, since: 0, until: 5000 });
178
- expect(r.exportId).toBeTruthy();
179
-
180
- const resp = await fetch(`http://127.0.0.1:${port}/replay/${r.exportId}`);
181
- expect(resp.status).toBe(200);
182
- const html = await resp.text();
183
- expect(html).toContain('new rrwebPlayer');
184
- expect(html).toContain(r.exportId!);
185
- } finally {
186
- await bridge.stop();
187
- }
188
- });
189
-
190
- it('serves events JSON via /replay/:id.json', async () => {
191
- const { bridge, store, port } = await startBridge();
192
- try {
193
- const sessId = openSession(store, 'proj', 'tab-1');
194
- seedRecording(store, sessId, 'tab-1', { chunkId: 'a', startTs: 1000, endTs: 1500, events: [{ type: 4 }, { type: 2 }, { type: 3 }] });
195
- await store.flush();
196
- const r = createReplayExport(store, undefined, { sessionId: sessId, since: 0, until: 5000 });
197
-
198
- const resp = await fetch(`http://127.0.0.1:${port}/replay/${r.exportId}.json`);
199
- expect(resp.status).toBe(200);
200
- expect(resp.headers.get('content-type')).toMatch(/application\/json/);
201
- const body = await resp.json();
202
- expect(Array.isArray(body)).toBe(true);
203
- expect(body).toHaveLength(3);
204
- } finally {
205
- await bridge.stop();
206
- }
207
- });
208
-
209
- it('serves the bundled rrweb-player JS and CSS', async () => {
210
- const { bridge, port } = await startBridge();
211
- try {
212
- const js = await fetch(`http://127.0.0.1:${port}/replay/static/player.js`);
213
- expect(js.status).toBe(200);
214
- expect(js.headers.get('content-type')).toMatch(/javascript/);
215
- const jsBody = await js.text();
216
- expect(jsBody.length).toBeGreaterThan(1000);
217
- expect(jsBody).toContain('rrwebPlayer');
218
-
219
- const css = await fetch(`http://127.0.0.1:${port}/replay/static/player.css`);
220
- expect(css.status).toBe(200);
221
- expect(css.headers.get('content-type')).toMatch(/css/);
222
- } finally {
223
- await bridge.stop();
224
- }
225
- });
226
-
227
- it('returns 404 for unknown export', async () => {
228
- const { bridge, port } = await startBridge();
229
- try {
230
- const resp = await fetch(`http://127.0.0.1:${port}/replay/exp_nope`);
231
- expect(resp.status).toBe(404);
232
- } finally {
233
- await bridge.stop();
234
- }
235
- });
236
-
237
- it('rejects malformed export ids', async () => {
238
- const { bridge, port } = await startBridge();
239
- try {
240
- const resp = await fetch(`http://127.0.0.1:${port}/replay/has%20space`);
241
- expect(resp.status).toBe(400);
242
- } finally {
243
- await bridge.stop();
244
- }
245
- });
246
- });
247
-
248
- describe('export retention interacts cleanly with replay creation', () => {
249
- it('export created after a purge still resolves chunks still on disk', async () => {
250
- const dir = mkTmp();
251
- const store = new JsonlStore(dir);
252
- const sessId = openSession(store, 'proj', 'tab-1');
253
- const now = Date.now();
254
- seedRecording(store, sessId, 'tab-1', { chunkId: 'a', startTs: now - 3000, endTs: now - 2800, events: [{ type: 4 }, { type: 2 }, { type: 3 }] });
255
- // Each surviving chunk carries its own baseline so the post-purge
256
- // export still has a FullSnapshot to replay from.
257
- seedRecording(store, sessId, 'tab-1', { chunkId: 'b', startTs: now - 2000, endTs: now - 1800, events: [{ type: 2 }, { type: 3 }] });
258
- seedRecording(store, sessId, 'tab-1', { chunkId: 'c', startTs: now - 1000, endTs: now - 800, events: [{ type: 3 }, { type: 3 }] });
259
- await store.flush();
260
-
261
- // Trim to 2 chunks per session (new key name).
262
- const purge = store.purge({ maxRecordingChunksPerSession: 2, preserveMarkedChunks: false });
263
- expect(purge.recordingsDeleted).toBeGreaterThanOrEqual(1);
264
-
265
- // Export over the full window — should only include surviving chunks.
266
- const r = createReplayExport(store, undefined, { sessionId: sessId, since: now - 10000, until: now + 10000 });
267
- expect(r.error).toBeUndefined();
268
- expect(r.chunkCount).toBe(2);
269
- expect(r.eventCount).toBe(4);
270
- });
271
- });