@harness-fe/mcp-server 4.0.0-next.1 → 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 +49 -15
  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 +51 -19
  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 -74
  36. package/dist/identity.js +0 -101
  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 -86
  78. package/src/identity.ts +0 -116
  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,1538 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import * as fc from 'fast-check';
6
- import { randomUUID } from 'node:crypto';
7
- import { JsonlStore, sanitizeId } from './JsonlStore.js';
8
- import { WriteQueue } from './WriteQueue.js';
9
-
10
- /** Helper: create a fresh store + temp dir */
11
- function makeStore() {
12
- const dir = mkdtempSync(join(tmpdir(), 'harness-store-test-'));
13
- const store = new JsonlStore(dir);
14
- return { store, dir };
15
- }
16
-
17
- function cleanup(dir: string) {
18
- try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
19
- }
20
-
21
- /**
22
- * Helper: open a session the v0.4.0 way.
23
- * Returns a sessionId and the tabId used so callers can reference them.
24
- */
25
- function openSession(store: JsonlStore, projectId: string, tabId?: string): { sessionId: string; tabId: string } {
26
- const resolvedTabId = tabId ?? `tab-${randomUUID().slice(0, 8)}`;
27
- const sessionId = randomUUID();
28
- store.upsertTab(resolvedTabId, { connectedAt: Date.now() });
29
- store.upsertSession(sessionId, {
30
- tabId: resolvedTabId,
31
- startedAt: Date.now(),
32
- participants: [{ projectId, joinedAt: Date.now() }],
33
- });
34
- return { sessionId, tabId: resolvedTabId };
35
- }
36
-
37
- describe('JsonlStore', () => {
38
- let dir: string;
39
- let store: JsonlStore;
40
-
41
- beforeEach(() => {
42
- ({ store, dir } = makeStore());
43
- });
44
-
45
- afterEach(async () => {
46
- await store.close();
47
- cleanup(dir);
48
- });
49
-
50
- // ── Session lifecycle ────────────────────────────────────────────────
51
-
52
- it('upsertSession returns a SessionMeta with matching id', () => {
53
- const { sessionId } = openSession(store, 'my-project');
54
- const meta = store.getSession(sessionId);
55
- expect(meta).toBeDefined();
56
- expect(meta!.id).toBe(sessionId);
57
- });
58
-
59
- it('lists projects after opening a build', () => {
60
- store.openBuild('proj-a', { bundler: 'vite' });
61
- store.openBuild('proj-b', { bundler: 'webpack' });
62
- const projects = store.listProjects();
63
- expect(projects.map((p) => p.id)).toContain('proj-a');
64
- expect(projects.map((p) => p.id)).toContain('proj-b');
65
- });
66
-
67
- it('lists sessions for a project', () => {
68
- const { sessionId: s1 } = openSession(store, 'proj');
69
- const { sessionId: s2 } = openSession(store, 'proj');
70
- const sessions = store.listSessions({ projectId: 'proj' });
71
- const ids = sessions.map((s) => s.id);
72
- expect(ids).toContain(s1);
73
- expect(ids).toContain(s2);
74
- });
75
-
76
- it('closes a session and records endedAt', () => {
77
- const { sessionId } = openSession(store, 'proj');
78
- store.closeSession(sessionId);
79
- const meta = store.getSession(sessionId);
80
- expect(meta?.endedAt).toBeDefined();
81
- expect(meta!.endedAt!).toBeGreaterThan(0);
82
- });
83
-
84
- it('upsertTab and closeTab roundtrip', () => {
85
- store.upsertTab('tab-1', { connectedAt: Date.now(), userAgent: 'Chrome' });
86
- store.closeTab('tab-1');
87
- const meta = store.getTab('tab-1');
88
- expect(meta?.disconnectedAt).toBeDefined();
89
- });
90
-
91
- // ── Write + tail ─────────────────────────────────────────────────────
92
-
93
- it('appends events and tails them back', async () => {
94
- const { sessionId } = openSession(store, 'proj');
95
- store.appendEvent(sessionId, { ts: 1000, t: 'log', d: { level: 'info', args: ['hello'] } });
96
- store.appendEvent(sessionId, { ts: 2000, t: 'err', d: { message: 'boom' } });
97
- store.appendEvent(sessionId, { ts: 3000, t: 'hmr', d: { file: 'App.tsx' } });
98
-
99
- await store.flush();
100
- const events = store.tail(sessionId);
101
- expect(events).toHaveLength(3);
102
- expect(events[0].t).toBe('log');
103
- expect(events[2].t).toBe('hmr');
104
- });
105
-
106
- it('tail filters by type', async () => {
107
- const { sessionId } = openSession(store, 'proj');
108
- store.appendEvent(sessionId, { ts: 1000, t: 'log', d: {} });
109
- store.appendEvent(sessionId, { ts: 2000, t: 'err', d: { message: 'oops' } });
110
- store.appendEvent(sessionId, { ts: 3000, t: 'log', d: {} });
111
-
112
- await store.flush();
113
- const errors = store.tail(sessionId, { type: 'err' });
114
- expect(errors).toHaveLength(1);
115
- expect(errors[0].t).toBe('err');
116
- });
117
-
118
- it('tail filters by multiple types', async () => {
119
- const { sessionId } = openSession(store, 'proj');
120
- store.appendEvent(sessionId, { ts: 1000, t: 'log', d: {} });
121
- store.appendEvent(sessionId, { ts: 2000, t: 'err', d: {} });
122
- store.appendEvent(sessionId, { ts: 3000, t: 'hmr', d: {} });
123
- store.appendEvent(sessionId, { ts: 4000, t: 'cmd', d: {} });
124
-
125
- await store.flush();
126
- const result = store.tail(sessionId, { type: ['err', 'hmr'] });
127
- expect(result).toHaveLength(2);
128
- expect(result.map((e) => e.t).sort()).toEqual(['err', 'hmr']);
129
- });
130
-
131
- it('tail respects n limit', async () => {
132
- const { sessionId } = openSession(store, 'proj');
133
- for (let i = 0; i < 20; i++) {
134
- store.appendEvent(sessionId, { ts: i * 100, t: 'log', d: { i } });
135
- }
136
- await store.flush();
137
- const result = store.tail(sessionId, { n: 5 });
138
- expect(result).toHaveLength(5);
139
- // Should be the last 5
140
- expect((result[4].d as { i: number }).i).toBe(19);
141
- });
142
-
143
- it('appendEventBatch writes all events', async () => {
144
- const { sessionId } = openSession(store, 'proj');
145
- store.appendEventBatch(sessionId, [
146
- { ts: 1000, t: 'log', d: { msg: 'a' } },
147
- { ts: 2000, t: 'log', d: { msg: 'b' } },
148
- { ts: 3000, t: 'err', d: { message: 'c' } },
149
- ]);
150
- await store.flush();
151
- const events = store.tail(sessionId);
152
- expect(events).toHaveLength(3);
153
- });
154
-
155
- it('appendEvent drops oversized events silently', async () => {
156
- const { sessionId } = openSession(store, 'proj');
157
- // 300 KB — above the 256 KB per-event ceiling
158
- const huge = 'A'.repeat(300 * 1024);
159
- store.appendEvent(sessionId, { ts: Date.now(), t: 'log', d: { msg: huge } });
160
- await store.flush();
161
- expect(store.tail(sessionId)).toHaveLength(0);
162
- });
163
-
164
- it('appendRecording drops chunks larger than the rrweb byte limit', async () => {
165
- const { sessionId } = openSession(store, 'proj');
166
- // 3 MB chunk — above the 2 MB ceiling.
167
- const fatChunk = {
168
- chunkId: 'c1',
169
- startTs: 1,
170
- endTs: 2,
171
- eventCount: 1,
172
- events: [{ blob: 'B'.repeat(3 * 1024 * 1024) }],
173
- };
174
- store.appendRecording(sessionId, fatChunk);
175
- await store.flush();
176
- expect(store.listRecordings(sessionId)).toHaveLength(0);
177
- });
178
-
179
- it('appends rrweb recording chunks', async () => {
180
- const { sessionId } = openSession(store, 'proj');
181
- store.appendRecording(sessionId, {
182
- chunkId: 'c1', startTs: 1000, endTs: 1200, eventCount: 2,
183
- events: [{ type: 4, data: {} }, { type: 3, data: {} }],
184
- });
185
- store.appendRecording(sessionId, {
186
- chunkId: 'c2', startTs: 2000, endTs: 2100, eventCount: 1,
187
- events: [{ type: 3, data: {} }],
188
- });
189
- await store.flush();
190
- const recordings = store.listRecordings(sessionId);
191
- expect(recordings).toHaveLength(2);
192
- });
193
-
194
- it('lists recording chunks in chronological order', async () => {
195
- const { sessionId } = openSession(store, 'proj', 'tab-1');
196
- store.appendRecording(sessionId, {
197
- chunkId: 'rrc_1',
198
- startTs: 1000,
199
- endTs: 1500,
200
- eventCount: 2,
201
- events: [{ type: 4 }, { type: 3 }],
202
- });
203
- store.appendRecording(sessionId, {
204
- chunkId: 'rrc_2',
205
- startTs: 2000,
206
- endTs: 2500,
207
- eventCount: 1,
208
- events: [{ type: 3 }],
209
- });
210
-
211
- await store.flush();
212
- const all = store.listRecordings(sessionId);
213
- expect(all).toHaveLength(2);
214
- expect(all[0].chunkId).toBe('rrc_1');
215
- expect(all[1].chunkId).toBe('rrc_2');
216
- // tabId is derived from sessionMeta.tabId
217
- expect(all[0].tabId).toBe('tab-1');
218
- });
219
-
220
- it('slices recording chunks by overlapping time window', async () => {
221
- const { sessionId } = openSession(store, 'proj', 'tab-1');
222
- store.appendRecording(sessionId, {
223
- chunkId: 'rrc_1',
224
- startTs: 1000,
225
- endTs: 1500,
226
- eventCount: 2,
227
- events: [{ type: 4 }, { type: 3 }],
228
- });
229
- store.appendRecording(sessionId, {
230
- chunkId: 'rrc_2',
231
- startTs: 2000,
232
- endTs: 2500,
233
- eventCount: 1,
234
- events: [{ type: 3 }],
235
- });
236
-
237
- await store.flush();
238
- const slice = store.sliceRecordings(sessionId, 1200, 2100);
239
- expect(slice).toHaveLength(2);
240
- expect(slice.map((chunk) => chunk.chunkId)).toEqual(['rrc_1', 'rrc_2']);
241
- expect(slice[0].events).toHaveLength(2);
242
- expect(store.sliceRecordings(sessionId, 2600, 3000)).toEqual([]);
243
- });
244
-
245
- it('purge trims recording chunks by per-session count limit', async () => {
246
- const { sessionId } = openSession(store, 'proj', 'tab-1');
247
- store.appendRecording(sessionId, {
248
- chunkId: 'rrc_1',
249
- startTs: Date.now() - 1000,
250
- endTs: Date.now() - 900,
251
- eventCount: 1,
252
- events: [{ type: 4 }],
253
- });
254
- store.appendRecording(sessionId, {
255
- chunkId: 'rrc_2',
256
- startTs: Date.now() - 800,
257
- endTs: Date.now() - 700,
258
- eventCount: 1,
259
- events: [{ type: 4 }],
260
- });
261
- store.appendRecording(sessionId, {
262
- chunkId: 'rrc_3',
263
- startTs: Date.now() - 600,
264
- endTs: Date.now() - 500,
265
- eventCount: 1,
266
- events: [{ type: 4 }],
267
- });
268
-
269
- await store.flush();
270
- const result = store.purge({
271
- maxAgeDays: 7,
272
- maxSessions: 20,
273
- recordingRetentionDays: 7,
274
- maxRecordingChunksPerSession: 2,
275
- });
276
-
277
- expect(result.recordingsDeleted).toBe(1);
278
- // Only the 2 newest chunks should remain
279
- const remaining = store.listRecordings(sessionId).map((chunk) => chunk.chunkId);
280
- expect(remaining).toEqual(['rrc_2', 'rrc_3']);
281
- });
282
-
283
- it('purge prefers keeping marked chunks when configured', async () => {
284
- const { sessionId } = openSession(store, 'proj', 'tab-1');
285
- const now = Date.now();
286
- // Marker event overlaps rrc_2
287
- store.appendEvent(sessionId, {
288
- ts: now - 450,
289
- t: 'rrweb:marker',
290
- d: { markerId: 'rrm_1', kind: 'error', label: 'boom' },
291
- });
292
- store.appendRecording(sessionId, {
293
- chunkId: 'rrc_1',
294
- startTs: now - 1000,
295
- endTs: now - 900,
296
- eventCount: 1,
297
- events: [{ type: 4 }],
298
- });
299
- store.appendRecording(sessionId, {
300
- chunkId: 'rrc_2',
301
- startTs: now - 600,
302
- endTs: now - 400,
303
- eventCount: 1,
304
- events: [{ type: 4 }],
305
- });
306
- store.appendRecording(sessionId, {
307
- chunkId: 'rrc_3',
308
- startTs: now - 300,
309
- endTs: now - 200,
310
- eventCount: 1,
311
- events: [{ type: 4 }],
312
- });
313
-
314
- await store.flush();
315
- const result = store.purge({
316
- maxAgeDays: 7,
317
- maxSessions: 20,
318
- recordingRetentionDays: 7,
319
- maxRecordingChunksPerSession: 2,
320
- preserveMarkedChunks: true,
321
- });
322
-
323
- expect(result.recordingsDeleted).toBe(1);
324
- const remaining = store.listRecordings(sessionId).map((chunk) => chunk.chunkId);
325
- // rrc_2 must survive (overlaps marker); rrc_1 is oldest and dropped
326
- expect(remaining).toEqual(['rrc_2', 'rrc_3']);
327
- });
328
-
329
- it('recording prune leaves session timeline intact', async () => {
330
- const { sessionId } = openSession(store, 'proj', 'tab-1');
331
- const now = Date.now();
332
- store.appendEvent(sessionId, { ts: now - 800, t: 'log', d: { args: ['hello'] } });
333
- store.appendEvent(sessionId, { ts: now - 500, t: 'err', d: { message: 'boom' } });
334
- store.appendEvent(sessionId, {
335
- ts: now - 450,
336
- t: 'rrweb:marker',
337
- d: { markerId: 'rrm_1', kind: 'error', label: 'boom' },
338
- });
339
- // Three rrweb chunks — purge will trim to 2.
340
- for (let i = 0; i < 3; i++) {
341
- store.appendRecording(sessionId, {
342
- chunkId: `c_${i}`,
343
- startTs: now - 1000 + i * 100,
344
- endTs: now - 900 + i * 100,
345
- eventCount: 1,
346
- events: [{ type: 4 }],
347
- });
348
- }
349
- await store.flush();
350
-
351
- const before = store.tail(sessionId, { n: 50 });
352
- const beforeMarkers = store.tail(sessionId, { n: 50, type: 'rrweb:marker' });
353
-
354
- const result = store.purge({ maxRecordingChunksPerSession: 2, preserveMarkedChunks: false });
355
- expect(result.recordingsDeleted).toBe(1);
356
-
357
- const after = store.tail(sessionId, { n: 50 });
358
- const afterMarkers = store.tail(sessionId, { n: 50, type: 'rrweb:marker' });
359
- expect(after).toEqual(before);
360
- expect(afterMarkers).toEqual(beforeMarkers);
361
- expect(afterMarkers).toHaveLength(1);
362
- });
363
-
364
- // ── Exports (replay) ─────────────────────────────────────────────────
365
-
366
- it('writeExport persists events and metadata, readable by id', async () => {
367
- const { sessionId, tabId } = openSession(store, 'proj', 'tab-1');
368
- const meta = store.writeExport({
369
- sessionId,
370
- tabId,
371
- since: 1000,
372
- until: 2000,
373
- startTs: 1100,
374
- endTs: 1900,
375
- chunkCount: 2,
376
- events: [{ type: 4 }, { type: 3 }, { type: 3 }],
377
- label: 'bug-1',
378
- });
379
- expect(meta.exportId).toMatch(/^exp_/);
380
- expect(meta.eventCount).toBe(3);
381
- expect(meta.bytes).toBeGreaterThan(0);
382
-
383
- const fromIndex = store.getExport(meta.exportId);
384
- expect(fromIndex?.label).toBe('bug-1');
385
- expect(fromIndex?.chunkCount).toBe(2);
386
-
387
- const events = store.readExportEvents(meta.exportId);
388
- expect(events).toHaveLength(3);
389
- });
390
-
391
- it('listExports returns exports newest-first per project', () => {
392
- const { sessionId, tabId } = openSession(store, 'proj', 'tab-1');
393
- const a = store.writeExport({ sessionId, tabId, since: 0, until: 1, startTs: 0, endTs: 1, chunkCount: 1, events: [{}, {}] });
394
- const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
395
- return (async () => {
396
- await sleep(5);
397
- const b = store.writeExport({ sessionId, tabId, since: 0, until: 1, startTs: 0, endTs: 1, chunkCount: 1, events: [{}, {}] });
398
- const all = store.listExports('proj');
399
- expect(all.map((m) => m.exportId)).toEqual([b.exportId, a.exportId]);
400
- })();
401
- });
402
-
403
- it('purge trims exports beyond the per-project count limit, oldest first', async () => {
404
- const { sessionId, tabId } = openSession(store, 'proj', 'tab-1');
405
- const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
406
- const exports: string[] = [];
407
- for (let i = 0; i < 4; i++) {
408
- const meta = store.writeExport({
409
- sessionId, tabId, since: 0, until: 1, startTs: 0, endTs: 1, chunkCount: 1, events: [{}, {}],
410
- });
411
- exports.push(meta.exportId);
412
- await sleep(3);
413
- }
414
- const result = store.purge({
415
- maxExportsPerProject: 2,
416
- });
417
- expect(result.exportsDeleted).toBe(2);
418
- const remaining = store.listExports('proj').map((m) => m.exportId);
419
- // newest two kept
420
- expect(remaining).toEqual([exports[3], exports[2]]);
421
- // deleted ones gone
422
- expect(store.getExport(exports[0])).toBeUndefined();
423
- expect(store.readExportEvents(exports[0])).toBeUndefined();
424
- });
425
-
426
- it('purge trims exports beyond the per-project byte ceiling', async () => {
427
- const { sessionId, tabId } = openSession(store, 'proj', 'tab-1');
428
- const bigEvent = { type: 3, data: { payload: 'x'.repeat(900) } };
429
- const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
430
- const ids: string[] = [];
431
- for (let i = 0; i < 3; i++) {
432
- const meta = store.writeExport({
433
- sessionId, tabId, since: 0, until: 1, startTs: 0, endTs: 1, chunkCount: 1,
434
- events: [bigEvent, bigEvent],
435
- });
436
- ids.push(meta.exportId);
437
- await sleep(3);
438
- }
439
- const result = store.purge({ maxExportBytesPerProject: 2000 });
440
- expect(result.exportsDeleted).toBeGreaterThanOrEqual(1);
441
- const surviving = store.listExports('proj').map((m) => m.exportId);
442
- // newest survives
443
- expect(surviving[0]).toBe(ids[2]);
444
- });
445
-
446
- // ── Search ───────────────────────────────────────────────────────────
447
-
448
- it('searches events by substring', async () => {
449
- const { sessionId } = openSession(store, 'proj');
450
- store.appendEvent(sessionId, { ts: 1000, t: 'log', d: { args: ['hello world'] } });
451
- store.appendEvent(sessionId, { ts: 2000, t: 'log', d: { args: ['goodbye'] } });
452
- store.appendEvent(sessionId, { ts: 3000, t: 'err', d: { message: 'hello error' } });
453
-
454
- await store.flush();
455
- const results = store.search(sessionId, 'hello');
456
- expect(results).toHaveLength(2);
457
- });
458
-
459
- it('search filters by type', async () => {
460
- const { sessionId } = openSession(store, 'proj');
461
- store.appendEvent(sessionId, { ts: 1000, t: 'log', d: { args: ['hello'] } });
462
- store.appendEvent(sessionId, { ts: 2000, t: 'err', d: { message: 'hello error' } });
463
-
464
- await store.flush();
465
- const results = store.search(sessionId, 'hello', { type: 'err' });
466
- expect(results).toHaveLength(1);
467
- expect(results[0].t).toBe('err');
468
- });
469
-
470
- // ── Summary ──────────────────────────────────────────────────────────
471
-
472
- it('returns a session summary with counts', async () => {
473
- const { sessionId } = openSession(store, 'proj');
474
- store.appendEvent(sessionId, { ts: 1000, t: 'log', d: {} });
475
- store.appendEvent(sessionId, { ts: 2000, t: 'log', d: {} });
476
- store.appendEvent(sessionId, { ts: 3000, t: 'err', d: { message: 'boom' } });
477
- store.appendEvent(sessionId, { ts: 4000, t: 'cmd', d: {} });
478
-
479
- await store.flush();
480
- const s = store.summary(sessionId);
481
- expect(s.counts['log']).toBe(2);
482
- expect(s.counts['err']).toBe(1);
483
- expect(s.counts['cmd']).toBe(1);
484
- expect(s.lastError?.t).toBe('err');
485
- expect(s.lastActivity).toBe(4000);
486
- });
487
-
488
- // ── Notes ────────────────────────────────────────────────────────────
489
-
490
- it('writes and reads project notes', () => {
491
- store.upsertProject('proj', {});
492
- store.writeNote('proj', 'known_issues', 'Login button broken on Safari');
493
- store.writeNote('proj', 'architecture', 'Uses React 18 + Vite 7');
494
-
495
- const notes = store.listNotes('proj');
496
- expect(notes.map((n) => n.key)).toContain('known_issues');
497
- expect(notes.map((n) => n.key)).toContain('architecture');
498
- });
499
-
500
- it('returns latest value when same key written multiple times', () => {
501
- store.upsertProject('proj', {});
502
- store.writeNote('proj', 'status', 'v1');
503
- store.writeNote('proj', 'status', 'v2');
504
-
505
- const notes = store.listNotes('proj');
506
- const status = notes.find((n) => n.key === 'status');
507
- expect(status?.value).toBe('v2');
508
- });
509
-
510
- // ── Purge ────────────────────────────────────────────────────────────
511
-
512
- it('purge removes sessions older than maxAgeDays', async () => {
513
- const { sessionId } = openSession(store, 'proj');
514
- store.appendEvent(sessionId, { ts: Date.now(), t: 'log', d: {} });
515
-
516
- // Manually backdate the session meta to the new flat path
517
- const { writeFileSync: wfs } = await import('node:fs');
518
- const { join: pathJoin } = await import('node:path');
519
- const sessDir = pathJoin(dir, 'sessions', sanitizeId(sessionId));
520
- const meta = store.getSession(sessionId)!;
521
- meta.startedAt = Date.now() - 10 * 86400000; // 10 days ago
522
- wfs(pathJoin(sessDir, 'meta.json'), JSON.stringify(meta));
523
-
524
- const result = store.purge({ maxAgeDays: 7 });
525
- expect(result.sessionsDeleted).toBe(1);
526
- });
527
-
528
- it('purge keeps recent sessions', () => {
529
- openSession(store, 'proj');
530
- openSession(store, 'proj');
531
-
532
- const result = store.purge({ maxAgeDays: 7 });
533
- expect(result.sessionsDeleted).toBe(0);
534
-
535
- const remaining = store.listSessions({ projectId: 'proj' });
536
- expect(remaining).toHaveLength(2);
537
- });
538
-
539
- it('purge respects maxSessions (global cap)', () => {
540
- for (let i = 0; i < 5; i++) {
541
- openSession(store, 'proj');
542
- }
543
- const result = store.purge({ maxAgeDays: 365, maxSessions: 3 });
544
- expect(result.sessionsDeleted).toBe(2);
545
- expect(store.listSessions({ projectId: 'proj' })).toHaveLength(3);
546
- });
547
-
548
- // ── Startup recovery ──────────────────────────────────────────────────
549
-
550
- it('startup recovery: rebuilds sessionIndex from disk', async () => {
551
- const { sessionId: s1 } = openSession(store, 'proj');
552
- const { sessionId: s2 } = openSession(store, 'proj');
553
- store.closeSession(s1); // s1 has endedAt
554
- // s2 is left open (no endedAt)
555
- await store.close();
556
-
557
- // Create a new store instance pointing to the same directory
558
- const store2 = new JsonlStore(dir);
559
-
560
- const meta1 = store2.getSession(s1);
561
- const meta2 = store2.getSession(s2);
562
-
563
- expect(meta1).toBeDefined();
564
- expect(meta1!.id).toBe(s1);
565
- expect(meta2).toBeDefined();
566
- expect(meta2!.id).toBe(s2);
567
-
568
- await store2.close();
569
- });
570
-
571
- it('startup recovery: sets endedAt on orphaned sessions', async () => {
572
- const { sessionId: orphanId } = openSession(store, 'proj');
573
- const metaBefore = store.getSession(orphanId);
574
- expect(metaBefore?.endedAt).toBeUndefined();
575
- await store.close();
576
-
577
- const beforeRestart = Date.now();
578
-
579
- const store2 = new JsonlStore(dir);
580
-
581
- const metaAfter = store2.getSession(orphanId);
582
- expect(metaAfter).toBeDefined();
583
- expect(metaAfter!.endedAt).toBeDefined();
584
- expect(metaAfter!.endedAt!).toBeGreaterThanOrEqual(beforeRestart);
585
-
586
- await store2.close();
587
- });
588
-
589
- it('startup recovery: does not overwrite endedAt on already-closed sessions', async () => {
590
- const { sessionId: closedId } = openSession(store, 'proj');
591
- store.closeSession(closedId);
592
- const metaBefore = store.getSession(closedId);
593
- const originalEndedAt = metaBefore!.endedAt!;
594
- expect(originalEndedAt).toBeDefined();
595
- await store.close();
596
-
597
- const store2 = new JsonlStore(dir);
598
-
599
- const metaAfter = store2.getSession(closedId);
600
- expect(metaAfter).toBeDefined();
601
- expect(metaAfter!.endedAt).toBe(originalEndedAt);
602
-
603
- await store2.close();
604
- });
605
-
606
- it('startup recovery: handles multiple projects and sessions', async () => {
607
- const { sessionId: s1 } = openSession(store, 'proj-alpha');
608
- const { sessionId: s2 } = openSession(store, 'proj-beta');
609
- const { sessionId: s3 } = openSession(store, 'proj-alpha');
610
- store.closeSession(s1); // closed
611
- // s2 and s3 are orphaned
612
- await store.close();
613
-
614
- const store2 = new JsonlStore(dir);
615
-
616
- expect(store2.getSession(s1)).toBeDefined();
617
- expect(store2.getSession(s2)).toBeDefined();
618
- expect(store2.getSession(s3)).toBeDefined();
619
-
620
- // Orphaned sessions should have endedAt set
621
- expect(store2.getSession(s2)!.endedAt).toBeDefined();
622
- expect(store2.getSession(s3)!.endedAt).toBeDefined();
623
-
624
- // Closed session should retain its original endedAt
625
- expect(store2.getSession(s1)!.endedAt).toBeDefined();
626
-
627
- await store2.close();
628
- });
629
- });
630
-
631
- // ── Project tree + build metadata ────────────────────────────────────────────
632
-
633
- describe('JsonlStore — project tree + build metadata', () => {
634
- let dataDir: string;
635
- let store: JsonlStore;
636
-
637
- beforeEach(() => {
638
- dataDir = mkdtempSync(join(tmpdir(), 'jstore-tree-'));
639
- store = new JsonlStore(dataDir);
640
- });
641
-
642
- afterEach(async () => {
643
- await store.close();
644
- rmSync(dataDir, { recursive: true, force: true });
645
- });
646
-
647
- it('upsertProject writes parentProjectId/displayName/tags into meta.json', () => {
648
- store.upsertProject('app-parent', { displayName: 'Parent App' });
649
- store.upsertProject('app-child', {
650
- parentProjectId: 'app-parent',
651
- displayName: 'Child App',
652
- tags: ['mfe', 'iframe'],
653
- });
654
-
655
- const parent = store.getProject('app-parent');
656
- const child = store.getProject('app-child');
657
- expect(parent?.displayName).toBe('Parent App');
658
- expect(parent?.parentProjectId).toBeUndefined();
659
- expect(child?.parentProjectId).toBe('app-parent');
660
- expect(child?.tags).toEqual(['mfe', 'iframe']);
661
- });
662
-
663
- it('upsertProject preserves fields not in the patch (last-write-wins per field)', () => {
664
- store.upsertProject('p1', { displayName: 'first', tags: ['a'] });
665
- store.upsertProject('p1', { parentProjectId: 'root' });
666
-
667
- const meta = store.getProject('p1');
668
- expect(meta?.displayName).toBe('first'); // preserved
669
- expect(meta?.tags).toEqual(['a']); // preserved
670
- expect(meta?.parentProjectId).toBe('root'); // newly set
671
- expect(meta?.id).toBe('p1');
672
- expect(typeof meta?.createdAt).toBe('number');
673
- });
674
-
675
- it('upsertProject refuses self-parent cycle', () => {
676
- store.upsertProject('p1', {});
677
- expect(() => store.upsertProject('p1', { parentProjectId: 'p1' })).toThrow(/itself/);
678
- });
679
-
680
- it('upsertProject refuses indirect parent cycle (A→B→A)', () => {
681
- store.upsertProject('a', {});
682
- store.upsertProject('b', { parentProjectId: 'a' });
683
- expect(() => store.upsertProject('a', { parentProjectId: 'b' })).toThrow(/cycle/);
684
- });
685
-
686
- it('subsequent upsertProject does NOT overwrite parentProjectId / displayName', () => {
687
- store.upsertProject('p1', { displayName: 'Parent', parentProjectId: 'root' });
688
- // A second upsert without parentProjectId should preserve existing values
689
- store.upsertProject('p1', { tags: ['new-tag'] });
690
-
691
- const meta = store.getProject('p1');
692
- expect(meta?.displayName).toBe('Parent');
693
- expect(meta?.parentProjectId).toBe('root');
694
- });
695
-
696
- it('upsertBuild + getBuild + listBuilds roundtrip', () => {
697
- store.upsertProject('app', {});
698
- store.upsertBuild('app', 'b1', {
699
- gitSha: 'abc',
700
- gitDirty: false,
701
- bundler: 'vite',
702
- });
703
- store.upsertBuild('app', 'b2', {
704
- gitSha: 'def',
705
- gitDirty: true,
706
- bundler: 'vite',
707
- });
708
-
709
- const b1 = store.getBuild('app', 'b1');
710
- expect(b1?.gitSha).toBe('abc');
711
- expect(b1?.projectId).toBe('app');
712
-
713
- const all = store.listBuilds('app');
714
- expect(all.map((b) => b.id).sort()).toEqual(['b1', 'b2']);
715
- });
716
-
717
- it('upsertBuild merges incremental patches', () => {
718
- store.upsertProject('app', {});
719
- store.upsertBuild('app', 'b1', { gitSha: 'abc' });
720
- store.upsertBuild('app', 'b1', { nodeVersion: 'v22.0.0' });
721
-
722
- const meta = store.getBuild('app', 'b1');
723
- expect(meta?.gitSha).toBe('abc');
724
- expect(meta?.nodeVersion).toBe('v22.0.0');
725
- });
726
-
727
- it('getProjectTree assembles parent/child relationships into a forest', () => {
728
- store.upsertProject('root1', { displayName: 'Root One' });
729
- store.upsertProject('child-a', { parentProjectId: 'root1', displayName: 'Alpha' });
730
- store.upsertProject('child-b', { parentProjectId: 'root1', displayName: 'Bravo' });
731
- store.upsertProject('grandchild', { parentProjectId: 'child-a' });
732
- store.upsertProject('root2', { displayName: 'Root Two' });
733
-
734
- const tree = store.getProjectTree();
735
- expect(tree.map((n) => n.id).sort()).toEqual(['root1', 'root2']);
736
- const r1 = tree.find((n) => n.id === 'root1')!;
737
- expect(r1.children.map((c) => c.id).sort()).toEqual(['child-a', 'child-b']);
738
- const ca = r1.children.find((c) => c.id === 'child-a')!;
739
- expect(ca.children.map((c) => c.id)).toEqual(['grandchild']);
740
- });
741
-
742
- it('getProjectTree with rootId returns just that sub-tree', () => {
743
- store.upsertProject('root', { displayName: 'Root' });
744
- store.upsertProject('mid', { parentProjectId: 'root' });
745
- store.upsertProject('leaf', { parentProjectId: 'mid' });
746
-
747
- const subtree = store.getProjectTree('mid');
748
- expect(subtree).toHaveLength(1);
749
- expect(subtree[0]?.id).toBe('mid');
750
- expect(subtree[0]?.children.map((c) => c.id)).toEqual(['leaf']);
751
- });
752
-
753
- it('getProjectTree handles a 1000-deep chain without stack overflow', () => {
754
- for (let i = 0; i < 1000; i++) {
755
- const parent = i === 0 ? undefined : `p${i - 1}`;
756
- store.upsertProject(`p${i}`, parent ? { parentProjectId: parent } : {});
757
- }
758
- const tree = store.getProjectTree('p0');
759
- let depth = 0;
760
- let cursor = tree[0];
761
- while (cursor) {
762
- depth++;
763
- cursor = cursor.children[0];
764
- }
765
- expect(depth).toBe(1000);
766
- });
767
-
768
- it('upsertProject rejects tags + metadata exceeding 16KB', () => {
769
- const big = 'x'.repeat(20 * 1024);
770
- expect(() =>
771
- store.upsertProject('p1', { metadata: { blob: big } }),
772
- ).toThrow(/refused.*bytes.*limit/);
773
- });
774
-
775
- it('upsertProject accepts modest tags + metadata under the limit', () => {
776
- const ok = 'x'.repeat(1024);
777
- expect(() =>
778
- store.upsertProject('p1', {
779
- tags: ['a', 'b', 'c'],
780
- metadata: { note: ok },
781
- }),
782
- ).not.toThrow();
783
- });
784
-
785
- it('upsertBuild rejects tags + metadata exceeding 16KB', () => {
786
- store.upsertProject('app', {});
787
- const big = 'x'.repeat(20 * 1024);
788
- expect(() =>
789
- store.upsertBuild('app', 'b1', { metadata: { blob: big } }),
790
- ).toThrow(/refused.*bytes.*limit/);
791
- });
792
-
793
- it('purge enforces maxBuildsPerProject (newest builds kept)', () => {
794
- store.upsertProject('app', {});
795
- for (let i = 0; i < 5; i++) {
796
- store.upsertBuild('app', `b${i}`, { bundler: 'vite' });
797
- // Patch builtAt to force sortability — write directly to the NEW flat path
798
- const meta = store.getBuild('app', `b${i}`)!;
799
- const fixed = { ...meta, builtAt: 1_700_000_000_000 + i * 1000 };
800
- writeFileSync(
801
- join(dataDir, 'projects', 'app', 'builds', `b${i}`, 'meta.json'),
802
- JSON.stringify(fixed),
803
- );
804
- }
805
- expect(store.listBuilds('app')).toHaveLength(5);
806
-
807
- const result = store.purge({ maxBuildsPerProject: 2 });
808
- expect(result.buildsDeleted).toBe(3);
809
-
810
- const remaining = store.listBuilds('app').map((b) => b.id);
811
- expect(remaining.sort()).toEqual(['b3', 'b4']); // newest 2 kept
812
- });
813
-
814
- // ── Session participants (upsertSession merge semantics) ───────────────
815
-
816
- it('upsertSession merges participants without duplicates', () => {
817
- const sessionId = randomUUID();
818
- store.upsertTab('tab-merge', { connectedAt: Date.now() });
819
- store.upsertSession(sessionId, {
820
- tabId: 'tab-merge',
821
- startedAt: Date.now(),
822
- participants: [{ projectId: 'proj-a', joinedAt: Date.now() }],
823
- });
824
- // Second upsert adds a new participant
825
- store.upsertSession(sessionId, {
826
- tabId: 'tab-merge',
827
- startedAt: Date.now(),
828
- participants: [
829
- { projectId: 'proj-a', joinedAt: Date.now() }, // duplicate — must not double
830
- { projectId: 'proj-b', joinedAt: Date.now() }, // new
831
- ],
832
- });
833
- const meta = store.getSession(sessionId)!;
834
- expect(meta.participants.map((p) => p.projectId).sort()).toEqual(['proj-a', 'proj-b']);
835
- });
836
-
837
- it('listSessions filters by projectId', () => {
838
- const { sessionId: s1 } = openSession(store, 'proj-x');
839
- const { sessionId: s2 } = openSession(store, 'proj-y');
840
-
841
- const xSessions = store.listSessions({ projectId: 'proj-x' });
842
- const ySessions = store.listSessions({ projectId: 'proj-y' });
843
-
844
- expect(xSessions.map((s) => s.id)).toContain(s1);
845
- expect(xSessions.map((s) => s.id)).not.toContain(s2);
846
- expect(ySessions.map((s) => s.id)).toContain(s2);
847
- });
848
-
849
- it('listSessions filters by tabId', () => {
850
- const { sessionId: s1 } = openSession(store, 'proj', 'tab-A');
851
- const { sessionId: s2 } = openSession(store, 'proj', 'tab-B');
852
-
853
- const tabA = store.listSessions({ tabId: 'tab-A' });
854
- expect(tabA.map((s) => s.id)).toContain(s1);
855
- expect(tabA.map((s) => s.id)).not.toContain(s2);
856
- });
857
- });
858
-
859
- // ── Property-Based Tests ─────────────────────────────────────────────────────
860
-
861
- // Feature: persistence, Property 13: ID sanitization safety
862
- describe('sanitizeId — Property 13: ID sanitization safety', () => {
863
- it('sanitized output always matches /^[a-zA-Z0-9._-]{1,64}$/ for any string input', () => {
864
- fc.assert(
865
- fc.property(fc.string({ minLength: 1 }), (id) => {
866
- const sanitized = sanitizeId(id);
867
- expect(sanitized).toMatch(/^[a-zA-Z0-9._-]{1,64}$/);
868
- }),
869
- { numRuns: 100 },
870
- );
871
- });
872
-
873
- it('sanitized output is at most 64 characters for any string input', () => {
874
- fc.assert(
875
- fc.property(fc.string(), (id) => {
876
- const sanitized = sanitizeId(id);
877
- expect(sanitized.length).toBeLessThanOrEqual(64);
878
- }),
879
- { numRuns: 100 },
880
- );
881
- });
882
-
883
- it('sanitized output contains only allowed characters for any string input', () => {
884
- fc.assert(
885
- fc.property(fc.string({ minLength: 1 }), (id) => {
886
- const sanitized = sanitizeId(id);
887
- expect(sanitized).toMatch(/^[a-zA-Z0-9._-]+$/);
888
- }),
889
- { numRuns: 100 },
890
- );
891
- });
892
- });
893
-
894
- // Feature: persistence, Property 3: StoreEvent JSONL round-trip
895
- describe('Property 3: StoreEvent JSONL round-trip', () => {
896
- it('writing an event and reading it back preserves ts, t, and d fields', async () => {
897
- await fc.assert(
898
- fc.asyncProperty(
899
- fc.record({
900
- seq: fc.nat(),
901
- ts: fc.integer({ min: 0 }),
902
- t: fc.string(),
903
- tab: fc.option(fc.string(), { nil: undefined }),
904
- d: fc.jsonValue(),
905
- }),
906
- async (event) => {
907
- const tmpDir = mkdtempSync(join(tmpdir(), 'pbt-p3-'));
908
- try {
909
- const s = new JsonlStore(tmpDir);
910
- const { sessionId } = openSession(s, 'proj');
911
- s.appendEvent(sessionId, { ts: event.ts, t: event.t, d: event.d });
912
- await s.flush();
913
-
914
- const events = s.tail(sessionId);
915
- expect(events).toHaveLength(1);
916
- expect(events[0].ts).toBe(event.ts);
917
- expect(events[0].t).toBe(event.t);
918
- expect(JSON.parse(JSON.stringify(events[0].d))).toEqual(
919
- JSON.parse(JSON.stringify(event.d)),
920
- );
921
-
922
- await s.close();
923
- } finally {
924
- cleanup(tmpDir);
925
- }
926
- },
927
- ),
928
- { numRuns: 100 },
929
- );
930
- });
931
- });
932
-
933
- // Feature: persistence, Property 4: Monotonically increasing seq values
934
- describe('Property 4: Monotonically increasing seq values', () => {
935
- it('seq values are non-negative integers increasing by exactly 1 for each appended event', async () => {
936
- await fc.assert(
937
- fc.asyncProperty(
938
- fc.array(
939
- fc.record({ ts: fc.integer(), t: fc.string() }),
940
- { minLength: 2, maxLength: 50 },
941
- ),
942
- async (eventInputs) => {
943
- const tmpDir = mkdtempSync(join(tmpdir(), 'pbt-p4-'));
944
- try {
945
- const s = new JsonlStore(tmpDir);
946
- const { sessionId } = openSession(s, 'proj');
947
-
948
- for (const ev of eventInputs) {
949
- s.appendEvent(sessionId, { ts: ev.ts, t: ev.t });
950
- }
951
- await s.flush();
952
-
953
- const events = s.tail(sessionId, { n: eventInputs.length });
954
- expect(events).toHaveLength(eventInputs.length);
955
-
956
- for (let i = 0; i < events.length; i++) {
957
- expect(events[i].seq).toBeGreaterThanOrEqual(0);
958
- if (i > 0) {
959
- expect(events[i].seq).toBe((events[i - 1].seq as number) + 1);
960
- }
961
- }
962
-
963
- await s.close();
964
- } finally {
965
- cleanup(tmpDir);
966
- }
967
- },
968
- ),
969
- { numRuns: 100 },
970
- );
971
- });
972
- });
973
-
974
- // Feature: persistence, Property 5: Single-timeline write (v0.4.0 — no dual-write)
975
- describe('Property 5: Single-timeline write invariant', () => {
976
- it('an event appended to a session appears in the session timeline', async () => {
977
- await fc.assert(
978
- fc.asyncProperty(
979
- fc.record({ ts: fc.integer(), t: fc.string() }),
980
- async (eventInput) => {
981
- const tmpDir = mkdtempSync(join(tmpdir(), 'pbt-p5-'));
982
- try {
983
- const s = new JsonlStore(tmpDir);
984
- const { sessionId } = openSession(s, 'proj');
985
- s.appendEvent(sessionId, { ts: eventInput.ts, t: eventInput.t });
986
- await s.flush();
987
-
988
- const sessEvents = s.tail(sessionId);
989
- expect(sessEvents).toHaveLength(1);
990
- expect(sessEvents[0].ts).toBe(eventInput.ts);
991
- expect(sessEvents[0].t).toBe(eventInput.t);
992
-
993
- await s.close();
994
- } finally {
995
- cleanup(tmpDir);
996
- }
997
- },
998
- ),
999
- { numRuns: 100 },
1000
- );
1001
- });
1002
- });
1003
-
1004
- // Feature: persistence, Property 6: Session upsert/get round-trip
1005
- describe('Property 6: Session upsert/get round-trip', () => {
1006
- it('getSession returns a SessionMeta with matching id and tabId for any upsertSession call', async () => {
1007
- await fc.assert(
1008
- fc.asyncProperty(
1009
- fc.string({ minLength: 1 }),
1010
- async (projectId) => {
1011
- const tmpDir = mkdtempSync(join(tmpdir(), 'pbt-p6-'));
1012
- try {
1013
- const s = new JsonlStore(tmpDir);
1014
- const { sessionId, tabId } = openSession(s, projectId);
1015
-
1016
- const meta = s.getSession(sessionId);
1017
- expect(meta).toBeDefined();
1018
- expect(meta!.id).toBe(sessionId);
1019
- expect(meta!.tabId).toBe(tabId);
1020
- expect(meta!.participants[0]?.projectId).toBe(projectId);
1021
-
1022
- await s.close();
1023
- } finally {
1024
- cleanup(tmpDir);
1025
- }
1026
- },
1027
- ),
1028
- { numRuns: 100 },
1029
- );
1030
- });
1031
- });
1032
-
1033
- // ── WriteQueue Unit Tests ────────────────────────────────────────────────────
1034
-
1035
- describe('WriteQueue', () => {
1036
- let tmpDir: string;
1037
-
1038
- beforeEach(() => {
1039
- tmpDir = mkdtempSync(join(tmpdir(), 'write-queue-test-'));
1040
- });
1041
-
1042
- afterEach(() => {
1043
- try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
1044
- });
1045
-
1046
- it('events enqueued before flush appear in file after drain()', async () => {
1047
- const queue = new WriteQueue();
1048
- const filePath = join(tmpDir, 'timeline.jsonl');
1049
- const sessionId = 'sess-1';
1050
-
1051
- queue.enqueue(filePath, sessionId, JSON.stringify({ ts: 1000, t: 'log', d: { msg: 'a' } }));
1052
- queue.enqueue(filePath, sessionId, JSON.stringify({ ts: 2000, t: 'err', d: { msg: 'b' } }));
1053
- queue.enqueue(filePath, sessionId, JSON.stringify({ ts: 3000, t: 'hmr', d: { msg: 'c' } }));
1054
-
1055
- await queue.drain();
1056
-
1057
- const content = readFileSync(filePath, 'utf-8');
1058
- const lines = content.split('\n').filter((l) => l.trim());
1059
-
1060
- expect(lines).toHaveLength(3);
1061
-
1062
- const events = lines.map((l) => JSON.parse(l));
1063
- expect(events[0].t).toBe('log');
1064
- expect(events[1].t).toBe('err');
1065
- expect(events[2].t).toBe('hmr');
1066
-
1067
- expect(events[0].seq).toBe(0);
1068
- expect(events[1].seq).toBe(1);
1069
- expect(events[2].seq).toBe(2);
1070
- });
1071
-
1072
- it('all enqueued events are present in file after drain() with correct seq numbers', async () => {
1073
- const queue = new WriteQueue();
1074
- const filePath = join(tmpDir, 'timeline.jsonl');
1075
- const sessionId = 'sess-2';
1076
- const count = 10;
1077
-
1078
- for (let i = 0; i < count; i++) {
1079
- queue.enqueue(filePath, sessionId, JSON.stringify({ ts: i * 100, t: 'log', d: { i } }));
1080
- }
1081
-
1082
- await queue.drain();
1083
-
1084
- const content = readFileSync(filePath, 'utf-8');
1085
- const lines = content.split('\n').filter((l) => l.trim());
1086
- expect(lines).toHaveLength(count);
1087
-
1088
- const events = lines.map((l) => JSON.parse(l));
1089
- for (let i = 0; i < count; i++) {
1090
- expect(events[i].seq).toBe(i);
1091
- }
1092
- });
1093
-
1094
- it('failed flush does not throw and seq numbers are not reused', async () => {
1095
- const queue = new WriteQueue();
1096
- const badPath = join(tmpDir, 'nonexistent-dir', 'timeline.jsonl');
1097
- const goodPath = join(tmpDir, 'timeline.jsonl');
1098
- const sessionId = 'sess-3';
1099
-
1100
- queue.enqueue(badPath, sessionId, JSON.stringify({ ts: 1000, t: 'log', d: {} }));
1101
- queue.enqueue(badPath, sessionId, JSON.stringify({ ts: 2000, t: 'log', d: {} }));
1102
- queue.enqueue(badPath, sessionId, JSON.stringify({ ts: 3000, t: 'log', d: {} }));
1103
-
1104
- await expect(queue.drain()).resolves.toBeUndefined();
1105
-
1106
- expect(queue.getSeq(sessionId)).toBe(3);
1107
-
1108
- queue.enqueue(goodPath, sessionId, JSON.stringify({ ts: 4000, t: 'log', d: {} }));
1109
- queue.enqueue(goodPath, sessionId, JSON.stringify({ ts: 5000, t: 'log', d: {} }));
1110
-
1111
- await queue.drain();
1112
-
1113
- expect(queue.getSeq(sessionId)).toBe(5);
1114
-
1115
- const content = readFileSync(goodPath, 'utf-8');
1116
- const lines = content.split('\n').filter((l) => l.trim());
1117
- expect(lines).toHaveLength(2);
1118
-
1119
- const events = lines.map((l) => JSON.parse(l));
1120
- expect(events[0].seq).toBe(3);
1121
- expect(events[1].seq).toBe(4);
1122
- });
1123
-
1124
- it('server continues after flush failure — subsequent enqueues work normally', async () => {
1125
- const queue = new WriteQueue();
1126
- const badPath = join(tmpDir, 'no-such-dir', 'file.jsonl');
1127
- const goodPath = join(tmpDir, 'good.jsonl');
1128
- const sessionId = 'sess-4';
1129
-
1130
- queue.enqueue(badPath, sessionId, JSON.stringify({ ts: 1, t: 'log', d: {} }));
1131
- await queue.drain(); // fails silently
1132
-
1133
- queue.enqueue(goodPath, sessionId, JSON.stringify({ ts: 2, t: 'log', d: {} }));
1134
- await queue.drain();
1135
-
1136
- const content = readFileSync(goodPath, 'utf-8');
1137
- const lines = content.split('\n').filter((l) => l.trim());
1138
- expect(lines).toHaveLength(1);
1139
- expect(JSON.parse(lines[0]).seq).toBe(1);
1140
- });
1141
-
1142
- it('drain() flushes all pending events without waiting for the timer', async () => {
1143
- const queue = new WriteQueue();
1144
- const filePath = join(tmpDir, 'timeline.jsonl');
1145
- const sessionId = 'sess-5';
1146
-
1147
- queue.enqueue(filePath, sessionId, JSON.stringify({ ts: 1000, t: 'log', d: { msg: 'first' } }));
1148
- queue.enqueue(filePath, sessionId, JSON.stringify({ ts: 2000, t: 'log', d: { msg: 'second' } }));
1149
- queue.enqueue(filePath, sessionId, JSON.stringify({ ts: 3000, t: 'log', d: { msg: 'third' } }));
1150
-
1151
- await queue.drain();
1152
-
1153
- const content = readFileSync(filePath, 'utf-8');
1154
- const lines = content.split('\n').filter((l) => l.trim());
1155
-
1156
- expect(lines).toHaveLength(3);
1157
- const events = lines.map((l) => JSON.parse(l));
1158
- expect(events[0].d.msg).toBe('first');
1159
- expect(events[1].d.msg).toBe('second');
1160
- expect(events[2].d.msg).toBe('third');
1161
- });
1162
-
1163
- it('drain() is idempotent — calling it twice does not duplicate events', async () => {
1164
- const queue = new WriteQueue();
1165
- const filePath = join(tmpDir, 'timeline.jsonl');
1166
- const sessionId = 'sess-6';
1167
-
1168
- queue.enqueue(filePath, sessionId, JSON.stringify({ ts: 1000, t: 'log', d: {} }));
1169
- queue.enqueue(filePath, sessionId, JSON.stringify({ ts: 2000, t: 'log', d: {} }));
1170
-
1171
- await queue.drain();
1172
- await queue.drain();
1173
-
1174
- const content = readFileSync(filePath, 'utf-8');
1175
- const lines = content.split('\n').filter((l) => l.trim());
1176
- expect(lines).toHaveLength(2);
1177
- });
1178
-
1179
- it('drain() flushes events enqueued across multiple file paths', async () => {
1180
- const queue = new WriteQueue();
1181
- const fileA = join(tmpDir, 'session.jsonl');
1182
- const fileB = join(tmpDir, 'tab.jsonl');
1183
- const sessionId = 'sess-7';
1184
-
1185
- queue.enqueue(fileA, sessionId, JSON.stringify({ ts: 1000, t: 'log', d: {} }));
1186
- queue.enqueue(fileB, sessionId, JSON.stringify({ ts: 1000, t: 'log', d: {} }));
1187
- queue.enqueue(fileA, sessionId, JSON.stringify({ ts: 2000, t: 'err', d: {} }));
1188
-
1189
- await queue.drain();
1190
-
1191
- const linesA = readFileSync(fileA, 'utf-8').split('\n').filter((l) => l.trim());
1192
- const linesB = readFileSync(fileB, 'utf-8').split('\n').filter((l) => l.trim());
1193
-
1194
- expect(linesA).toHaveLength(2);
1195
- expect(linesB).toHaveLength(1);
1196
- });
1197
- });
1198
-
1199
- // Feature: persistence, Property 8: session.tail type filter correctness
1200
- describe('Property 8: session.tail type filter correctness', () => {
1201
- it('tail with a single type filter returns only events of that type', async () => {
1202
- await fc.assert(
1203
- fc.asyncProperty(
1204
- fc.array(
1205
- fc.record({ ts: fc.integer(), t: fc.constantFrom('log', 'err', 'req') }),
1206
- { minLength: 1, maxLength: 30 },
1207
- ),
1208
- async (eventInputs) => {
1209
- const tmpDir = mkdtempSync(join(tmpdir(), 'pbt-p8-'));
1210
- try {
1211
- const s = new JsonlStore(tmpDir);
1212
- const { sessionId } = openSession(s, 'proj');
1213
-
1214
- for (const ev of eventInputs) {
1215
- s.appendEvent(sessionId, { ts: ev.ts, t: ev.t });
1216
- }
1217
- await s.flush();
1218
-
1219
- for (const filterType of ['log', 'err', 'req'] as const) {
1220
- const results = s.tail(sessionId, { type: filterType, n: eventInputs.length });
1221
- for (const ev of results) {
1222
- expect(ev.t).toBe(filterType);
1223
- }
1224
- const wrongType = results.find((ev) => ev.t !== filterType);
1225
- expect(wrongType).toBeUndefined();
1226
- }
1227
-
1228
- await s.close();
1229
- } finally {
1230
- cleanup(tmpDir);
1231
- }
1232
- },
1233
- ),
1234
- { numRuns: 100 },
1235
- );
1236
- });
1237
-
1238
- it('tail with an array type filter returns only events whose type is in the array', async () => {
1239
- await fc.assert(
1240
- fc.asyncProperty(
1241
- fc.array(
1242
- fc.record({ ts: fc.integer(), t: fc.constantFrom('log', 'err', 'req') }),
1243
- { minLength: 1, maxLength: 30 },
1244
- ),
1245
- async (eventInputs) => {
1246
- const tmpDir = mkdtempSync(join(tmpdir(), 'pbt-p8b-'));
1247
- try {
1248
- const s = new JsonlStore(tmpDir);
1249
- const { sessionId } = openSession(s, 'proj');
1250
-
1251
- for (const ev of eventInputs) {
1252
- s.appendEvent(sessionId, { ts: ev.ts, t: ev.t });
1253
- }
1254
- await s.flush();
1255
-
1256
- const filterTypes: Array<'log' | 'err'> = ['log', 'err'];
1257
- const results = s.tail(sessionId, { type: filterTypes, n: eventInputs.length });
1258
-
1259
- for (const ev of results) {
1260
- expect(filterTypes).toContain(ev.t);
1261
- }
1262
- const wrongType = results.find((ev) => ev.t === 'req');
1263
- expect(wrongType).toBeUndefined();
1264
-
1265
- await s.close();
1266
- } finally {
1267
- cleanup(tmpDir);
1268
- }
1269
- },
1270
- ),
1271
- { numRuns: 100 },
1272
- );
1273
- });
1274
- });
1275
-
1276
- // Feature: persistence, Property 9: session.search substring correctness
1277
- describe('Property 9: session.search substring correctness', () => {
1278
- it('search returns only events whose JSON line contains the query as a case-insensitive substring', async () => {
1279
- await fc.assert(
1280
- fc.asyncProperty(
1281
- fc.tuple(
1282
- fc.array(
1283
- fc.record({ ts: fc.integer(), t: fc.string(), d: fc.jsonValue() }),
1284
- { minLength: 1, maxLength: 20 },
1285
- ),
1286
- fc.string({ minLength: 1 }),
1287
- ),
1288
- async ([eventInputs, query]) => {
1289
- const tmpDir = mkdtempSync(join(tmpdir(), 'pbt-p9-'));
1290
- try {
1291
- const s = new JsonlStore(tmpDir);
1292
- const { sessionId } = openSession(s, 'proj');
1293
-
1294
- for (const ev of eventInputs) {
1295
- s.appendEvent(sessionId, { ts: ev.ts, t: ev.t, d: ev.d });
1296
- }
1297
- await s.flush();
1298
-
1299
- const results = s.search(sessionId, query, { limit: eventInputs.length + 10 });
1300
- const lowerQuery = query.toLowerCase();
1301
-
1302
- for (const ev of results) {
1303
- const line = JSON.stringify(ev);
1304
- expect(line.toLowerCase()).toContain(lowerQuery);
1305
- }
1306
-
1307
- for (const ev of results) {
1308
- const line = JSON.stringify(ev);
1309
- expect(line.toLowerCase().includes(lowerQuery)).toBe(true);
1310
- }
1311
-
1312
- await s.close();
1313
- } finally {
1314
- cleanup(tmpDir);
1315
- }
1316
- },
1317
- ),
1318
- { numRuns: 100 },
1319
- );
1320
- });
1321
- });
1322
-
1323
- // Feature: persistence, Property 10: Purge age-based deletion
1324
- describe('Property 10: Purge age-based deletion', () => {
1325
- it('purge deletes sessions older than maxAgeDays and retains sessions within the window', async () => {
1326
- await fc.assert(
1327
- fc.asyncProperty(
1328
- fc.array(fc.integer({ min: 1, max: 30 }), { minLength: 1, maxLength: 10 }),
1329
- async (daysAgoList) => {
1330
- const tmpDir = mkdtempSync(join(tmpdir(), 'pbt-p10-'));
1331
- try {
1332
- const s = new JsonlStore(tmpDir);
1333
- const maxAgeDays = 7;
1334
- const now = Date.now();
1335
- const sessionIds: string[] = [];
1336
-
1337
- for (const daysAgo of daysAgoList) {
1338
- const { sessionId } = openSession(s, 'proj');
1339
- sessionIds.push(sessionId);
1340
-
1341
- // Backdate using the NEW flat path
1342
- const meta = s.getSession(sessionId)!;
1343
- meta.startedAt = now - daysAgo * 86400000;
1344
- const sessDir = join(tmpDir, 'sessions', sanitizeId(sessionId));
1345
- writeFileSync(join(sessDir, 'meta.json'), JSON.stringify(meta));
1346
- }
1347
-
1348
- s.purge({ maxAgeDays, maxSessions: 1000 });
1349
-
1350
- for (let i = 0; i < daysAgoList.length; i++) {
1351
- const daysAgo = daysAgoList[i];
1352
- const sessId = sessionIds[i];
1353
- const meta = s.getSession(sessId);
1354
-
1355
- if (daysAgo > maxAgeDays) {
1356
- expect(meta).toBeUndefined();
1357
- } else if (daysAgo < maxAgeDays) {
1358
- expect(meta).toBeDefined();
1359
- }
1360
- }
1361
-
1362
- await s.close();
1363
- } finally {
1364
- cleanup(tmpDir);
1365
- }
1366
- },
1367
- ),
1368
- { numRuns: 100 },
1369
- );
1370
- });
1371
- });
1372
-
1373
- // Feature: persistence, Property 11: Purge count-based deletion
1374
- describe('Property 11: Purge count-based deletion', () => {
1375
- it('purge retains exactly the M most recent sessions when count exceeds maxSessions', async () => {
1376
- await fc.assert(
1377
- fc.asyncProperty(
1378
- fc.integer({ min: 1, max: 20 }),
1379
- async (sessionCount) => {
1380
- const tmpDir = mkdtempSync(join(tmpdir(), 'pbt-p11-'));
1381
- try {
1382
- const s = new JsonlStore(tmpDir);
1383
- const maxSessions = Math.max(1, Math.floor(sessionCount / 2));
1384
- const now = Date.now();
1385
- const sessionIds: string[] = [];
1386
- const startedAts: number[] = [];
1387
-
1388
- for (let i = 0; i < sessionCount; i++) {
1389
- const { sessionId } = openSession(s, 'proj');
1390
- sessionIds.push(sessionId);
1391
-
1392
- const startedAt = now - (sessionCount - i) * 1000;
1393
- startedAts.push(startedAt);
1394
-
1395
- const meta = s.getSession(sessionId)!;
1396
- meta.startedAt = startedAt;
1397
- const sessDir = join(tmpDir, 'sessions', sanitizeId(sessionId));
1398
- writeFileSync(join(sessDir, 'meta.json'), JSON.stringify(meta));
1399
- }
1400
-
1401
- s.purge({ maxAgeDays: 365, maxSessions });
1402
-
1403
- const remaining = s.listSessions({ limit: 1000 });
1404
- expect(remaining.length).toBeLessThanOrEqual(maxSessions);
1405
-
1406
- if (remaining.length > 0) {
1407
- const sortedByRecency = [...sessionIds]
1408
- .map((id, idx) => ({ id, startedAt: startedAts[idx] }))
1409
- .sort((a, b) => b.startedAt - a.startedAt)
1410
- .slice(0, maxSessions)
1411
- .map((sess) => sess.id);
1412
-
1413
- for (const retainedSess of remaining) {
1414
- expect(sortedByRecency).toContain(retainedSess.id);
1415
- }
1416
- }
1417
-
1418
- await s.close();
1419
- } finally {
1420
- cleanup(tmpDir);
1421
- }
1422
- },
1423
- ),
1424
- { numRuns: 100 },
1425
- );
1426
- });
1427
- });
1428
-
1429
- // Feature: persistence, Property 12: Recording purge preserves timeline
1430
- describe('Property 12: Recording purge preserves timeline', () => {
1431
- it('purge deletes recording.jsonl but preserves timeline.jsonl', async () => {
1432
- const { utimesSync, existsSync: efs } = await import('node:fs');
1433
-
1434
- await fc.assert(
1435
- fc.asyncProperty(
1436
- fc.integer({ min: 1, max: 10 }),
1437
- async (daysAgo) => {
1438
- const tmpDir = mkdtempSync(join(tmpdir(), 'pbt-p12-'));
1439
- try {
1440
- const s = new JsonlStore(tmpDir);
1441
- const { sessionId } = openSession(s, 'proj');
1442
-
1443
- s.appendEvent(sessionId, { ts: Date.now(), t: 'log', d: {} });
1444
- s.appendRecording(sessionId, {
1445
- chunkId: 'c1', startTs: 1, endTs: 2, eventCount: 1,
1446
- events: [{ type: 4, data: {} }],
1447
- });
1448
- await s.flush();
1449
-
1450
- // Paths in the NEW flat layout
1451
- const sessDir = join(tmpDir, 'sessions', sanitizeId(sessionId));
1452
- const recordingPath = join(sessDir, 'recording.jsonl');
1453
- const timelinePath = join(sessDir, 'timeline.jsonl');
1454
-
1455
- const oldTime = new Date(Date.now() - daysAgo * 86400000);
1456
- if (efs(recordingPath)) {
1457
- utimesSync(recordingPath, oldTime, oldTime);
1458
- }
1459
-
1460
- const recordingRetentionDays = Math.max(0, daysAgo - 1);
1461
- s.purge({
1462
- maxAgeDays: 365,
1463
- maxSessions: 1000,
1464
- recordingRetentionDays,
1465
- });
1466
-
1467
- if (daysAgo > recordingRetentionDays) {
1468
- expect(efs(recordingPath)).toBe(false);
1469
- }
1470
-
1471
- // timeline must still exist
1472
- expect(efs(timelinePath)).toBe(true);
1473
-
1474
- await s.close();
1475
- } finally {
1476
- cleanup(tmpDir);
1477
- }
1478
- },
1479
- ),
1480
- { numRuns: 100 },
1481
- );
1482
- });
1483
- });
1484
-
1485
- // Feature: persistence, Property 7: Tab metadata schema completeness
1486
- describe('Property 7: Tab metadata schema completeness', () => {
1487
- it('upsertTab writes meta.json with required fields and optional fields iff provided', async () => {
1488
- const { readFileSync: rfs, existsSync: efs } = await import('node:fs');
1489
-
1490
- await fc.assert(
1491
- fc.asyncProperty(
1492
- fc.record({
1493
- id: fc.string({ minLength: 1 }),
1494
- userAgent: fc.option(fc.string(), { nil: undefined }),
1495
- }),
1496
- async (tabInput) => {
1497
- const tmpDir = mkdtempSync(join(tmpdir(), 'pbt-p7-'));
1498
- try {
1499
- const s = new JsonlStore(tmpDir);
1500
-
1501
- const tabArg: { connectedAt: number; userAgent?: string } = {
1502
- connectedAt: Date.now(),
1503
- };
1504
- if (tabInput.userAgent !== undefined) tabArg.userAgent = tabInput.userAgent;
1505
-
1506
- s.upsertTab(tabInput.id, tabArg);
1507
-
1508
- // Read the written meta.json from disk using new flat path
1509
- const sanitizedTabId = sanitizeId(tabInput.id);
1510
- const metaPath = join(tmpDir, 'tabs', sanitizedTabId, 'meta.json');
1511
-
1512
- expect(efs(metaPath)).toBe(true);
1513
- const meta = JSON.parse(rfs(metaPath, 'utf-8'));
1514
-
1515
- // Required fields must be present
1516
- expect(meta.id).toBeDefined();
1517
- expect(meta.id).not.toBeNull();
1518
- expect(meta.connectedAt).toBeDefined();
1519
- expect(meta.connectedAt).not.toBeNull();
1520
-
1521
- // Optional fields: present iff provided to upsertTab
1522
- if (tabInput.userAgent !== undefined) {
1523
- expect(meta.userAgent).toBeDefined();
1524
- expect(meta.userAgent).toBe(tabInput.userAgent);
1525
- } else {
1526
- expect(meta.userAgent).toBeUndefined();
1527
- }
1528
-
1529
- await s.close();
1530
- } finally {
1531
- cleanup(tmpDir);
1532
- }
1533
- },
1534
- ),
1535
- { numRuns: 100 },
1536
- );
1537
- });
1538
- });