@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,1708 +0,0 @@
1
- import { describe, expect, it, vi, afterEach } from 'vitest';
2
- import { WebSocket } from 'ws';
3
- import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
6
-
7
- function dirSize(dir: string): number {
8
- let total = 0;
9
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
10
- const p = join(dir, entry.name);
11
- total += entry.isDirectory() ? dirSize(p) : statSync(p).size;
12
- }
13
- return total;
14
- }
15
- import { Bridge, defaultDataDir } from './bridge.js';
16
- import { JsonlStore, JsonTaskStore, type IStore } from './store/index.js';
17
- import {
18
- EVENT_NAME,
19
- PROTOCOL_VERSION,
20
- type RrwebChunkPayload,
21
- type EventFrame,
22
- type Frame,
23
- type HelloAckFrame,
24
- type ResponseFrame,
25
- type TaskSubmitPayload,
26
- } from '@harness-fe/protocol';
27
-
28
- async function spawnBridge(): Promise<Bridge> {
29
- // store: null, taskStore: null → no persistence in tests
30
- const bridge = new Bridge({ port: 0, host: '127.0.0.1', store: null, taskStore: null });
31
- // ws library: port=0 → ephemeral assigned port; we read address() after listening.
32
- await bridge.start();
33
- return bridge;
34
- }
35
-
36
- function getPort(bridge: Bridge): number {
37
- const port = bridge.getBoundPort();
38
- if (!port) throw new Error('no address');
39
- return port;
40
- }
41
-
42
- /**
43
- * Connect a vite-plugin first (to create an active session), then connect a runtime-client.
44
- * Returns both WebSocket connections and the runtime-client ack.
45
- */
46
- async function fakeClientWithSession(
47
- port: number,
48
- opts: { tabId?: string; projectId?: string; sessionId?: string } = {},
49
- ): Promise<{ pluginWs: WebSocket; ws: WebSocket; ack: HelloAckFrame }> {
50
- const projectId = opts.projectId ?? 'demo';
51
- // First connect vite-plugin to create an active session
52
- const pluginWs = new WebSocket(`ws://127.0.0.1:${port}`);
53
- await new Promise<void>((resolve, reject) => {
54
- pluginWs.once('open', () => resolve());
55
- pluginWs.once('error', reject);
56
- });
57
- pluginWs.send(JSON.stringify({
58
- type: 'hello',
59
- id: 'hp1',
60
- role: 'vite-plugin',
61
- projectId,
62
- page: { url: 'http://localhost:5173/', title: 'Demo' },
63
- }));
64
- // Wait for plugin ack
65
- await new Promise<void>((resolve, reject) => {
66
- const timer = setTimeout(() => reject(new Error('plugin hello.ack timeout')), 1000);
67
- pluginWs.once('message', () => { clearTimeout(timer); resolve(); });
68
- });
69
- // Now connect runtime-client
70
- const { ws, ack } = await fakeClient(port, 'runtime-client', opts);
71
- return { pluginWs, ws, ack };
72
- }
73
-
74
- async function fakeClient(
75
- port: number,
76
- role: 'runtime-client' | 'vite-plugin',
77
- opts: { tabId?: string; projectId?: string; sessionId?: string } = {},
78
- ): Promise<{ ws: WebSocket; ack: HelloAckFrame }> {
79
- const ws = new WebSocket(`ws://127.0.0.1:${port}`);
80
- await new Promise<void>((resolve, reject) => {
81
- ws.once('open', () => resolve());
82
- ws.once('error', reject);
83
- });
84
- const sessionId = role === 'runtime-client' ? (opts.sessionId ?? 'sess-1') : undefined;
85
- ws.send(
86
- JSON.stringify({
87
- type: 'hello',
88
- id: 'h1',
89
- role,
90
- projectId: opts.projectId ?? 'demo',
91
- tabId: opts.tabId,
92
- sessionId,
93
- page: { url: 'http://localhost:5173/', title: 'Demo' },
94
- }),
95
- );
96
- const ack = await new Promise<HelloAckFrame>((resolve, reject) => {
97
- const timer = setTimeout(() => reject(new Error('hello.ack timeout')), 1000);
98
- ws.once('message', (raw) => {
99
- clearTimeout(timer);
100
- resolve(JSON.parse(raw.toString()) as HelloAckFrame);
101
- });
102
- });
103
- return { ws, ack };
104
- }
105
-
106
- describe('Bridge — auto-purge scheduler', () => {
107
- it('runs store.purge() on start when enabled, with policy passed through', async () => {
108
- const calls: Array<unknown> = [];
109
- const fakeStore = {
110
- purge: (policy: unknown) => {
111
- calls.push(policy);
112
- return {
113
- sessionsDeleted: 0,
114
- recordingsDeleted: 0,
115
- exportsDeleted: 0,
116
- bytesFreed: 0,
117
- };
118
- },
119
- } as unknown as IStore;
120
-
121
- const bridge = new Bridge({
122
- port: 0,
123
- host: '127.0.0.1',
124
- store: fakeStore as unknown as IStore,
125
- taskStore: null,
126
- autoPurge: {
127
- enabled: true,
128
- intervalMs: 9_999_999, // periodic timer is unref'd; we only assert startup call here
129
- policy: { maxAgeDays: 1 },
130
- },
131
- });
132
- try {
133
- await bridge.start();
134
- // start() defers the initial purge via setImmediate; yield twice.
135
- await new Promise((resolve) => setTimeout(resolve, 20));
136
- expect(calls).toHaveLength(1);
137
- expect(calls[0]).toEqual({ maxAgeDays: 1 });
138
- } finally {
139
- await bridge.stop();
140
- }
141
- });
142
-
143
- it('skips startup purge when skipInitial is set', async () => {
144
- let count = 0;
145
- const fakeStore = {
146
- purge: () => {
147
- count++;
148
- return {
149
- sessionsDeleted: 0,
150
- recordingsDeleted: 0,
151
- exportsDeleted: 0,
152
- bytesFreed: 0,
153
- };
154
- },
155
- } as unknown as IStore;
156
-
157
- const bridge = new Bridge({
158
- port: 0,
159
- host: '127.0.0.1',
160
- store: fakeStore,
161
- taskStore: null,
162
- autoPurge: { enabled: true, intervalMs: 9_999_999, skipInitial: true },
163
- });
164
- try {
165
- await bridge.start();
166
- await new Promise((resolve) => setTimeout(resolve, 20));
167
- expect(count).toBe(0);
168
- } finally {
169
- await bridge.stop();
170
- }
171
- });
172
-
173
- it('does not crash daemon when store.purge throws', async () => {
174
- const fakeStore = {
175
- purge: () => {
176
- throw new Error('disk full');
177
- },
178
- } as unknown as IStore;
179
-
180
- const bridge = new Bridge({
181
- port: 0,
182
- host: '127.0.0.1',
183
- store: fakeStore,
184
- taskStore: null,
185
- autoPurge: { enabled: true, intervalMs: 9_999_999 },
186
- });
187
- try {
188
- await expect(bridge.start()).resolves.toBeUndefined();
189
- await new Promise((resolve) => setTimeout(resolve, 20));
190
- // bridge is still listening
191
- expect(bridge.getBoundPort()).toBeGreaterThan(0);
192
- } finally {
193
- await bridge.stop();
194
- }
195
- });
196
-
197
- it('end-to-end: real JsonlStore + auto-purge shrinks disk usage', async () => {
198
- // Real store on temp dir + real Bridge. Proves disk usage actually
199
- // drops (not just that purge() returns numbers).
200
- const dir = mkdtempSync(join(tmpdir(), 'autopurge-int-'));
201
- const store = new JsonlStore(dir);
202
- try {
203
- const { randomUUID } = await import('node:crypto');
204
- for (let i = 0; i < 10; i++) {
205
- const sessionId = randomUUID();
206
- store.upsertTab(`t-${i}`, { connectedAt: Date.now() });
207
- store.upsertSession(sessionId, {
208
- tabId: `t-${i}`,
209
- startedAt: Date.now(),
210
- participants: [{ projectId: `proj-${i}`, joinedAt: Date.now() }],
211
- });
212
- store.appendEvent(sessionId, {
213
- ts: Date.now(),
214
- t: 'log',
215
- d: { msg: 'x'.repeat(2048) },
216
- });
217
- }
218
- await store.flush();
219
- const before = dirSize(dir);
220
- expect(before).toBeGreaterThan(10_000);
221
-
222
- const bridge = new Bridge({
223
- port: 0,
224
- host: '127.0.0.1',
225
- store,
226
- taskStore: null,
227
- autoPurge: {
228
- enabled: true,
229
- intervalMs: 9_999_999,
230
- policy: { maxAgeDays: 0 }, // wipe everything older than 0 days
231
- },
232
- });
233
- await bridge.start();
234
- await new Promise((resolve) => setTimeout(resolve, 50));
235
- await bridge.stop();
236
-
237
- const after = dirSize(dir);
238
- expect(after).toBeLessThan(before);
239
- } finally {
240
- await store.close();
241
- rmSync(dir, { recursive: true, force: true });
242
- }
243
- });
244
-
245
- it('respects enabled:false (no purge runs)', async () => {
246
- let count = 0;
247
- const fakeStore = {
248
- purge: () => {
249
- count++;
250
- return {
251
- sessionsDeleted: 0,
252
- recordingsDeleted: 0,
253
- exportsDeleted: 0,
254
- bytesFreed: 0,
255
- };
256
- },
257
- } as unknown as IStore;
258
-
259
- const bridge = new Bridge({
260
- port: 0,
261
- host: '127.0.0.1',
262
- store: fakeStore,
263
- taskStore: null,
264
- autoPurge: { enabled: false },
265
- });
266
- try {
267
- await bridge.start();
268
- await new Promise((resolve) => setTimeout(resolve, 20));
269
- expect(count).toBe(0);
270
- } finally {
271
- await bridge.stop();
272
- }
273
- });
274
- });
275
-
276
- describe('Bridge', () => {
277
- it('handshakes a runtime-client and registers it', async () => {
278
- const bridge = await spawnBridge();
279
- try {
280
- const port = getPort(bridge);
281
- const { ack } = await fakeClientWithSession(port, {
282
- tabId: 't-1',
283
- projectId: 'demo',
284
- });
285
- expect(ack.type).toBe('hello.ack');
286
- expect(ack.tabId).toBe('t-1');
287
- expect(ack.serverVersion).toBe(PROTOCOL_VERSION);
288
- expect(bridge.router.listTabs()).toHaveLength(1);
289
- } finally {
290
- await bridge.stop();
291
- }
292
- });
293
-
294
- it('rejects a runtime-client hello missing sessionId', async () => {
295
- const bridge = await spawnBridge();
296
- try {
297
- const port = getPort(bridge);
298
- const ws = new WebSocket(`ws://127.0.0.1:${port}`);
299
- await new Promise<void>((resolve, reject) => {
300
- ws.once('open', () => resolve());
301
- ws.once('error', reject);
302
- });
303
- ws.send(
304
- JSON.stringify({
305
- type: 'hello',
306
- id: 'h1',
307
- role: 'runtime-client',
308
- projectId: 'demo',
309
- tabId: 't-1',
310
- // sessionId intentionally omitted
311
- }),
312
- );
313
- const ack = await new Promise<HelloAckFrame>((resolve, reject) => {
314
- const timer = setTimeout(() => reject(new Error('hello.ack timeout')), 1000);
315
- ws.once('message', (raw) => {
316
- clearTimeout(timer);
317
- resolve(JSON.parse(raw.toString()) as HelloAckFrame);
318
- });
319
- });
320
- expect(ack.type).toBe('hello.ack');
321
- expect(ack.error).toMatch(/sessionId/);
322
- expect(bridge.router.listTabs()).toHaveLength(0);
323
- ws.close();
324
- } finally {
325
- await bridge.stop();
326
- }
327
- });
328
-
329
- it('sendCommand round-trips request and response', async () => {
330
- const bridge = await spawnBridge();
331
- try {
332
- const port = getPort(bridge);
333
- const { ws } = await fakeClientWithSession(port, {
334
- tabId: 't-1',
335
- projectId: 'demo',
336
- });
337
- // Echo handler
338
- ws.on('message', (raw) => {
339
- const frame = JSON.parse(raw.toString()) as Frame;
340
- if (frame.type !== 'command') return;
341
- const resp: ResponseFrame = {
342
- type: 'response',
343
- id: frame.id,
344
- ok: true,
345
- result: { echoed: frame.args },
346
- };
347
- ws.send(JSON.stringify(resp));
348
- });
349
- const out = await bridge.sendCommand('page.click', { selector: { component: 'X' } });
350
- expect(out).toEqual({ echoed: { selector: { component: 'X' } } });
351
- } finally {
352
- await bridge.stop();
353
- }
354
- });
355
-
356
- it('sendCommand rejects when client has no tab connected', async () => {
357
- const bridge = await spawnBridge();
358
- try {
359
- await expect(bridge.sendCommand('page.click', {})).rejects.toThrow(
360
- /no runtime-client/,
361
- );
362
- } finally {
363
- await bridge.stop();
364
- }
365
- });
366
-
367
- it('sendCommand surfaces ok=false errors', async () => {
368
- const bridge = await spawnBridge();
369
- try {
370
- const port = getPort(bridge);
371
- const { ws } = await fakeClientWithSession(port, { tabId: 't-1', projectId: 'demo' });
372
- ws.on('message', (raw) => {
373
- const frame = JSON.parse(raw.toString()) as Frame;
374
- if (frame.type !== 'command') return;
375
- ws.send(
376
- JSON.stringify({
377
- type: 'response',
378
- id: frame.id,
379
- ok: false,
380
- error: { code: 'NOT_FOUND', message: 'no such element' },
381
- } satisfies ResponseFrame),
382
- );
383
- });
384
- await expect(bridge.sendCommand('page.click', {})).rejects.toThrow(
385
- /no such element/,
386
- );
387
- } finally {
388
- await bridge.stop();
389
- }
390
- });
391
-
392
- it('records task.submit events into the task queue', async () => {
393
- const bridge = await spawnBridge();
394
- try {
395
- const port = getPort(bridge);
396
- const { ws } = await fakeClientWithSession(port, {
397
- tabId: 't-1',
398
- projectId: 'demo',
399
- });
400
- const payload: TaskSubmitPayload = {
401
- question: 'why does increment break?',
402
- url: 'http://localhost:5173/',
403
- selector: { comp: 'IncrementBtn', loc: 'src/App.tsx:24:16' },
404
- element: {
405
- tag: 'button',
406
- outerHTML: '<button>Increment</button>',
407
- rect: { x: 10, y: 20, width: 80, height: 32 },
408
- },
409
- };
410
- ws.send(
411
- JSON.stringify({
412
- type: 'event',
413
- id: 'e1',
414
- tabId: 't-1',
415
- projectId: 'demo',
416
- name: EVENT_NAME.TASK_SUBMIT,
417
- ts: Date.now(),
418
- payload,
419
- } satisfies EventFrame),
420
- );
421
- await new Promise((r) => setTimeout(r, 30));
422
-
423
- const pending = await bridge.listTasks({ status: 'pending' });
424
- expect(pending).toHaveLength(1);
425
- expect(pending[0].question).toBe(payload.question);
426
- expect(pending[0].selector.comp).toBe('IncrementBtn');
427
-
428
- const claimed = await bridge.claimTask(pending[0].id);
429
- expect(claimed?.status).toBe('claimed');
430
- expect(claimed?.claimedAt).toBeTypeOf('number');
431
- expect(await bridge.listTasks({ status: 'pending' })).toHaveLength(0);
432
- expect(await bridge.listTasks({ status: 'claimed' })).toHaveLength(1);
433
-
434
- const resolved = await bridge.resolveTask(pending[0].id, 'fixed setCount closure');
435
- expect(resolved?.status).toBe('resolved');
436
- expect(resolved?.note).toBe('fixed setCount closure');
437
- expect(await bridge.listTasks({ status: 'resolved' })).toHaveLength(1);
438
- } finally {
439
- await bridge.stop();
440
- }
441
- });
442
-
443
- it('ignores task.submit events with invalid payload', async () => {
444
- const bridge = await spawnBridge();
445
- try {
446
- const port = getPort(bridge);
447
- const { ws } = await fakeClientWithSession(port, { tabId: 't-1', projectId: 'demo' });
448
- ws.send(
449
- JSON.stringify({
450
- type: 'event',
451
- id: 'e2',
452
- tabId: 't-1',
453
- name: EVENT_NAME.TASK_SUBMIT,
454
- ts: Date.now(),
455
- payload: { garbage: true },
456
- } satisfies EventFrame),
457
- );
458
- await new Promise((r) => setTimeout(r, 30));
459
- expect(await bridge.listTasks({ status: 'all' })).toHaveLength(0);
460
- } finally {
461
- await bridge.stop();
462
- }
463
- });
464
-
465
- it('deduplicates repeat task.submit events with the same tab + selector + question', async () => {
466
- const bridge = await spawnBridge();
467
- try {
468
- const port = getPort(bridge);
469
- const { ws } = await fakeClientWithSession(port, {
470
- tabId: 't-dedup',
471
- projectId: 'demo',
472
- });
473
- const payload: TaskSubmitPayload = {
474
- question: 'fix this please',
475
- url: 'http://localhost:5173/',
476
- selector: { comp: 'IncrementBtn', loc: 'src/App.tsx:24:16' },
477
- element: { tag: 'button', outerHTML: '<button>+</button>' },
478
- };
479
- const frame = (id: string): EventFrame => ({
480
- type: 'event',
481
- id,
482
- tabId: 't-dedup',
483
- projectId: 'demo',
484
- name: EVENT_NAME.TASK_SUBMIT,
485
- ts: Date.now(),
486
- payload,
487
- });
488
- ws.send(JSON.stringify(frame('e1')));
489
- ws.send(JSON.stringify(frame('e2')));
490
- ws.send(JSON.stringify(frame('e3')));
491
- await new Promise((r) => setTimeout(r, 30));
492
- expect(await bridge.listTasks({ status: 'pending' })).toHaveLength(1);
493
- } finally {
494
- await bridge.stop();
495
- }
496
- });
497
-
498
- it('persists tasks across bridge restarts via JsonTaskStore', async () => {
499
- const dir = mkdtempSync(join(tmpdir(), 'morphix-bridge-test-'));
500
- try {
501
- const taskStore1 = new JsonTaskStore(dir);
502
- const b1 = new Bridge({ port: 0, host: '127.0.0.1', store: null, taskStore: taskStore1 });
503
- await b1.start();
504
- const port = getPort(b1);
505
- // Connect vite-plugin first to create an active session context
506
- const { ws } = await fakeClientWithSession(port, {
507
- tabId: 't-persist',
508
- projectId: 'demo',
509
- });
510
- const payload: TaskSubmitPayload = {
511
- question: 'persist me',
512
- url: 'http://localhost:5173/',
513
- selector: { comp: 'EchoInput' },
514
- element: { tag: 'input', outerHTML: '<input />' },
515
- };
516
- ws.send(
517
- JSON.stringify({
518
- type: 'event',
519
- id: 'p1',
520
- tabId: 't-persist',
521
- projectId: 'demo',
522
- name: EVENT_NAME.TASK_SUBMIT,
523
- ts: Date.now(),
524
- payload,
525
- } satisfies EventFrame),
526
- );
527
- await new Promise((r) => setTimeout(r, 30));
528
- expect(await b1.listTasks({ status: 'pending' })).toHaveLength(1);
529
- await b1.stop();
530
- // Verify tasks.json was written for the 'demo' project
531
- expect(existsSync(join(dir, 'demo', 'tasks.json'))).toBe(true);
532
-
533
- // Restart with a new bridge pointing to the same data dir
534
- const taskStore2 = new JsonTaskStore(dir);
535
- const b2 = new Bridge({ port: 0, host: '127.0.0.1', store: null, taskStore: taskStore2 });
536
- await b2.start();
537
- try {
538
- // Connect vite-plugin to trigger task loading for 'demo' project
539
- const port2 = getPort(b2);
540
- await fakeClient(port2, 'vite-plugin', { projectId: 'demo' });
541
- await new Promise((r) => setTimeout(r, 30));
542
- const restored = await b2.listTasks({ status: 'pending' });
543
- expect(restored).toHaveLength(1);
544
- expect(restored[0].question).toBe('persist me');
545
- } finally {
546
- await b2.stop();
547
- }
548
- } finally {
549
- rmSync(dir, { recursive: true, force: true });
550
- }
551
- });
552
-
553
- it('persists rrweb payloads outside timeline entries while keeping timeline metadata', async () => {
554
- const dir = mkdtempSync(join(tmpdir(), 'morphix-bridge-rrweb-'));
555
- const store = new JsonlStore(dir);
556
- const bridge = new Bridge({ port: 0, host: '127.0.0.1', store, taskStore: null, autoPurge: { enabled: false } });
557
- await bridge.start();
558
- try {
559
- const port = getPort(bridge);
560
- const projectId = 'rrweb-project';
561
- const tabId = 'tab-rrweb-1';
562
- const { pluginWs, ws } = await fakeClientWithSession(port, { tabId, projectId });
563
-
564
- const payload: RrwebChunkPayload = {
565
- chunkId: 'rrc_000001',
566
- startTs: 1000,
567
- endTs: 1400,
568
- eventCount: 2,
569
- events: [
570
- { type: 4, timestamp: 1000, data: { href: 'http://localhost:5173/', width: 1280, height: 720 } },
571
- { type: 3, timestamp: 1400, data: { source: 5, id: 1, text: 'abc', isChecked: false } },
572
- ],
573
- };
574
-
575
- ws.send(JSON.stringify({
576
- type: 'event',
577
- id: 'rr1',
578
- tabId,
579
- projectId,
580
- name: EVENT_NAME.RRWEB,
581
- ts: 1500,
582
- payload,
583
- } satisfies EventFrame));
584
-
585
- await new Promise((r) => setTimeout(r, 50));
586
- await store.close();
587
-
588
- const sessionId = store.listSessions({ projectId, limit: 1 })[0]?.id;
589
- expect(sessionId).toBeTruthy();
590
-
591
- const rrwebLine = store.tail(sessionId!, { n: 20 }).find((line) => line.t === 'rrweb');
592
- expect(rrwebLine).toBeTruthy();
593
- expect(rrwebLine?.d).toMatchObject({
594
- chunkId: payload.chunkId,
595
- eventCount: payload.eventCount,
596
- });
597
- expect((rrwebLine?.d as { events?: unknown[] } | undefined)?.events).toBeUndefined();
598
-
599
- // 0.4.0: recordings live at sessions/{sessionId}/recording.jsonl (flat layout).
600
- const recordingPath = join(dir, 'sessions', sessionId!, 'recording.jsonl');
601
- expect(existsSync(recordingPath)).toBe(true);
602
- const recordingLines = readFileSync(recordingPath, 'utf-8')
603
- .split('\n')
604
- .filter((l) => l.trim());
605
- expect(recordingLines).toHaveLength(1);
606
- const recordingChunk = JSON.parse(recordingLines[0]);
607
- expect(recordingChunk.chunkId).toBe(payload.chunkId);
608
- expect(recordingChunk.events).toHaveLength(2);
609
-
610
- pluginWs.close();
611
- ws.close();
612
- } finally {
613
- await bridge.stop();
614
- rmSync(dir, { recursive: true, force: true });
615
- }
616
- });
617
-
618
- it('derives rrweb markers from errors, failed network events, and task submissions', async () => {
619
- const dir = mkdtempSync(join(tmpdir(), 'morphix-bridge-markers-'));
620
- const store = new JsonlStore(dir);
621
- const bridge = new Bridge({ port: 0, host: '127.0.0.1', store, taskStore: null, autoPurge: { enabled: false } });
622
- await bridge.start();
623
- try {
624
- const port = getPort(bridge);
625
- const projectId = 'marker-project';
626
- const tabId = 'tab-marker-1';
627
- const { pluginWs, ws } = await fakeClientWithSession(port, { tabId, projectId });
628
-
629
- ws.send(JSON.stringify({
630
- type: 'event',
631
- id: 'err1',
632
- tabId,
633
- projectId,
634
- name: 'error',
635
- ts: 1100,
636
- payload: { message: 'Unhandled boom' },
637
- } satisfies EventFrame));
638
-
639
- ws.send(JSON.stringify({
640
- type: 'event',
641
- id: 'net1',
642
- tabId,
643
- projectId,
644
- name: 'network',
645
- ts: 1200,
646
- payload: { method: 'POST', url: '/api/save', status: 500 },
647
- } satisfies EventFrame));
648
-
649
- ws.send(JSON.stringify({
650
- type: 'event',
651
- id: 'task1',
652
- tabId,
653
- projectId,
654
- name: EVENT_NAME.TASK_SUBMIT,
655
- ts: 1300,
656
- payload: {
657
- question: 'why did save fail?',
658
- url: 'http://localhost:5173/',
659
- selector: { comp: 'SaveBtn' },
660
- element: { tag: 'button', outerHTML: '<button>Save</button>' },
661
- },
662
- } satisfies EventFrame));
663
-
664
- await new Promise((r) => setTimeout(r, 50));
665
- await store.close();
666
-
667
- const sessionId = store.listSessions({ projectId, limit: 1 })[0]?.id;
668
- expect(sessionId).toBeTruthy();
669
- const markers = store.tail(sessionId!, { n: 20, type: 'rrweb:marker' });
670
- expect(markers).toHaveLength(3);
671
- expect(markers.map((marker) => (marker.d as { kind: string }).kind)).toEqual([
672
- 'error',
673
- 'network',
674
- 'task',
675
- ]);
676
- expect((markers[1].d as { label: string }).label).toContain('/api/save');
677
-
678
- pluginWs.close();
679
- ws.close();
680
- } finally {
681
- await bridge.stop();
682
- rmSync(dir, { recursive: true, force: true });
683
- }
684
- });
685
-
686
- it('fans out event frames to listeners', async () => {
687
- const bridge = await spawnBridge();
688
- const received: EventFrame[] = [];
689
- bridge.onEvent((e) => received.push(e));
690
- try {
691
- const port = getPort(bridge);
692
- const { ws } = await fakeClientWithSession(port, { tabId: 't-1', projectId: 'demo' });
693
- ws.send(
694
- JSON.stringify({
695
- type: 'event',
696
- id: 'e1',
697
- tabId: 't-1',
698
- name: 'console',
699
- ts: Date.now(),
700
- payload: { level: 'log', args: ['hi'] },
701
- } satisfies EventFrame),
702
- );
703
- await new Promise((r) => setTimeout(r, 30));
704
- expect(received).toHaveLength(1);
705
- expect(received[0].name).toBe('console');
706
- } finally {
707
- await bridge.stop();
708
- }
709
- });
710
-
711
- it('accepts runtime-client hello with no prior plugin and opens its own session (plugin-less mode)', async () => {
712
- // This is the standard mode for the @harness-fe/next + jsxImportSource
713
- // integration and for any production / staging deployment: the bundler
714
- // plugin is absent, so the runtime-client must bootstrap the project
715
- // session on its own. We require the daemon to (a) accept the hello,
716
- // (b) register the tab, and (c) open a store session with
717
- // peerRole='runtime-client' so subsequent events have a place to land.
718
- const dir = mkdtempSync(join(tmpdir(), 'morphix-bridge-plugin-less-'));
719
- const store = new JsonlStore(dir);
720
- const bridge = new Bridge({ port: 0, host: '127.0.0.1', store, taskStore: null, autoPurge: { enabled: false } });
721
- await bridge.start();
722
- try {
723
- const port = getPort(bridge);
724
- const { ack } = await fakeClient(port, 'runtime-client', {
725
- tabId: 't-bootstrap',
726
- projectId: 'plugin-less-project',
727
- });
728
- expect(ack.type).toBe('hello.ack');
729
- expect(ack.error).toBeUndefined();
730
- expect(ack.tabId).toBe('t-bootstrap');
731
- expect(bridge.router.listTabs()).toHaveLength(1);
732
- const sessions = store.listSessions({ projectId: 'plugin-less-project', limit: 10 });
733
- expect(sessions).toHaveLength(1);
734
- // In the new model, peerRole is not stored on SessionMeta; verify session was created
735
- expect(sessions[0]?.tabId).toBe('t-bootstrap');
736
- } finally {
737
- await bridge.stop();
738
- store.close();
739
- rmSync(dir, { recursive: true, force: true });
740
- }
741
- });
742
-
743
- it('accepts runtime-client hello when an active vite-plugin session exists (Req 3.3)', async () => {
744
- const dir = mkdtempSync(join(tmpdir(), 'morphix-bridge-req33-'));
745
- const store = new JsonlStore(dir);
746
- const bridge = new Bridge({ port: 0, host: '127.0.0.1', store, taskStore: null, autoPurge: { enabled: false } });
747
- await bridge.start();
748
- try {
749
- const port = getPort(bridge);
750
- // First connect a vite-plugin to create an active session
751
- const { ws: pluginWs } = await fakeClient(port, 'vite-plugin', {
752
- projectId: 'active-project',
753
- });
754
- // Now connect a runtime-client for the same project
755
- const { ack } = await fakeClient(port, 'runtime-client', {
756
- tabId: 't-valid',
757
- projectId: 'active-project',
758
- });
759
- expect(ack.type).toBe('hello.ack');
760
- expect(ack.error).toBeUndefined();
761
- expect(ack.tabId).toBe('t-valid');
762
- // Tab should be registered
763
- expect(bridge.router.listTabs()).toHaveLength(1);
764
- pluginWs.close();
765
- } finally {
766
- await bridge.stop();
767
- store.close();
768
- rmSync(dir, { recursive: true, force: true });
769
- }
770
- });
771
- });
772
-
773
- // ─── Integration Tests ────────────────────────────────────────────────────────
774
-
775
- describe('Integration: end-to-end event persistence (Task 14.1)', () => {
776
- // Requirements: 4.1–4.8, 5.1–5.6
777
- it('events sent by runtime-client appear in JSONL files on disk with correct seq values', async () => {
778
- const dir = mkdtempSync(join(tmpdir(), 'morphix-bridge-int14-1-'));
779
- const store = new JsonlStore(dir);
780
- const bridge = new Bridge({ port: 0, host: '127.0.0.1', store, taskStore: null, autoPurge: { enabled: false } });
781
- await bridge.start();
782
- try {
783
- const port = getPort(bridge);
784
- const projectId = 'int-test-project';
785
-
786
- // Connect vite-plugin (creates a session)
787
- const { ws: pluginWs } = await fakeClient(port, 'vite-plugin', { projectId });
788
- // Wait briefly for session to be registered
789
- await new Promise((r) => setTimeout(r, 20));
790
-
791
- // Connect runtime-client (registers a tab)
792
- const { ws: runtimeWs } = await fakeClient(port, 'runtime-client', {
793
- tabId: 'tab-int-1',
794
- projectId,
795
- });
796
- await new Promise((r) => setTimeout(r, 20));
797
-
798
- // Send three event frames from the runtime-client
799
- const sentEvents = [
800
- { type: 'event', id: 'ev1', tabId: 'tab-int-1', projectId, name: 'log', ts: 1000, payload: { level: 'info', args: ['hello'] } },
801
- { type: 'event', id: 'ev2', tabId: 'tab-int-1', projectId, name: 'err', ts: 2000, payload: { message: 'boom' } },
802
- { type: 'event', id: 'ev3', tabId: 'tab-int-1', projectId, name: 'hmr', ts: 3000, payload: { file: 'App.tsx' } },
803
- ];
804
- for (const ev of sentEvents) {
805
- runtimeWs.send(JSON.stringify(ev));
806
- }
807
-
808
- // Allow events to be processed
809
- await new Promise((r) => setTimeout(r, 50));
810
-
811
- // Flush the store to ensure all events are written to disk
812
- await store.close();
813
-
814
- // Find the session directory
815
- const sessions = store.listSessions({ projectId });
816
- expect(sessions.length).toBeGreaterThanOrEqual(1);
817
- const sessionId = sessions[0].id;
818
-
819
- // Read the session-level timeline.jsonl directly from disk (flat layout)
820
- const timelinePath = join(dir, 'sessions', sessionId, 'timeline.jsonl');
821
- expect(existsSync(timelinePath)).toBe(true);
822
-
823
- const lines = readFileSync(timelinePath, 'utf-8')
824
- .split('\n')
825
- .filter((l) => l.trim());
826
-
827
- // Should have at least 3 events (the ones we sent)
828
- expect(lines.length).toBeGreaterThanOrEqual(3);
829
-
830
- const parsedEvents = lines.map((l) => JSON.parse(l) as { seq: number; t: string });
831
-
832
- // Verify seq values are strictly increasing across all events in the session timeline
833
- // (Note: seq values may not be consecutive by 1 because the same counter is shared
834
- // between session-level and tab-level writes for dual-write events)
835
- for (let i = 1; i < parsedEvents.length; i++) {
836
- expect(parsedEvents[i].seq).toBeGreaterThan(parsedEvents[i - 1].seq);
837
- }
838
-
839
- // Verify all seq values are non-negative integers
840
- for (const ev of parsedEvents) {
841
- expect(ev.seq).toBeGreaterThanOrEqual(0);
842
- expect(Number.isInteger(ev.seq)).toBe(true);
843
- }
844
-
845
- // Verify the event types we sent are present
846
- const types = parsedEvents.map((e) => e.t);
847
- expect(types).toContain('log');
848
- expect(types).toContain('err');
849
- expect(types).toContain('hmr');
850
-
851
- // In the v0.4.0 flat layout, there is no separate tab-level timeline.
852
- // All events for a session land in sessions/{sessionId}/timeline.jsonl.
853
- // The session should be associated with our tab.
854
- const tabSession = store.listSessions({ tabId: 'tab-int-1' });
855
- expect(tabSession.length).toBeGreaterThanOrEqual(1);
856
-
857
- pluginWs.close();
858
- runtimeWs.close();
859
- } finally {
860
- await bridge.stop();
861
- rmSync(dir, { recursive: true, force: true });
862
- }
863
- });
864
- });
865
-
866
- describe('Integration: session grace period (Task 14.2)', () => {
867
- // Requirements: 2.2, 2.3, 2.4
868
- afterEach(() => {
869
- vi.useRealTimers();
870
- });
871
-
872
- it('reconnecting within 30s reuses the same sessionId', async () => {
873
- vi.useFakeTimers({ shouldAdvanceTime: true });
874
- const dir = mkdtempSync(join(tmpdir(), 'morphix-bridge-int14-2a-'));
875
- const store = new JsonlStore(dir);
876
- const bridge = new Bridge({ port: 0, host: '127.0.0.1', store, taskStore: null, autoPurge: { enabled: false } });
877
- await bridge.start();
878
- try {
879
- const port = getPort(bridge);
880
- const projectId = 'grace-project';
881
-
882
- // Connect vite-plugin — creates build
883
- const { ws: pluginWs1 } = await fakeClient(port, 'vite-plugin', { projectId });
884
- await vi.runAllTimersAsync();
885
- await new Promise((r) => setTimeout(r, 10));
886
-
887
- // Get the build ID created
888
- const builds1 = store.listBuilds(projectId);
889
- expect(builds1.length).toBe(1);
890
- const originalBuildId = builds1[0].id;
891
-
892
- // Disconnect the vite-plugin — starts 30s grace period
893
- pluginWs1.close();
894
- // Allow close event to propagate
895
- await new Promise((r) => setTimeout(r, 30));
896
-
897
- // Advance time by 29 seconds (within grace period)
898
- vi.advanceTimersByTime(29_000);
899
- await new Promise((r) => setTimeout(r, 10));
900
-
901
- // Reconnect vite-plugin within grace period
902
- const { ws: pluginWs2 } = await fakeClient(port, 'vite-plugin', { projectId });
903
- await new Promise((r) => setTimeout(r, 30));
904
-
905
- // The build should be the same (reused)
906
- const builds2 = store.listBuilds(projectId);
907
- expect(builds2.length).toBe(1);
908
- expect(builds2[0].id).toBe(originalBuildId);
909
- // Build should NOT have endedAt set (still active)
910
- expect(builds2[0].endedAt).toBeUndefined();
911
-
912
- pluginWs2.close();
913
- } finally {
914
- await bridge.stop();
915
- await store.close();
916
- rmSync(dir, { recursive: true, force: true });
917
- }
918
- });
919
-
920
- it('reconnecting after 30s creates a new session', async () => {
921
- vi.useFakeTimers({ shouldAdvanceTime: true });
922
- const dir = mkdtempSync(join(tmpdir(), 'morphix-bridge-int14-2b-'));
923
- const store = new JsonlStore(dir);
924
- const bridge = new Bridge({ port: 0, host: '127.0.0.1', store, taskStore: null, autoPurge: { enabled: false } });
925
- await bridge.start();
926
- try {
927
- const port = getPort(bridge);
928
- const projectId = 'grace-project-expired';
929
-
930
- // Connect vite-plugin — creates build
931
- const { ws: pluginWs1 } = await fakeClient(port, 'vite-plugin', { projectId });
932
- await vi.runAllTimersAsync();
933
- await new Promise((r) => setTimeout(r, 10));
934
-
935
- // Get the build ID created
936
- const builds1 = store.listBuilds(projectId);
937
- expect(builds1.length).toBe(1);
938
- const originalBuildId = builds1[0].id;
939
-
940
- // Disconnect the vite-plugin — starts 30s grace period
941
- pluginWs1.close();
942
- await new Promise((r) => setTimeout(r, 30));
943
-
944
- // Advance time by 31 seconds (past grace period) — timer fires, build is closed
945
- vi.advanceTimersByTime(31_000);
946
- await vi.runAllTimersAsync();
947
- await new Promise((r) => setTimeout(r, 30));
948
-
949
- // Reconnect vite-plugin after grace period expired
950
- const { ws: pluginWs2 } = await fakeClient(port, 'vite-plugin', { projectId });
951
- await new Promise((r) => setTimeout(r, 30));
952
-
953
- // A new build should have been created
954
- const builds2 = store.listBuilds(projectId);
955
- expect(builds2.length).toBe(2);
956
- const newBuildId = builds2[0].id; // sorted by builtAt desc
957
- expect(newBuildId).not.toBe(originalBuildId);
958
-
959
- // The original build should now have endedAt set
960
- const originalBuild = builds2.find((b) => b.id === originalBuildId);
961
- expect(originalBuild?.endedAt).toBeDefined();
962
-
963
- pluginWs2.close();
964
- } finally {
965
- await bridge.stop();
966
- await store.close();
967
- rmSync(dir, { recursive: true, force: true });
968
- }
969
- });
970
- });
971
-
972
- describe('Integration: startup recovery (Task 14.3)', () => {
973
- // Requirements: 2.6
974
- it('new Bridge with new JsonlStore pointing to same dir sees existing sessions and orphaned sessions have endedAt', async () => {
975
- const dir = mkdtempSync(join(tmpdir(), 'morphix-bridge-int14-3-'));
976
-
977
- // ── First Bridge: create sessions ──────────────────────────────────
978
- // We create sessions directly via the store (no need for a full Bridge)
979
- // to avoid grace period complications.
980
- const store1 = new JsonlStore(dir);
981
- const { randomUUID } = await import('node:crypto');
982
-
983
- const projectId = 'recovery-project';
984
-
985
- // Create session 1 (page-load) and properly close it
986
- const closedSessionId = randomUUID();
987
- store1.upsertTab('t-recovery', { connectedAt: Date.now() });
988
- store1.upsertSession(closedSessionId, {
989
- tabId: 't-recovery',
990
- startedAt: Date.now(),
991
- participants: [{ projectId, joinedAt: Date.now() }],
992
- });
993
- store1.closeSession(closedSessionId);
994
-
995
- // Create session 2 and leave it open (orphaned — simulates a crash)
996
- const orphanedSessionId = randomUUID();
997
- store1.upsertSession(orphanedSessionId, {
998
- tabId: 't-recovery',
999
- startedAt: Date.now(),
1000
- participants: [{ projectId, joinedAt: Date.now() }],
1001
- });
1002
-
1003
- // Verify session 2 has no endedAt before recovery
1004
- const metaBefore = store1.getSession(orphanedSessionId);
1005
- expect(metaBefore?.endedAt).toBeUndefined();
1006
-
1007
- // Close the store (flush any pending writes)
1008
- await store1.close();
1009
-
1010
- // ── Second store: startup recovery ────────────────────────────────
1011
- const beforeRecovery = Date.now();
1012
- const store2 = new JsonlStore(dir);
1013
-
1014
- try {
1015
- // Both sessions should be accessible
1016
- const recoveredSessions = store2.listSessions({ projectId });
1017
- expect(recoveredSessions.length).toBe(2);
1018
-
1019
- const recoveredIds = recoveredSessions.map((s) => s.id);
1020
- expect(recoveredIds).toContain(closedSessionId);
1021
- expect(recoveredIds).toContain(orphanedSessionId);
1022
-
1023
- // The orphaned session should have endedAt set by startup recovery
1024
- const orphaned = recoveredSessions.find((s) => s.id === orphanedSessionId);
1025
- expect(orphaned).toBeDefined();
1026
- expect(orphaned!.endedAt).toBeDefined();
1027
- expect(orphaned!.endedAt!).toBeGreaterThanOrEqual(beforeRecovery);
1028
-
1029
- // The properly closed session should retain its original endedAt
1030
- const closed = recoveredSessions.find((s) => s.id === closedSessionId);
1031
- expect(closed).toBeDefined();
1032
- expect(closed!.endedAt).toBeDefined();
1033
- // Its endedAt should be before the recovery timestamp (it was closed earlier)
1034
- expect(closed!.endedAt!).toBeLessThan(beforeRecovery + 1000);
1035
- } finally {
1036
- await store2.close();
1037
- rmSync(dir, { recursive: true, force: true });
1038
- }
1039
- });
1040
-
1041
- it('new Bridge using new JsonlStore can list sessions from a previous Bridge run', async () => {
1042
- const dir = mkdtempSync(join(tmpdir(), 'morphix-bridge-int14-3b-'));
1043
-
1044
- // ── First Bridge run ───────────────────────────────────────────────
1045
- const store1 = new JsonlStore(dir);
1046
- const bridge1 = new Bridge({ port: 0, host: '127.0.0.1', store: store1, taskStore: null });
1047
- await bridge1.start();
1048
-
1049
- const projectId = 'bridge-recovery-project';
1050
- let buildId: string;
1051
-
1052
- try {
1053
- const port1 = getPort(bridge1);
1054
- // Connect vite-plugin to create a build
1055
- const { ws: pluginWs } = await fakeClient(port1, 'vite-plugin', { projectId });
1056
- await new Promise((r) => setTimeout(r, 20));
1057
-
1058
- const builds = store1.listBuilds(projectId);
1059
- expect(builds.length).toBe(1);
1060
- buildId = builds[0].id;
1061
-
1062
- pluginWs.close();
1063
- await new Promise((r) => setTimeout(r, 20));
1064
- } finally {
1065
- await bridge1.stop();
1066
- await store1.close();
1067
- }
1068
-
1069
- // ── Second Bridge run: startup recovery ───────────────────────────
1070
- const store2 = new JsonlStore(dir);
1071
- const bridge2 = new Bridge({ port: 0, host: '127.0.0.1', store: store2, taskStore: null });
1072
- await bridge2.start();
1073
-
1074
- try {
1075
- // Build from first run should be accessible in the new store
1076
- const recoveredBuild = store2.getBuild(projectId, buildId);
1077
- expect(recoveredBuild).toBeDefined();
1078
- expect(recoveredBuild!.id).toBe(buildId);
1079
- expect(recoveredBuild!.projectId).toBe(projectId);
1080
- } finally {
1081
- await bridge2.stop();
1082
- await store2.close();
1083
- rmSync(dir, { recursive: true, force: true });
1084
- }
1085
- });
1086
- });
1087
-
1088
- describe('PAGE_LOAD persistence', () => {
1089
- it('appends a LoadMeta row when a PAGE_LOAD event arrives', async () => {
1090
- const dir = mkdtempSync(join(tmpdir(), 'morphix-pageload-1-'));
1091
- const store = new JsonlStore(dir);
1092
- const bridge = new Bridge({ port: 0, host: '127.0.0.1', store, taskStore: null, autoPurge: { enabled: false } });
1093
- await bridge.start();
1094
- try {
1095
- const port = getPort(bridge);
1096
- const projectId = 'pl-project-1';
1097
- const { ws: pluginWs } = await fakeClient(port, 'vite-plugin', { projectId });
1098
- await new Promise((r) => setTimeout(r, 20));
1099
- const { ws: rcWs } = await fakeClient(port, 'runtime-client', {
1100
- projectId,
1101
- tabId: 'tab-1',
1102
- sessionId: 'sess-A',
1103
- });
1104
- await new Promise((r) => setTimeout(r, 20));
1105
-
1106
- rcWs.send(JSON.stringify({
1107
- type: 'event',
1108
- id: 'plE1',
1109
- projectId,
1110
- tabId: 'tab-1',
1111
- name: EVENT_NAME.PAGE_LOAD,
1112
- ts: 1000,
1113
- payload: {
1114
- sessionId: 'sess-A',
1115
- page: { url: 'http://x/', title: 'Demo' },
1116
- viewport: { w: 1024, h: 768, dpr: 2 },
1117
- storage: { local: { k: 'v' }, session: {}, cookie: '', truncated: false },
1118
- },
1119
- }));
1120
- await new Promise((r) => setTimeout(r, 40));
1121
-
1122
- // In the new model, LoadMeta IS SessionMeta — filter by tabId
1123
- const loads = store.listSessions({ tabId: 'tab-1' });
1124
- expect(loads).toHaveLength(1);
1125
- expect(loads[0].id).toBe('sess-A');
1126
- expect(loads[0].url).toBe('http://x/');
1127
- expect(loads[0].initial?.viewport).toEqual({ w: 1024, h: 768, dpr: 2 });
1128
- expect(loads[0].initial?.storageKeys?.local).toBe(1);
1129
- expect(loads[0].endedAt).toBeUndefined();
1130
-
1131
- rcWs.close();
1132
- pluginWs.close();
1133
- } finally {
1134
- await bridge.stop();
1135
- await store.close();
1136
- rmSync(dir, { recursive: true, force: true });
1137
- }
1138
- });
1139
-
1140
- it('closes the previous load endedAt when a refresh happens on the same tab', async () => {
1141
- // Real-browser refresh = old ws close (sets L1.endedAt = now) then
1142
- // new ws connect + PAGE_LOAD (L2 opens). The store guarantees:
1143
- // - both loads are recorded
1144
- // - L1.endedAt is set (either by close handler or by next openLoad)
1145
- // - L2 is open until its tab closes
1146
- const dir = mkdtempSync(join(tmpdir(), 'morphix-pageload-2-'));
1147
- const store = new JsonlStore(dir);
1148
- const bridge = new Bridge({ port: 0, host: '127.0.0.1', store, taskStore: null, autoPurge: { enabled: false } });
1149
- await bridge.start();
1150
- try {
1151
- const port = getPort(bridge);
1152
- const projectId = 'pl-project-2';
1153
- const { ws: pluginWs } = await fakeClient(port, 'vite-plugin', { projectId });
1154
- await new Promise((r) => setTimeout(r, 20));
1155
-
1156
- // First load
1157
- const rc1 = await fakeClient(port, 'runtime-client', {
1158
- projectId, tabId: 'tab-1', sessionId: 'L1',
1159
- });
1160
- await new Promise((r) => setTimeout(r, 20));
1161
- rc1.ws.send(JSON.stringify({
1162
- type: 'event', id: 'e1', projectId, tabId: 'tab-1',
1163
- name: EVENT_NAME.PAGE_LOAD, ts: 100,
1164
- payload: { sessionId: 'L1', page: {}, storage: { local: {}, session: {}, cookie: '' } },
1165
- }));
1166
- await new Promise((r) => setTimeout(r, 30));
1167
- rc1.ws.close();
1168
- await new Promise((r) => setTimeout(r, 30));
1169
-
1170
- // Second load — same tabId, new sessionId (simulates browser refresh)
1171
- const rc2 = await fakeClient(port, 'runtime-client', {
1172
- projectId, tabId: 'tab-1', sessionId: 'L2',
1173
- });
1174
- await new Promise((r) => setTimeout(r, 20));
1175
- const l2StartTs = Date.now();
1176
- rc2.ws.send(JSON.stringify({
1177
- type: 'event', id: 'e2', projectId, tabId: 'tab-1',
1178
- name: EVENT_NAME.PAGE_LOAD, ts: l2StartTs,
1179
- payload: { sessionId: 'L2', page: {}, storage: { local: {}, session: {}, cookie: '' } },
1180
- }));
1181
- await new Promise((r) => setTimeout(r, 40));
1182
-
1183
- // In the new model, LoadMeta IS SessionMeta — filter by tabId
1184
- const loads = store.listSessions({ tabId: 'tab-1' });
1185
- expect(loads).toHaveLength(2);
1186
- const l1 = loads.find((l) => l.id === 'L1')!;
1187
- const l2 = loads.find((l) => l.id === 'L2')!;
1188
- expect(l1.endedAt).toBeDefined();
1189
- expect(l1.endedAt!).toBeLessThanOrEqual(l2.startedAt);
1190
- expect(l2.endedAt).toBeUndefined();
1191
-
1192
- // Closing rc2's tab should fill L2's endedAt.
1193
- rc2.ws.close();
1194
- await new Promise((r) => setTimeout(r, 50));
1195
- const after = store.listSessions({ tabId: 'tab-1' });
1196
- expect(after.find((l) => l.id === 'L2')!.endedAt).toBeDefined();
1197
-
1198
- pluginWs.close();
1199
- } finally {
1200
- await bridge.stop();
1201
- await store.close();
1202
- rmSync(dir, { recursive: true, force: true });
1203
- }
1204
- });
1205
- });
1206
-
1207
- describe('Phase B: task attachment write path', () => {
1208
- it('writes attachment binary to disk and stores pointer (not data) in tasks', async () => {
1209
- const dir = mkdtempSync(join(tmpdir(), 'hfe-attach-test-'));
1210
- const store = new JsonlStore(dir);
1211
- const taskStore = new JsonTaskStore(dir);
1212
- const bridge = new Bridge({
1213
- port: 0,
1214
- host: '127.0.0.1',
1215
- store,
1216
- taskStore,
1217
- attachmentsDataDir: dir,
1218
- autoPurge: { enabled: false },
1219
- });
1220
- await bridge.start();
1221
- const port = bridge.getBoundPort()!;
1222
-
1223
- try {
1224
- const { pluginWs, ws } = await fakeClientWithSession(port, {
1225
- tabId: 'tab-att',
1226
- projectId: 'attach-proj',
1227
- sessionId: 'sess-att',
1228
- });
1229
-
1230
- // A small 1x1 PNG as base64 (minimal valid PNG)
1231
- const tiny1x1png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
1232
-
1233
- const payload: TaskSubmitPayload = {
1234
- question: 'attachment test',
1235
- url: 'http://localhost/',
1236
- selector: { css: 'div' },
1237
- element: { tag: 'div', outerHTML: '<div/>' },
1238
- attachments: [{
1239
- id: 'att-123',
1240
- kind: 'screenshot',
1241
- data: tiny1x1png,
1242
- width: 1,
1243
- height: 1,
1244
- }],
1245
- };
1246
-
1247
- ws.send(JSON.stringify({
1248
- type: 'event',
1249
- id: 'ev1',
1250
- name: 'task.submit',
1251
- ts: Date.now(),
1252
- tabId: 'tab-att',
1253
- projectId: 'attach-proj',
1254
- sessionId: 'sess-att',
1255
- payload,
1256
- }));
1257
-
1258
- // Give the bridge time to process the event
1259
- await new Promise<void>((resolve) => setTimeout(resolve, 100));
1260
-
1261
- // Find the task
1262
- const tasks = taskStore.loadTasks('attach-proj');
1263
- const task = tasks.find((t) => t.question === 'attachment test');
1264
- expect(task).toBeDefined();
1265
-
1266
- // tasks.json should store pointer only (no data field)
1267
- expect(task!.attachments).toBeDefined();
1268
- expect(task!.attachments!.length).toBe(1);
1269
- const ptr = task!.attachments![0];
1270
- expect(ptr.id).toBe('att-123');
1271
- expect(ptr.path).toBeDefined();
1272
- expect(ptr.data).toBeUndefined();
1273
-
1274
- // Binary file should exist on disk
1275
- const diskPath = join(dir, 'projects', 'attach-proj', 'task-attachments', task!.id, 'att-123.png');
1276
- expect(existsSync(diskPath)).toBe(true);
1277
- const fileContent = readFileSync(diskPath);
1278
- expect(fileContent.length).toBeGreaterThan(0);
1279
-
1280
- // getTaskAttachmentData should return base64
1281
- const b64 = await bridge.getTaskAttachmentData(task!.id, 'att-123');
1282
- expect(b64).toBeTruthy();
1283
- expect(typeof b64).toBe('string');
1284
-
1285
- pluginWs.close();
1286
- ws.close();
1287
- } finally {
1288
- await bridge.stop();
1289
- await store.close();
1290
- rmSync(dir, { recursive: true, force: true });
1291
- }
1292
- });
1293
-
1294
- it('drops attachments exceeding 4 MB total and logs warning to stderr', async () => {
1295
- const dir = mkdtempSync(join(tmpdir(), 'hfe-attach-big-'));
1296
- const taskStore = new JsonTaskStore(dir);
1297
- const bridge = new Bridge({
1298
- port: 0,
1299
- host: '127.0.0.1',
1300
- store: null,
1301
- taskStore,
1302
- attachmentsDataDir: dir,
1303
- autoPurge: { enabled: false },
1304
- });
1305
- await bridge.start();
1306
- const port = bridge.getBoundPort()!;
1307
-
1308
- const stderrChunks: string[] = [];
1309
- const origWrite = process.stderr.write.bind(process.stderr);
1310
- // @ts-expect-error patching for test
1311
- process.stderr.write = (chunk: string | Uint8Array, ...args: unknown[]) => {
1312
- if (typeof chunk === 'string') stderrChunks.push(chunk);
1313
- return origWrite(chunk, ...args as []);
1314
- };
1315
-
1316
- try {
1317
- const { pluginWs, ws } = await fakeClientWithSession(port, {
1318
- tabId: 'tab-big',
1319
- projectId: 'big-proj',
1320
- sessionId: 'sess-big',
1321
- });
1322
-
1323
- // Create a base64 string that decodes to >4 MB (4 * 1024 * 1024 + 1 bytes)
1324
- const bigBuf = Buffer.alloc(4 * 1024 * 1024 + 1, 0x42);
1325
- const bigData = bigBuf.toString('base64');
1326
-
1327
- const payload: TaskSubmitPayload = {
1328
- question: 'big attach',
1329
- url: 'http://localhost/',
1330
- selector: { css: 'div' },
1331
- element: { tag: 'div', outerHTML: '<div/>' },
1332
- attachments: [{
1333
- id: 'big-att',
1334
- kind: 'screenshot',
1335
- data: bigData,
1336
- width: 100,
1337
- height: 100,
1338
- }],
1339
- };
1340
-
1341
- ws.send(JSON.stringify({
1342
- type: 'event',
1343
- id: 'ev2',
1344
- name: 'task.submit',
1345
- ts: Date.now(),
1346
- tabId: 'tab-big',
1347
- projectId: 'big-proj',
1348
- sessionId: 'sess-big',
1349
- payload,
1350
- }));
1351
-
1352
- await new Promise<void>((resolve) => setTimeout(resolve, 100));
1353
-
1354
- const tasks = taskStore.loadTasks('big-proj');
1355
- const task = tasks.find((t) => t.question === 'big attach');
1356
- expect(task).toBeDefined();
1357
- // attachments should be empty (dropped)
1358
- expect(task!.attachments).toBeDefined();
1359
- expect(task!.attachments!.length).toBe(0);
1360
- // stderr warning should have been emitted
1361
- expect(stderrChunks.some((c) => c.includes('exceeds 4 MB limit'))).toBe(true);
1362
-
1363
- pluginWs.close();
1364
- ws.close();
1365
- } finally {
1366
- // @ts-expect-error restore
1367
- process.stderr.write = origWrite;
1368
- await bridge.stop();
1369
- rmSync(dir, { recursive: true, force: true });
1370
- }
1371
- });
1372
- });
1373
-
1374
- describe('Phase E: bridge accepts node-runtime hello', () => {
1375
- it('node-runtime hello is accepted and ack is received', async () => {
1376
- const bridge = new Bridge({
1377
- port: 0,
1378
- host: '127.0.0.1',
1379
- store: null,
1380
- taskStore: null,
1381
- autoPurge: { enabled: false },
1382
- });
1383
- await bridge.start();
1384
- const port = bridge.getBoundPort()!;
1385
-
1386
- try {
1387
- const ws = new WebSocket(`ws://127.0.0.1:${port}`);
1388
- const ack = await new Promise<HelloAckFrame>((resolve, reject) => {
1389
- const timer = setTimeout(() => reject(new Error('timeout')), 3000);
1390
- ws.on('open', () => {
1391
- ws.send(JSON.stringify({
1392
- type: 'hello',
1393
- id: 'hello-nr-1',
1394
- role: 'node-runtime',
1395
- protocolVersion: PROTOCOL_VERSION,
1396
- projectId: 'nr-test-proj',
1397
- displayName: 'Node Runtime Test',
1398
- }));
1399
- });
1400
- ws.on('message', (raw: Buffer | string) => {
1401
- const frame = JSON.parse(typeof raw === 'string' ? raw : raw.toString()) as HelloAckFrame;
1402
- if (frame.type === 'hello.ack') {
1403
- clearTimeout(timer);
1404
- resolve(frame);
1405
- }
1406
- });
1407
- ws.on('error', (err) => { clearTimeout(timer); reject(err); });
1408
- });
1409
-
1410
- expect(ack.type).toBe('hello.ack');
1411
- expect(ack.error).toBeUndefined();
1412
-
1413
- // Verify the node-runtime peer is in the router
1414
- const tabs = await bridge.listTabs();
1415
- // node-runtime connections don't have tabIds but the peer should be registered
1416
- expect(tabs.length).toBeGreaterThanOrEqual(0); // store=null so listTabs reads in-memory
1417
-
1418
- ws.close();
1419
- } finally {
1420
- await bridge.stop();
1421
- }
1422
- });
1423
-
1424
- it('node-runtime events are routed into the shared session', async () => {
1425
- const dir = mkdtempSync(join(tmpdir(), 'hfe-nr-test-'));
1426
- const store = new JsonlStore(dir);
1427
- const bridge = new Bridge({
1428
- port: 0,
1429
- host: '127.0.0.1',
1430
- store,
1431
- taskStore: null,
1432
- autoPurge: { enabled: false },
1433
- });
1434
- await bridge.start();
1435
- const port = bridge.getBoundPort()!;
1436
-
1437
- try {
1438
- // First a runtime-client connects and creates a session
1439
- const { pluginWs, ws: clientWs } = await fakeClientWithSession(port, {
1440
- tabId: 'tab-nr',
1441
- projectId: 'nr-proj',
1442
- sessionId: 'sess-nr-shared',
1443
- });
1444
-
1445
- // Then a node-runtime connects with the SAME sessionId
1446
- const nrWs = new WebSocket(`ws://127.0.0.1:${port}`);
1447
- await new Promise<void>((resolve, reject) => {
1448
- const timer = setTimeout(() => reject(new Error('timeout')), 3000);
1449
- nrWs.on('open', () => {
1450
- nrWs.send(JSON.stringify({
1451
- type: 'hello',
1452
- id: 'hello-nr-2',
1453
- role: 'node-runtime',
1454
- protocolVersion: PROTOCOL_VERSION,
1455
- projectId: 'nr-proj',
1456
- sessionId: 'sess-nr-shared',
1457
- }));
1458
- });
1459
- nrWs.on('message', (raw: Buffer | string) => {
1460
- const frame = JSON.parse(typeof raw === 'string' ? raw : raw.toString()) as { type: string };
1461
- if (frame.type === 'hello.ack') { clearTimeout(timer); resolve(); }
1462
- });
1463
- nrWs.on('error', (err) => { clearTimeout(timer); reject(err); });
1464
- });
1465
-
1466
- // Send a server-err event from the node-runtime
1467
- nrWs.send(JSON.stringify({
1468
- type: 'event',
1469
- id: 'ev-nr-1',
1470
- name: 'server-err',
1471
- ts: Date.now(),
1472
- projectId: 'nr-proj',
1473
- sessionId: 'sess-nr-shared',
1474
- payload: { message: 'Server threw!', stack: 'Error: Server threw!\n at ...' },
1475
- }));
1476
-
1477
- await new Promise<void>((res) => setTimeout(res, 100));
1478
-
1479
- // The event should be in the shared session's timeline
1480
- const timeline = store.tail('sess-nr-shared', { limit: 50 });
1481
- const serverErr = timeline.find((e) => (e as { t: string }).t === 'server-err');
1482
- expect(serverErr).toBeDefined();
1483
-
1484
- pluginWs.close();
1485
- clientWs.close();
1486
- nrWs.close();
1487
- } finally {
1488
- await bridge.stop();
1489
- rmSync(dir, { recursive: true, force: true });
1490
- }
1491
- });
1492
- });
1493
-
1494
- // ─── POST /events (HTTP-batch transport) ─────────────────────────────────────
1495
-
1496
- describe('Bridge — POST /events (HTTP batch transport)', () => {
1497
- it('persists events from POST /events to sessions/{sid}/timeline.jsonl', async () => {
1498
- const dir = mkdtempSync(join(tmpdir(), 'harness-http-batch-'));
1499
- const store = new JsonlStore(dir);
1500
- const bridge = new Bridge({
1501
- port: 0,
1502
- host: '127.0.0.1',
1503
- store,
1504
- taskStore: null,
1505
- autoPurge: { enabled: false },
1506
- });
1507
- try {
1508
- await bridge.start();
1509
- const port = bridge.getBoundPort()!;
1510
-
1511
- const body = JSON.stringify({
1512
- hello: {
1513
- role: 'node-runtime',
1514
- projectId: 'http-proj',
1515
- sessionId: 'sess-http-1',
1516
- buildId: 'build-x',
1517
- },
1518
- events: [
1519
- { id: 'e1', name: 'server-err', ts: Date.now(), payload: { message: 'http error' } },
1520
- { id: 'e2', name: 'server-log', ts: Date.now() + 1, payload: { level: 'info', args: ['hello'] } },
1521
- { id: 'e3', name: 'server-action', ts: Date.now() + 2, payload: { status: 'ok', durationMs: 10 } },
1522
- ],
1523
- });
1524
-
1525
- const res = await fetch(`http://127.0.0.1:${port}/events`, {
1526
- method: 'POST',
1527
- headers: { 'content-type': 'application/json', host: `127.0.0.1:${port}` },
1528
- body,
1529
- });
1530
- expect(res.status).toBe(204);
1531
-
1532
- // Wait for the async store write to land
1533
- await new Promise<void>((resolve) => setTimeout(resolve, 100));
1534
- await store.close();
1535
-
1536
- const timeline = store.tail('sess-http-1', { n: 50 });
1537
- expect(timeline.length).toBeGreaterThanOrEqual(3);
1538
-
1539
- const errEvent = timeline.find((e) => e.t === 'server-err');
1540
- expect(errEvent).toBeDefined();
1541
- expect((errEvent!.d as { message: string }).message).toBe('http error');
1542
-
1543
- const logEvent = timeline.find((e) => e.t === 'server-log');
1544
- expect(logEvent).toBeDefined();
1545
-
1546
- const actionEvent = timeline.find((e) => e.t === 'server-action');
1547
- expect(actionEvent).toBeDefined();
1548
- } finally {
1549
- await bridge.stop();
1550
- rmSync(dir, { recursive: true, force: true });
1551
- }
1552
- });
1553
-
1554
- it('GET /events/ping returns 200 with version', async () => {
1555
- const bridge = new Bridge({ port: 0, host: '127.0.0.1', store: null, taskStore: null });
1556
- try {
1557
- await bridge.start();
1558
- const port = bridge.getBoundPort()!;
1559
- const res = await fetch(`http://127.0.0.1:${port}/events/ping`);
1560
- expect(res.status).toBe(200);
1561
- const json = await res.json() as { ok: boolean; version: string };
1562
- expect(json.ok).toBe(true);
1563
- expect(typeof json.version).toBe('string');
1564
- } finally {
1565
- await bridge.stop();
1566
- }
1567
- });
1568
-
1569
- it('returns 400 for invalid batch body', async () => {
1570
- const bridge = new Bridge({ port: 0, host: '127.0.0.1', store: null, taskStore: null });
1571
- try {
1572
- await bridge.start();
1573
- const port = bridge.getBoundPort()!;
1574
- const res = await fetch(`http://127.0.0.1:${port}/events`, {
1575
- method: 'POST',
1576
- headers: { 'content-type': 'application/json' },
1577
- body: JSON.stringify({ hello: { role: 'runtime-client' }, events: [] }),
1578
- });
1579
- expect(res.status).toBe(400);
1580
- } finally {
1581
- await bridge.stop();
1582
- }
1583
- });
1584
-
1585
- it('WS client and HTTP-batch client with same sessionId share SessionMeta.participants', async () => {
1586
- const dir = mkdtempSync(join(tmpdir(), 'harness-shared-sess-'));
1587
- const store = new JsonlStore(dir);
1588
- const bridge = new Bridge({
1589
- port: 0,
1590
- host: '127.0.0.1',
1591
- store,
1592
- taskStore: null,
1593
- autoPurge: { enabled: false },
1594
- });
1595
- try {
1596
- await bridge.start();
1597
- const port = bridge.getBoundPort()!;
1598
- const sharedSessionId = 'sess-shared-ws-http';
1599
-
1600
- // Connect a vite-plugin (builds project)
1601
- const pluginWs = new WebSocket(`ws://127.0.0.1:${port}`);
1602
- await new Promise<void>((resolve, reject) => {
1603
- pluginWs.once('open', () => resolve());
1604
- pluginWs.once('error', reject);
1605
- });
1606
- pluginWs.send(JSON.stringify({ type: 'hello', id: 'p1', role: 'vite-plugin', projectId: 'shared-proj' }));
1607
- await new Promise<void>((resolve) => { pluginWs.once('message', () => resolve()); });
1608
-
1609
- // Connect runtime-client with the same sessionId
1610
- const clientWs = new WebSocket(`ws://127.0.0.1:${port}`);
1611
- await new Promise<void>((resolve, reject) => {
1612
- clientWs.once('open', () => resolve());
1613
- clientWs.once('error', reject);
1614
- });
1615
- clientWs.send(JSON.stringify({
1616
- type: 'hello', id: 'c1', role: 'runtime-client',
1617
- projectId: 'shared-proj', tabId: 'tab-shared', sessionId: sharedSessionId,
1618
- }));
1619
- await new Promise<void>((resolve) => { clientWs.once('message', () => resolve()); });
1620
-
1621
- // POST HTTP batch with the SAME sessionId from node-runtime
1622
- const batchBody = JSON.stringify({
1623
- hello: {
1624
- role: 'node-runtime',
1625
- projectId: 'shared-proj',
1626
- sessionId: sharedSessionId,
1627
- buildId: 'build-shared',
1628
- },
1629
- events: [
1630
- { id: 'ev1', name: 'server-err', ts: Date.now(), payload: { message: 'from http' } },
1631
- ],
1632
- });
1633
- const httpRes = await fetch(`http://127.0.0.1:${port}/events`, {
1634
- method: 'POST',
1635
- headers: { 'content-type': 'application/json' },
1636
- body: batchBody,
1637
- });
1638
- expect(httpRes.status).toBe(204);
1639
-
1640
- await new Promise<void>((resolve) => setTimeout(resolve, 100));
1641
-
1642
- // Both the WS and HTTP paths should have written to the same timeline
1643
- const timeline = store.tail(sharedSessionId, { n: 50 });
1644
- const httpEvent = timeline.find((e) => e.t === 'server-err');
1645
- expect(httpEvent).toBeDefined();
1646
-
1647
- // The session's participants should include 'shared-proj'
1648
- const sessionMeta = store.getSession(sharedSessionId);
1649
- expect(sessionMeta).toBeDefined();
1650
- const hasProject = sessionMeta!.participants.some((p) => p.projectId === 'shared-proj');
1651
- expect(hasProject).toBe(true);
1652
-
1653
- pluginWs.close();
1654
- clientWs.close();
1655
- } finally {
1656
- await bridge.stop();
1657
- rmSync(dir, { recursive: true, force: true });
1658
- }
1659
- });
1660
- });
1661
-
1662
- describe('Bridge — port-keyed data directory', () => {
1663
- it('defaultDataDir(port) returns a port-specific path under ~/.harness/daemons', () => {
1664
- const p1 = defaultDataDir(47729);
1665
- const p2 = defaultDataDir(47730);
1666
- // Same daemon → same data dir; different daemon → different data dir.
1667
- expect(p1).toMatch(/[/\\]\.harness[/\\]daemons[/\\]47729[/\\]data$/);
1668
- expect(p2).toMatch(/[/\\]\.harness[/\\]daemons[/\\]47730[/\\]data$/);
1669
- expect(p1).not.toBe(p2);
1670
- });
1671
-
1672
- it('Bridge() picks a port-keyed data dir when dataDir is omitted', () => {
1673
- // Use null stores so the constructor doesn't actually try to mkdir.
1674
- // We're only checking that the wiring threads `port` into the default.
1675
- const bridge = new Bridge({
1676
- port: 51234,
1677
- store: null,
1678
- taskStore: null,
1679
- memoryStore: null,
1680
- });
1681
- // attachDataDir is the only sub-store path that's always populated.
1682
- expect((bridge as unknown as { attachDataDir: string }).attachDataDir)
1683
- .toBe(defaultDataDir(51234));
1684
- });
1685
-
1686
- it('Bridge() honors an explicit dataDir over the port-keyed default', () => {
1687
- const dir = mkdtempSync(join(tmpdir(), 'harness-bridge-explicit-'));
1688
- try {
1689
- const bridge = new Bridge({
1690
- port: 51235,
1691
- dataDir: dir,
1692
- store: null,
1693
- taskStore: null,
1694
- memoryStore: null,
1695
- });
1696
- expect((bridge as unknown as { attachDataDir: string }).attachDataDir).toBe(dir);
1697
- } finally {
1698
- rmSync(dir, { recursive: true, force: true });
1699
- }
1700
- });
1701
-
1702
- it('Bridge() exposes the configured label, undefined when not set', () => {
1703
- const a = new Bridge({ port: 51236, store: null, taskStore: null, memoryStore: null });
1704
- const b = new Bridge({ port: 51237, label: 'my-mono', store: null, taskStore: null, memoryStore: null });
1705
- expect(a.label).toBeUndefined();
1706
- expect(b.label).toBe('my-mono');
1707
- });
1708
- });