@harness-fe/mcp-server 4.0.0-next.2 → 4.0.0-next.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +15 -0
- package/dist/daemon.d.ts +3 -3
- package/dist/daemon.js +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +65 -19
- package/dist/mcpHttp.d.ts +2 -2
- package/dist/mcpHttp.js +88 -18
- package/package.json +5 -7
- package/src/bin.ts +19 -0
- package/src/daemon.ts +3 -3
- package/src/experimental.test.ts +2 -2
- package/src/index.ts +4 -4
- package/src/mcp.ts +67 -23
- package/src/mcpHttp.test.ts +52 -3
- package/src/mcpHttp.ts +102 -23
- package/src/mcpLayer.e2e.test.ts +2 -2
- package/src/newCapabilities.e2e.test.ts +3 -3
- package/dist/auth.d.ts +0 -53
- package/dist/auth.js +0 -212
- package/dist/bridge.d.ts +0 -323
- package/dist/bridge.js +0 -1618
- package/dist/cli.d.ts +0 -18
- package/dist/cli.js +0 -293
- package/dist/dashboardApi.d.ts +0 -40
- package/dist/dashboardApi.js +0 -142
- package/dist/dashboardSpa.d.ts +0 -18
- package/dist/dashboardSpa.js +0 -180
- package/dist/dashboardUrl.d.ts +0 -13
- package/dist/dashboardUrl.js +0 -18
- package/dist/eventsHandler.d.ts +0 -24
- package/dist/eventsHandler.js +0 -114
- package/dist/identity.d.ts +0 -90
- package/dist/identity.js +0 -123
- package/dist/openBrowser.d.ts +0 -33
- package/dist/openBrowser.js +0 -63
- package/dist/remoteBridge.d.ts +0 -61
- package/dist/remoteBridge.js +0 -307
- package/dist/replayCreate.d.ts +0 -36
- package/dist/replayCreate.js +0 -156
- package/dist/replayViewer.d.ts +0 -20
- package/dist/replayViewer.js +0 -168
- package/dist/sessionRouter.d.ts +0 -45
- package/dist/sessionRouter.js +0 -88
- package/dist/store/JsonMemoryStore.d.ts +0 -52
- package/dist/store/JsonMemoryStore.js +0 -119
- package/dist/store/JsonTaskStore.d.ts +0 -21
- package/dist/store/JsonTaskStore.js +0 -53
- package/dist/store/JsonlStore.d.ts +0 -128
- package/dist/store/JsonlStore.js +0 -1172
- package/dist/store/MemoryEventStore.d.ts +0 -47
- package/dist/store/MemoryEventStore.js +0 -111
- package/dist/store/WriteQueue.d.ts +0 -51
- package/dist/store/WriteQueue.js +0 -142
- package/dist/store/index.d.ts +0 -6
- package/dist/store/index.js +0 -5
- package/dist/store/types.d.ts +0 -427
- package/dist/store/types.js +0 -19
- package/dist/visitorTimeline.d.ts +0 -24
- package/dist/visitorTimeline.js +0 -68
- package/src/auth.test.ts +0 -90
- package/src/auth.ts +0 -248
- package/src/bridge-auth.test.ts +0 -196
- package/src/bridge.test.ts +0 -1708
- package/src/bridge.ts +0 -1854
- package/src/cli.ts +0 -338
- package/src/dashboardApi.test.ts +0 -235
- package/src/dashboardApi.ts +0 -184
- package/src/dashboardSpa.test.ts +0 -239
- package/src/dashboardSpa.ts +0 -195
- package/src/dashboardUrl.test.ts +0 -46
- package/src/dashboardUrl.ts +0 -28
- package/src/eventsHandler.test.ts +0 -247
- package/src/eventsHandler.ts +0 -136
- package/src/identity.test.ts +0 -109
- package/src/identity.ts +0 -137
- package/src/openBrowser.test.ts +0 -103
- package/src/openBrowser.ts +0 -81
- package/src/remoteBridge.test.ts +0 -119
- package/src/remoteBridge.ts +0 -404
- package/src/replay.test.ts +0 -271
- package/src/replayCreate.ts +0 -194
- package/src/replayViewer.ts +0 -173
- package/src/sessionRouter.ts +0 -119
- package/src/store/JsonMemoryStore.test.ts +0 -175
- package/src/store/JsonMemoryStore.ts +0 -128
- package/src/store/JsonTaskStore.test.ts +0 -212
- package/src/store/JsonTaskStore.ts +0 -59
- package/src/store/JsonlStore.test.ts +0 -1538
- package/src/store/JsonlStore.ts +0 -1325
- package/src/store/MemoryEventStore.test.ts +0 -119
- package/src/store/MemoryEventStore.ts +0 -151
- package/src/store/WriteQueue.ts +0 -165
- package/src/store/identityTagging.test.ts +0 -67
- package/src/store/index.ts +0 -29
- package/src/store/types.ts +0 -532
- package/src/visitorTimeline.test.ts +0 -197
- package/src/visitorTimeline.ts +0 -89
package/src/bridge.test.ts
DELETED
|
@@ -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
|
-
});
|