@roj-ai/transport 0.1.36 → 0.2.2
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/client/index.d.ts +2 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/core/connection.d.ts +11 -0
- package/dist/core/connection.d.ts.map +1 -1
- package/dist/core/connection.js +26 -6
- package/dist/core/connection.js.map +1 -1
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/protocol.d.ts +12 -117
- package/dist/core/protocol.d.ts.map +1 -1
- package/dist/core/protocol.js +13 -60
- package/dist/core/protocol.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/index.js.map +1 -1
- package/package.json +4 -2
- package/src/client/index.ts +2 -2
- package/src/core/connection.ts +27 -6
- package/src/core/index.ts +2 -16
- package/src/core/protocol.ts +25 -208
- package/src/index.ts +3 -17
- package/src/server/index.ts +2 -2
- package/src/__tests__/integration.test.ts +0 -443
- package/src/client/client-connection.test.ts +0 -146
- package/src/core/protocol.test.ts +0 -235
- package/src/core/router.test.ts +0 -248
|
@@ -1,443 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Integration Tests for Transport Package
|
|
3
|
-
*
|
|
4
|
-
* These tests run actual WebSocket server and client to verify
|
|
5
|
-
* real-world communication scenarios with notification-only protocol.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { Server } from 'bun'
|
|
9
|
-
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'bun:test'
|
|
10
|
-
import { z } from 'zod'
|
|
11
|
-
|
|
12
|
-
import { ClientConnection } from '../client/client-connection.js'
|
|
13
|
-
import { defineProtocol, notification } from '../core/protocol.js'
|
|
14
|
-
import { BunServerWebSocketAdapter, bunWebSocketFactory, createBunWebSocketHandlers } from '../platform/bun.js'
|
|
15
|
-
import type { IServerWebSocket } from '../platform/types.js'
|
|
16
|
-
import { ServerConnection } from '../server/server-connection.js'
|
|
17
|
-
|
|
18
|
-
// ============================================================================
|
|
19
|
-
// Test Protocols (notification-only)
|
|
20
|
-
// ============================================================================
|
|
21
|
-
|
|
22
|
-
// Client -> Server (notifications)
|
|
23
|
-
const clientProtocol = defineProtocol({
|
|
24
|
-
userMessage: notification({
|
|
25
|
-
input: z.object({ sessionId: z.string(), content: z.string() }),
|
|
26
|
-
}),
|
|
27
|
-
userStatus: notification({
|
|
28
|
-
input: z.object({ status: z.enum(['online', 'away', 'offline']) }),
|
|
29
|
-
}),
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
// Server -> Client (notifications)
|
|
33
|
-
const serverProtocol = defineProtocol({
|
|
34
|
-
serverEvent: notification({
|
|
35
|
-
input: z.object({ eventType: z.string(), data: z.unknown() }),
|
|
36
|
-
}),
|
|
37
|
-
agentMessage: notification({
|
|
38
|
-
input: z.object({ sessionId: z.string(), content: z.string() }),
|
|
39
|
-
}),
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
// ============================================================================
|
|
43
|
-
// Test Server Setup
|
|
44
|
-
// ============================================================================
|
|
45
|
-
|
|
46
|
-
interface TestServerState {
|
|
47
|
-
server: Server
|
|
48
|
-
port: number
|
|
49
|
-
connections: Map<string, ServerConnection<typeof clientProtocol._def, typeof serverProtocol._def>>
|
|
50
|
-
receivedNotifications: Array<{ type: string; input: unknown }>
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function createTestServer(): Promise<TestServerState> {
|
|
54
|
-
return new Promise((resolve) => {
|
|
55
|
-
const connections = new Map<string, ServerConnection<typeof clientProtocol._def, typeof serverProtocol._def>>()
|
|
56
|
-
const receivedNotifications: Array<{ type: string; input: unknown }> = []
|
|
57
|
-
|
|
58
|
-
const wsHandlers = createBunWebSocketHandlers({
|
|
59
|
-
onOpen: (ws: IServerWebSocket) => {
|
|
60
|
-
const serverConn = new ServerConnection({
|
|
61
|
-
receiveProtocol: clientProtocol,
|
|
62
|
-
sendProtocol: serverProtocol,
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
// Set up handlers for incoming notifications
|
|
66
|
-
serverConn.setHandlers({
|
|
67
|
-
userMessage: async (input) => {
|
|
68
|
-
receivedNotifications.push({ type: 'userMessage', input })
|
|
69
|
-
},
|
|
70
|
-
userStatus: async (input) => {
|
|
71
|
-
receivedNotifications.push({ type: 'userStatus', input })
|
|
72
|
-
},
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
serverConn.attach(ws)
|
|
76
|
-
connections.set(serverConn.getConnectionId(), serverConn)
|
|
77
|
-
;(ws as unknown as { __serverConn: ServerConnection<typeof clientProtocol._def, typeof serverProtocol._def> }).__serverConn = serverConn
|
|
78
|
-
},
|
|
79
|
-
onClose: (ws: IServerWebSocket) => {
|
|
80
|
-
const serverConn = (ws as unknown as { __serverConn: ServerConnection<typeof clientProtocol._def, typeof serverProtocol._def> }).__serverConn
|
|
81
|
-
if (serverConn) {
|
|
82
|
-
connections.delete(serverConn.getConnectionId())
|
|
83
|
-
}
|
|
84
|
-
},
|
|
85
|
-
onMessage: (ws: IServerWebSocket, message: string) => {
|
|
86
|
-
// Forward message to the WebSocket's onmessage handler
|
|
87
|
-
ws.onmessage?.(message)
|
|
88
|
-
},
|
|
89
|
-
onError: () => {},
|
|
90
|
-
getData: () => ({}),
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
const server = Bun.serve({
|
|
94
|
-
port: 0, // Random available port
|
|
95
|
-
fetch(req, server) {
|
|
96
|
-
if (server.upgrade(req)) {
|
|
97
|
-
return
|
|
98
|
-
}
|
|
99
|
-
return new Response('WebSocket server', { status: 200 })
|
|
100
|
-
},
|
|
101
|
-
websocket: {
|
|
102
|
-
open: wsHandlers.open,
|
|
103
|
-
close: wsHandlers.close,
|
|
104
|
-
message: wsHandlers.message,
|
|
105
|
-
},
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
resolve({
|
|
109
|
-
server,
|
|
110
|
-
port: server.port,
|
|
111
|
-
connections,
|
|
112
|
-
receivedNotifications,
|
|
113
|
-
})
|
|
114
|
-
})
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// ============================================================================
|
|
118
|
-
// Integration Tests
|
|
119
|
-
// ============================================================================
|
|
120
|
-
|
|
121
|
-
describe('Transport Integration Tests', () => {
|
|
122
|
-
let testServer: TestServerState
|
|
123
|
-
|
|
124
|
-
beforeAll(async () => {
|
|
125
|
-
testServer = await createTestServer()
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
afterAll(() => {
|
|
129
|
-
testServer.server.stop()
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
afterEach(() => {
|
|
133
|
-
testServer.receivedNotifications.length = 0
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
describe('Connection', () => {
|
|
137
|
-
it('should connect successfully', async () => {
|
|
138
|
-
const client = new ClientConnection({
|
|
139
|
-
url: `ws://localhost:${testServer.port}`,
|
|
140
|
-
wsFactory: bunWebSocketFactory,
|
|
141
|
-
receiveProtocol: serverProtocol,
|
|
142
|
-
sendProtocol: clientProtocol,
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
await client.connect()
|
|
146
|
-
|
|
147
|
-
expect(client.isConnected()).toBe(true)
|
|
148
|
-
expect(client.getState()).toBe('connected')
|
|
149
|
-
|
|
150
|
-
await client.disconnect()
|
|
151
|
-
expect(client.isConnected()).toBe(false)
|
|
152
|
-
})
|
|
153
|
-
|
|
154
|
-
it('should emit state change events', async () => {
|
|
155
|
-
const stateChanges: Array<{ from: string; to: string }> = []
|
|
156
|
-
|
|
157
|
-
const client = new ClientConnection({
|
|
158
|
-
url: `ws://localhost:${testServer.port}`,
|
|
159
|
-
wsFactory: bunWebSocketFactory,
|
|
160
|
-
receiveProtocol: serverProtocol,
|
|
161
|
-
sendProtocol: clientProtocol,
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
client.on((event, data) => {
|
|
165
|
-
if (event === 'state_change') {
|
|
166
|
-
stateChanges.push(data as { from: string; to: string })
|
|
167
|
-
}
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
await client.connect()
|
|
171
|
-
await client.disconnect()
|
|
172
|
-
|
|
173
|
-
// Should have state changes: disconnected -> connecting -> connected -> disconnected
|
|
174
|
-
expect(stateChanges.length).toBeGreaterThan(2)
|
|
175
|
-
expect(stateChanges.some((s) => s.to === 'connected')).toBe(true)
|
|
176
|
-
})
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
describe('Client-to-Server Notifications', () => {
|
|
180
|
-
it('should send notification to server', async () => {
|
|
181
|
-
const client = new ClientConnection({
|
|
182
|
-
url: `ws://localhost:${testServer.port}`,
|
|
183
|
-
wsFactory: bunWebSocketFactory,
|
|
184
|
-
receiveProtocol: serverProtocol,
|
|
185
|
-
sendProtocol: clientProtocol,
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
await client.connect()
|
|
189
|
-
|
|
190
|
-
// Send notification
|
|
191
|
-
client.notify.userMessage({ sessionId: 'sess-1', content: 'Hello World' })
|
|
192
|
-
|
|
193
|
-
// Wait for notification to arrive
|
|
194
|
-
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
195
|
-
|
|
196
|
-
// Verify server received the notification
|
|
197
|
-
expect(testServer.receivedNotifications).toContainEqual({
|
|
198
|
-
type: 'userMessage',
|
|
199
|
-
input: { sessionId: 'sess-1', content: 'Hello World' },
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
await client.disconnect()
|
|
203
|
-
})
|
|
204
|
-
|
|
205
|
-
it('should send multiple notifications', async () => {
|
|
206
|
-
const client = new ClientConnection({
|
|
207
|
-
url: `ws://localhost:${testServer.port}`,
|
|
208
|
-
wsFactory: bunWebSocketFactory,
|
|
209
|
-
receiveProtocol: serverProtocol,
|
|
210
|
-
sendProtocol: clientProtocol,
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
await client.connect()
|
|
214
|
-
|
|
215
|
-
// Send multiple notifications
|
|
216
|
-
client.notify.userMessage({ sessionId: 'sess-1', content: 'First message' })
|
|
217
|
-
client.notify.userStatus({ status: 'online' })
|
|
218
|
-
client.notify.userMessage({ sessionId: 'sess-1', content: 'Second message' })
|
|
219
|
-
|
|
220
|
-
// Wait for notifications to arrive
|
|
221
|
-
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
222
|
-
|
|
223
|
-
expect(testServer.receivedNotifications.length).toBe(3)
|
|
224
|
-
expect(testServer.receivedNotifications[0]).toEqual({
|
|
225
|
-
type: 'userMessage',
|
|
226
|
-
input: { sessionId: 'sess-1', content: 'First message' },
|
|
227
|
-
})
|
|
228
|
-
expect(testServer.receivedNotifications[1]).toEqual({
|
|
229
|
-
type: 'userStatus',
|
|
230
|
-
input: { status: 'online' },
|
|
231
|
-
})
|
|
232
|
-
expect(testServer.receivedNotifications[2]).toEqual({
|
|
233
|
-
type: 'userMessage',
|
|
234
|
-
input: { sessionId: 'sess-1', content: 'Second message' },
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
await client.disconnect()
|
|
238
|
-
})
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
describe('Server-to-Client Notifications', () => {
|
|
242
|
-
it('should receive notifications from server', async () => {
|
|
243
|
-
const receivedNotifications: Array<{ eventType: string; data: unknown }> = []
|
|
244
|
-
|
|
245
|
-
const client = new ClientConnection({
|
|
246
|
-
url: `ws://localhost:${testServer.port}`,
|
|
247
|
-
wsFactory: bunWebSocketFactory,
|
|
248
|
-
receiveProtocol: serverProtocol,
|
|
249
|
-
sendProtocol: clientProtocol,
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
// Set up notification handler
|
|
253
|
-
client.setHandlers({
|
|
254
|
-
serverEvent: async (input) => {
|
|
255
|
-
receivedNotifications.push(input)
|
|
256
|
-
},
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
await client.connect()
|
|
260
|
-
|
|
261
|
-
// Get the server connection and send notification
|
|
262
|
-
await new Promise((resolve) => setTimeout(resolve, 50)) // Wait for server to register connection
|
|
263
|
-
|
|
264
|
-
const serverConnections = Array.from(testServer.connections.values())
|
|
265
|
-
expect(serverConnections.length).toBeGreaterThan(0)
|
|
266
|
-
|
|
267
|
-
const serverConn = serverConnections[0]
|
|
268
|
-
serverConn.notify.serverEvent({ eventType: 'test_event', data: { foo: 'bar' } })
|
|
269
|
-
|
|
270
|
-
// Wait for notification to arrive
|
|
271
|
-
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
272
|
-
|
|
273
|
-
expect(receivedNotifications.length).toBe(1)
|
|
274
|
-
expect(receivedNotifications[0].eventType).toBe('test_event')
|
|
275
|
-
expect(receivedNotifications[0].data).toEqual({ foo: 'bar' })
|
|
276
|
-
|
|
277
|
-
await client.disconnect()
|
|
278
|
-
})
|
|
279
|
-
|
|
280
|
-
it('should receive agent messages from server', async () => {
|
|
281
|
-
const receivedMessages: Array<{ sessionId: string; content: string }> = []
|
|
282
|
-
|
|
283
|
-
const client = new ClientConnection({
|
|
284
|
-
url: `ws://localhost:${testServer.port}`,
|
|
285
|
-
wsFactory: bunWebSocketFactory,
|
|
286
|
-
receiveProtocol: serverProtocol,
|
|
287
|
-
sendProtocol: clientProtocol,
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
client.setHandlers({
|
|
291
|
-
agentMessage: async (input) => {
|
|
292
|
-
receivedMessages.push(input)
|
|
293
|
-
},
|
|
294
|
-
})
|
|
295
|
-
|
|
296
|
-
await client.connect()
|
|
297
|
-
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
298
|
-
|
|
299
|
-
const serverConnections = Array.from(testServer.connections.values())
|
|
300
|
-
const serverConn = serverConnections[0]
|
|
301
|
-
|
|
302
|
-
serverConn.notify.agentMessage({ sessionId: 'sess-1', content: 'Hello from agent!' })
|
|
303
|
-
|
|
304
|
-
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
305
|
-
|
|
306
|
-
expect(receivedMessages.length).toBe(1)
|
|
307
|
-
expect(receivedMessages[0]).toEqual({ sessionId: 'sess-1', content: 'Hello from agent!' })
|
|
308
|
-
|
|
309
|
-
await client.disconnect()
|
|
310
|
-
})
|
|
311
|
-
})
|
|
312
|
-
|
|
313
|
-
describe('Reconnection', () => {
|
|
314
|
-
it('should attempt reconnection when connection is lost', async () => {
|
|
315
|
-
const reconnectAttempts: number[] = []
|
|
316
|
-
const stateChanges: string[] = []
|
|
317
|
-
|
|
318
|
-
// Create a server that immediately closes connections
|
|
319
|
-
const tempServer = Bun.serve({
|
|
320
|
-
port: 0,
|
|
321
|
-
fetch(req, server) {
|
|
322
|
-
if (server.upgrade(req)) return
|
|
323
|
-
return new Response('ok')
|
|
324
|
-
},
|
|
325
|
-
websocket: {
|
|
326
|
-
open(ws) {
|
|
327
|
-
// Close connection after a brief delay to trigger reconnect
|
|
328
|
-
setTimeout(() => ws.close(1000, 'Server initiated close'), 50)
|
|
329
|
-
},
|
|
330
|
-
message() {},
|
|
331
|
-
close() {},
|
|
332
|
-
},
|
|
333
|
-
})
|
|
334
|
-
|
|
335
|
-
const client = new ClientConnection({
|
|
336
|
-
url: `ws://localhost:${tempServer.port}`,
|
|
337
|
-
wsFactory: bunWebSocketFactory,
|
|
338
|
-
receiveProtocol: serverProtocol,
|
|
339
|
-
sendProtocol: clientProtocol,
|
|
340
|
-
reconnect: {
|
|
341
|
-
baseDelayMs: 20,
|
|
342
|
-
maxDelayMs: 100,
|
|
343
|
-
maxAttempts: 2,
|
|
344
|
-
jitterFactor: 0,
|
|
345
|
-
},
|
|
346
|
-
})
|
|
347
|
-
|
|
348
|
-
client.on((event, data) => {
|
|
349
|
-
if (event === 'reconnecting') {
|
|
350
|
-
reconnectAttempts.push((data as { attempt: number }).attempt)
|
|
351
|
-
}
|
|
352
|
-
if (event === 'state_change') {
|
|
353
|
-
stateChanges.push((data as { to: string }).to)
|
|
354
|
-
}
|
|
355
|
-
})
|
|
356
|
-
|
|
357
|
-
await client.connect()
|
|
358
|
-
|
|
359
|
-
// Wait for server to close connection and client to attempt reconnect
|
|
360
|
-
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
361
|
-
|
|
362
|
-
// Should have attempted reconnection
|
|
363
|
-
expect(reconnectAttempts.length).toBeGreaterThan(0)
|
|
364
|
-
expect(stateChanges).toContain('reconnecting')
|
|
365
|
-
|
|
366
|
-
await client.disconnect()
|
|
367
|
-
tempServer.stop()
|
|
368
|
-
})
|
|
369
|
-
})
|
|
370
|
-
|
|
371
|
-
describe('Multiple Clients', () => {
|
|
372
|
-
it('should handle multiple simultaneous clients', async () => {
|
|
373
|
-
const numClients = 5
|
|
374
|
-
const clients: ClientConnection<typeof serverProtocol._def, typeof clientProtocol._def>[] = []
|
|
375
|
-
|
|
376
|
-
// Connect multiple clients
|
|
377
|
-
for (let i = 0; i < numClients; i++) {
|
|
378
|
-
const client = new ClientConnection({
|
|
379
|
-
url: `ws://localhost:${testServer.port}`,
|
|
380
|
-
wsFactory: bunWebSocketFactory,
|
|
381
|
-
receiveProtocol: serverProtocol,
|
|
382
|
-
sendProtocol: clientProtocol,
|
|
383
|
-
})
|
|
384
|
-
await client.connect()
|
|
385
|
-
clients.push(client)
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// All clients should be connected
|
|
389
|
-
expect(clients.every((c) => c.isConnected())).toBe(true)
|
|
390
|
-
|
|
391
|
-
// Each client sends a notification
|
|
392
|
-
clients.forEach((client, i) => {
|
|
393
|
-
client.notify.userMessage({ sessionId: `sess-${i}`, content: `Message from client ${i}` })
|
|
394
|
-
})
|
|
395
|
-
|
|
396
|
-
// Wait for notifications to arrive
|
|
397
|
-
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
398
|
-
|
|
399
|
-
// All notifications should be received
|
|
400
|
-
expect(testServer.receivedNotifications.length).toBe(numClients)
|
|
401
|
-
|
|
402
|
-
// Disconnect all
|
|
403
|
-
await Promise.all(clients.map((c) => c.disconnect()))
|
|
404
|
-
})
|
|
405
|
-
})
|
|
406
|
-
|
|
407
|
-
describe('Bidirectional Communication', () => {
|
|
408
|
-
it('should handle simultaneous send and receive', async () => {
|
|
409
|
-
const clientReceivedMessages: unknown[] = []
|
|
410
|
-
|
|
411
|
-
const client = new ClientConnection({
|
|
412
|
-
url: `ws://localhost:${testServer.port}`,
|
|
413
|
-
wsFactory: bunWebSocketFactory,
|
|
414
|
-
receiveProtocol: serverProtocol,
|
|
415
|
-
sendProtocol: clientProtocol,
|
|
416
|
-
})
|
|
417
|
-
|
|
418
|
-
client.setHandlers({
|
|
419
|
-
agentMessage: async (input) => {
|
|
420
|
-
clientReceivedMessages.push(input)
|
|
421
|
-
},
|
|
422
|
-
})
|
|
423
|
-
|
|
424
|
-
await client.connect()
|
|
425
|
-
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
426
|
-
|
|
427
|
-
// Client sends notification
|
|
428
|
-
client.notify.userMessage({ sessionId: 'sess-1', content: 'Client message' })
|
|
429
|
-
|
|
430
|
-
// Server sends notification simultaneously
|
|
431
|
-
const serverConnections = Array.from(testServer.connections.values())
|
|
432
|
-
serverConnections[0].notify.agentMessage({ sessionId: 'sess-1', content: 'Server message' })
|
|
433
|
-
|
|
434
|
-
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
435
|
-
|
|
436
|
-
// Both sides should have received notifications
|
|
437
|
-
expect(testServer.receivedNotifications.some((n) => n.type === 'userMessage')).toBe(true)
|
|
438
|
-
expect(clientReceivedMessages.length).toBe(1)
|
|
439
|
-
|
|
440
|
-
await client.disconnect()
|
|
441
|
-
})
|
|
442
|
-
})
|
|
443
|
-
})
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ClientConnection reconnect / connect() settlement tests
|
|
3
|
-
*
|
|
4
|
-
* Regression guard: a WebSocket that closes *before it ever opens* must not
|
|
5
|
-
* reject the initial connect() promise while reconnect is still viable. The
|
|
6
|
-
* agent server awaits connect() during boot; rejecting on the first pre-open
|
|
7
|
-
* close crashed the whole process ("Process died") even though it was
|
|
8
|
-
* configured with an infinite-reconnect policy.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { describe, expect, it } from 'bun:test'
|
|
12
|
-
|
|
13
|
-
import { ClientConnection } from './client-connection.js'
|
|
14
|
-
import type { ICloseEvent, IWebSocket, IWebSocketFactory } from '../platform/types.js'
|
|
15
|
-
import { WebSocketReadyState } from '../platform/types.js'
|
|
16
|
-
|
|
17
|
-
class MockWebSocket implements IWebSocket {
|
|
18
|
-
readyState: WebSocketReadyState = WebSocketReadyState.CONNECTING
|
|
19
|
-
onopen: (() => void) | null = null
|
|
20
|
-
onclose: ((event: ICloseEvent) => void) | null = null
|
|
21
|
-
onerror: ((error: Error) => void) | null = null
|
|
22
|
-
onmessage: ((data: string) => void) | null = null
|
|
23
|
-
|
|
24
|
-
send(_data: string): void {}
|
|
25
|
-
|
|
26
|
-
close(code = 1000, reason = ''): void {
|
|
27
|
-
this.readyState = WebSocketReadyState.CLOSED
|
|
28
|
-
this.onclose?.({ code, reason })
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
simulateOpen(): void {
|
|
32
|
-
this.readyState = WebSocketReadyState.OPEN
|
|
33
|
-
this.onopen?.()
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
simulateClose(code: number, reason: string): void {
|
|
37
|
-
this.readyState = WebSocketReadyState.CLOSED
|
|
38
|
-
this.onclose?.({ code, reason })
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Factory that fires open/close on the next tick — after ClientConnection has
|
|
43
|
-
// wired ws.onopen / ws.onclose in doConnect(). 'close' never opens the socket,
|
|
44
|
-
// reproducing a rejected upgrade / immediate server close on connect.
|
|
45
|
-
class MockWsFactory implements IWebSocketFactory {
|
|
46
|
-
created = 0
|
|
47
|
-
constructor(private readonly behavior: 'close' | 'open') {}
|
|
48
|
-
|
|
49
|
-
create(_url: string): IWebSocket {
|
|
50
|
-
this.created++
|
|
51
|
-
const ws = new MockWebSocket()
|
|
52
|
-
if (this.behavior === 'close') {
|
|
53
|
-
setTimeout(() => ws.simulateClose(1006, 'handshake failed'), 0)
|
|
54
|
-
} else {
|
|
55
|
-
setTimeout(() => ws.simulateOpen(), 0)
|
|
56
|
-
}
|
|
57
|
-
return ws
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const tick = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
62
|
-
|
|
63
|
-
describe('ClientConnection connect() settlement on pre-open close', () => {
|
|
64
|
-
it('keeps connect() pending (no reject) across pre-open closes under infinite reconnect', async () => {
|
|
65
|
-
const factory = new MockWsFactory('close')
|
|
66
|
-
const client = new ClientConnection({
|
|
67
|
-
url: 'ws://mock',
|
|
68
|
-
wsFactory: factory,
|
|
69
|
-
reconnect: { baseDelayMs: 10, maxDelayMs: 20, maxAttempts: Infinity, jitterFactor: 0 },
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
let settled: 'resolved' | 'rejected' | null = null
|
|
73
|
-
const reconnecting: number[] = []
|
|
74
|
-
client.on((event, data) => {
|
|
75
|
-
if (event === 'reconnecting') reconnecting.push((data as { attempt: number }).attempt)
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
const p = client.connect().then(
|
|
79
|
-
() => { settled = 'resolved' },
|
|
80
|
-
() => { settled = 'rejected' },
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
await tick(80)
|
|
84
|
-
|
|
85
|
-
// The crash used to happen here: connect() must NOT have rejected yet.
|
|
86
|
-
expect(settled).toBeNull()
|
|
87
|
-
expect(factory.created).toBeGreaterThan(1) // kept retrying
|
|
88
|
-
expect(reconnecting.length).toBeGreaterThan(1)
|
|
89
|
-
|
|
90
|
-
// disconnect() is the only thing that settles a still-pending connect()
|
|
91
|
-
await client.disconnect()
|
|
92
|
-
await p
|
|
93
|
-
expect(settled).toBe('rejected')
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
it('resolves connect() once a later attempt opens (recovers after transient closes)', async () => {
|
|
97
|
-
// Fails the first two attempts (pre-open close), then opens.
|
|
98
|
-
let attempt = 0
|
|
99
|
-
const factory: IWebSocketFactory = {
|
|
100
|
-
create: () => {
|
|
101
|
-
attempt++
|
|
102
|
-
const ws = new MockWebSocket()
|
|
103
|
-
if (attempt <= 2) {
|
|
104
|
-
setTimeout(() => ws.simulateClose(1006, 'handshake failed'), 0)
|
|
105
|
-
} else {
|
|
106
|
-
setTimeout(() => ws.simulateOpen(), 0)
|
|
107
|
-
}
|
|
108
|
-
return ws
|
|
109
|
-
},
|
|
110
|
-
}
|
|
111
|
-
const client = new ClientConnection({
|
|
112
|
-
url: 'ws://mock',
|
|
113
|
-
wsFactory: factory,
|
|
114
|
-
reconnect: { baseDelayMs: 10, maxDelayMs: 20, maxAttempts: Infinity, jitterFactor: 0 },
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
await client.connect()
|
|
118
|
-
expect(client.isConnected()).toBe(true)
|
|
119
|
-
|
|
120
|
-
await client.disconnect()
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
it('rejects connect() after a finite reconnect budget is exhausted', async () => {
|
|
124
|
-
const factory = new MockWsFactory('close')
|
|
125
|
-
const client = new ClientConnection({
|
|
126
|
-
url: 'ws://mock',
|
|
127
|
-
wsFactory: factory,
|
|
128
|
-
reconnect: { baseDelayMs: 5, maxDelayMs: 10, maxAttempts: 2, jitterFactor: 0 },
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
await expect(client.connect()).rejects.toThrow(/Connection closed/)
|
|
132
|
-
expect(factory.created).toBe(3) // initial attempt + 2 reconnects
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
it('rejects connect() on the first pre-open close when reconnect is disabled', async () => {
|
|
136
|
-
const factory = new MockWsFactory('close')
|
|
137
|
-
const client = new ClientConnection({
|
|
138
|
-
url: 'ws://mock',
|
|
139
|
-
wsFactory: factory,
|
|
140
|
-
// no reconnect option → reconnect disabled
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
await expect(client.connect()).rejects.toThrow(/Connection closed/)
|
|
144
|
-
expect(factory.created).toBe(1)
|
|
145
|
-
})
|
|
146
|
-
})
|