@roj-ai/transport 0.1.36 → 0.2.1

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.
@@ -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
- })