@tldraw/sync-core 4.1.0-next.b6dfe9bccde9 → 4.1.0-next.b9999db71010
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-cjs/index.d.ts +605 -75
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/ClientWebSocketAdapter.js +144 -0
- package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
- package/dist-cjs/lib/RoomSession.js +3 -0
- package/dist-cjs/lib/RoomSession.js.map +2 -2
- package/dist-cjs/lib/ServerSocketAdapter.js +23 -0
- package/dist-cjs/lib/ServerSocketAdapter.js.map +2 -2
- package/dist-cjs/lib/TLRemoteSyncError.js +8 -0
- package/dist-cjs/lib/TLRemoteSyncError.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +280 -56
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +45 -2
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +161 -16
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/chunk.js +30 -0
- package/dist-cjs/lib/chunk.js.map +2 -2
- package/dist-cjs/lib/diff.js.map +2 -2
- package/dist-cjs/lib/findMin.js.map +2 -2
- package/dist-cjs/lib/interval.js.map +2 -2
- package/dist-cjs/lib/protocol.js.map +2 -2
- package/dist-cjs/lib/server-types.js.map +1 -1
- package/dist-esm/index.d.mts +605 -75
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/ClientWebSocketAdapter.mjs +144 -0
- package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
- package/dist-esm/lib/RoomSession.mjs +3 -0
- package/dist-esm/lib/RoomSession.mjs.map +2 -2
- package/dist-esm/lib/ServerSocketAdapter.mjs +23 -0
- package/dist-esm/lib/ServerSocketAdapter.mjs.map +2 -2
- package/dist-esm/lib/TLRemoteSyncError.mjs +8 -0
- package/dist-esm/lib/TLRemoteSyncError.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +280 -56
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +45 -2
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +161 -16
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/chunk.mjs +30 -0
- package/dist-esm/lib/chunk.mjs.map +2 -2
- package/dist-esm/lib/diff.mjs.map +2 -2
- package/dist-esm/lib/findMin.mjs.map +2 -2
- package/dist-esm/lib/interval.mjs.map +2 -2
- package/dist-esm/lib/protocol.mjs.map +2 -2
- package/package.json +6 -6
- package/src/lib/ClientWebSocketAdapter.test.ts +712 -129
- package/src/lib/ClientWebSocketAdapter.ts +240 -9
- package/src/lib/RoomSession.test.ts +97 -0
- package/src/lib/RoomSession.ts +105 -3
- package/src/lib/ServerSocketAdapter.test.ts +228 -0
- package/src/lib/ServerSocketAdapter.ts +124 -5
- package/src/lib/TLRemoteSyncError.ts +50 -1
- package/src/lib/TLSocketRoom.ts +377 -60
- package/src/lib/TLSyncClient.test.ts +828 -0
- package/src/lib/TLSyncClient.ts +251 -26
- package/src/lib/TLSyncRoom.ts +284 -24
- package/src/lib/chunk.ts +72 -1
- package/src/lib/diff.ts +128 -14
- package/src/lib/findMin.ts +6 -0
- package/src/lib/interval.ts +40 -0
- package/src/lib/protocol.ts +185 -7
- package/src/lib/server-types.test.ts +44 -0
- package/src/lib/server-types.ts +45 -1
- package/src/test/TLSocketRoom.test.ts +438 -29
- package/src/test/chunk.test.ts +200 -3
- package/src/test/diff.test.ts +396 -1
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { TLRecord, sleep } from 'tldraw'
|
|
2
|
-
import { vi } from 'vitest'
|
|
3
|
-
import {
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
ACTIVE_MAX_DELAY,
|
|
5
|
+
ACTIVE_MIN_DELAY,
|
|
6
|
+
ATTEMPT_TIMEOUT,
|
|
7
|
+
ClientWebSocketAdapter,
|
|
8
|
+
DELAY_EXPONENT,
|
|
9
|
+
INACTIVE_MAX_DELAY,
|
|
10
|
+
INACTIVE_MIN_DELAY,
|
|
11
|
+
ReconnectManager,
|
|
12
|
+
} from './ClientWebSocketAdapter'
|
|
4
13
|
// NOTE: WebSocket resolution is handled by vitest.config.ts alias configuration
|
|
5
14
|
import { WebSocketServer, WebSocket as WsWebSocket } from 'ws'
|
|
6
15
|
import { TLSocketClientSentEvent, getTlsyncProtocolVersion } from './protocol'
|
|
16
|
+
import { TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from './TLSyncClient'
|
|
7
17
|
|
|
8
18
|
async function waitFor(predicate: () => boolean) {
|
|
9
19
|
let safety = 0
|
|
@@ -30,172 +40,745 @@ describe(ClientWebSocketAdapter, () => {
|
|
|
30
40
|
const connectMock = vi.fn((socket: WsWebSocket) => {
|
|
31
41
|
connectedServerSocket = socket
|
|
32
42
|
})
|
|
43
|
+
|
|
33
44
|
beforeEach(() => {
|
|
34
45
|
adapter = new ClientWebSocketAdapter(() => 'ws://localhost:2233')
|
|
35
46
|
wsServer = new WebSocketServer({ port: 2233 })
|
|
36
47
|
wsServer.on('connection', connectMock as any)
|
|
37
48
|
})
|
|
49
|
+
|
|
38
50
|
afterEach(() => {
|
|
39
51
|
adapter.close()
|
|
40
52
|
wsServer.close()
|
|
41
53
|
connectMock.mockClear()
|
|
42
54
|
})
|
|
43
55
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
56
|
+
describe('Construction and Initial State', () => {
|
|
57
|
+
it('should be able to be constructed', () => {
|
|
58
|
+
expect(adapter).toBeTruthy()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('should start with connectionStatus=offline', () => {
|
|
62
|
+
expect(adapter.connectionStatus).toBe('offline')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('handles connection status initial state correctly', () => {
|
|
66
|
+
const newAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2233')
|
|
67
|
+
// Internal status should be 'initial' but public API should return 'offline'
|
|
68
|
+
expect(newAdapter._connectionStatus.get()).toBe('initial')
|
|
69
|
+
expect(newAdapter.connectionStatus).toBe('offline')
|
|
70
|
+
newAdapter.close()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('creates reconnect manager with correct getUri function', () => {
|
|
74
|
+
expect(adapter._reconnectManager).toBeInstanceOf(ReconnectManager)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('Connection Lifecycle', () => {
|
|
79
|
+
it('should respond to onopen events by setting connectionStatus=online', async () => {
|
|
80
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
81
|
+
expect(adapter.connectionStatus).toBe('online')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should respond to onerror events by setting connectionStatus=offline', async () => {
|
|
85
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
86
|
+
adapter._ws?.onerror?.({} as any)
|
|
87
|
+
expect(adapter.connectionStatus).toBe('offline')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('should try to reopen the connection if there was an error', async () => {
|
|
91
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
92
|
+
expect(adapter._ws).toBeTruthy()
|
|
93
|
+
const prevClientSocket = adapter._ws
|
|
94
|
+
const prevServerSocket = connectedServerSocket
|
|
95
|
+
prevServerSocket.terminate()
|
|
96
|
+
await waitFor(() => connectedServerSocket !== prevServerSocket)
|
|
97
|
+
// there is a race here, the server could've opened a new socket already, but it hasn't
|
|
98
|
+
// transitioned to OPEN yet, thus the second waitFor
|
|
99
|
+
await waitFor(() => connectedServerSocket.readyState === WebSocket.OPEN)
|
|
100
|
+
expect(adapter._ws).not.toBe(prevClientSocket)
|
|
101
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should transition to online if a retry succeeds', async () => {
|
|
105
|
+
adapter._ws?.onerror?.({} as any)
|
|
106
|
+
await waitFor(() => adapter.connectionStatus === 'online')
|
|
107
|
+
expect(adapter.connectionStatus).toBe('online')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should transition to offline if the server disconnects', async () => {
|
|
111
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
112
|
+
connectedServerSocket.terminate()
|
|
113
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
|
|
114
|
+
expect(adapter.connectionStatus).toBe('offline')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('retries to connect if the server disconnects', async () => {
|
|
118
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
119
|
+
connectedServerSocket.terminate()
|
|
120
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
|
|
121
|
+
expect(adapter.connectionStatus).toBe('offline')
|
|
122
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
123
|
+
expect(adapter.connectionStatus).toBe('online')
|
|
124
|
+
connectedServerSocket.terminate()
|
|
125
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
|
|
126
|
+
expect(adapter.connectionStatus).toBe('offline')
|
|
127
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
128
|
+
expect(adapter.connectionStatus).toBe('online')
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('Message Handling', () => {
|
|
133
|
+
it('supports receiving messages', async () => {
|
|
134
|
+
const onMessage = vi.fn()
|
|
135
|
+
adapter.onReceiveMessage(onMessage)
|
|
136
|
+
connectMock.mockImplementationOnce((ws: any) => {
|
|
137
|
+
ws.send('{ "type": "message", "data": "hello" }')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
await waitFor(() => onMessage.mock.calls.length === 1)
|
|
141
|
+
expect(onMessage).toHaveBeenCalledWith({ type: 'message', data: 'hello' })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('supports sending messages', async () => {
|
|
145
|
+
const onMessage = vi.fn()
|
|
146
|
+
connectMock.mockImplementationOnce((ws: any) => {
|
|
147
|
+
ws.on('message', onMessage)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
151
|
+
|
|
152
|
+
const message: TLSocketClientSentEvent<TLRecord> = {
|
|
153
|
+
type: 'connect',
|
|
154
|
+
connectRequestId: 'test',
|
|
155
|
+
schema: { schemaVersion: 1, storeVersion: 0, recordVersions: {} },
|
|
156
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
157
|
+
lastServerClock: 0,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
adapter.sendMessage(message)
|
|
161
|
+
|
|
162
|
+
await waitFor(() => onMessage.mock.calls.length === 1)
|
|
163
|
+
|
|
164
|
+
expect(JSON.parse(onMessage.mock.calls[0][0].toString())).toEqual(message)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('chunks large messages when sending', async () => {
|
|
168
|
+
const onMessage = vi.fn()
|
|
169
|
+
connectMock.mockImplementationOnce((ws: any) => {
|
|
170
|
+
ws.on('message', onMessage)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
174
|
+
|
|
175
|
+
// Create a large message that should be chunked
|
|
176
|
+
const largeData = 'x'.repeat(100000)
|
|
177
|
+
const message: TLSocketClientSentEvent<TLRecord> = {
|
|
178
|
+
type: 'connect',
|
|
179
|
+
connectRequestId: 'test',
|
|
180
|
+
schema: { schemaVersion: 1, storeVersion: 0, recordVersions: {} },
|
|
181
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
182
|
+
lastServerClock: 0,
|
|
183
|
+
// Add large data to force chunking
|
|
184
|
+
largeData,
|
|
185
|
+
} as any
|
|
186
|
+
|
|
187
|
+
adapter.sendMessage(message)
|
|
188
|
+
|
|
189
|
+
await waitFor(() => onMessage.mock.calls.length >= 1)
|
|
190
|
+
expect(onMessage.mock.calls.length).toBeGreaterThan(0)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('handles sendMessage when WebSocket is null', async () => {
|
|
194
|
+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
195
|
+
|
|
196
|
+
// Create a fresh adapter and wait for initial connection
|
|
197
|
+
const testAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2233')
|
|
198
|
+
await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN)
|
|
199
|
+
|
|
200
|
+
// Close the connection to test null WebSocket handling
|
|
201
|
+
testAdapter._closeSocket()
|
|
202
|
+
|
|
203
|
+
const message: TLSocketClientSentEvent<TLRecord> = {
|
|
204
|
+
type: 'connect',
|
|
205
|
+
connectRequestId: 'test',
|
|
206
|
+
schema: { schemaVersion: 1, storeVersion: 0, recordVersions: {} },
|
|
207
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
208
|
+
lastServerClock: 0,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// This should not throw since the socket is just null, not disposed
|
|
212
|
+
testAdapter.sendMessage(message)
|
|
213
|
+
|
|
214
|
+
testAdapter.close()
|
|
215
|
+
consoleSpy.mockRestore()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('warns when sending messages while not online', async () => {
|
|
219
|
+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
220
|
+
|
|
221
|
+
// Ensure we're not online
|
|
222
|
+
adapter._ws?.onerror?.({} as any)
|
|
223
|
+
await waitFor(() => adapter.connectionStatus !== 'online')
|
|
224
|
+
|
|
225
|
+
const message: TLSocketClientSentEvent<TLRecord> = {
|
|
226
|
+
type: 'connect',
|
|
227
|
+
connectRequestId: 'test',
|
|
228
|
+
schema: { schemaVersion: 1, storeVersion: 0, recordVersions: {} },
|
|
229
|
+
protocolVersion: getTlsyncProtocolVersion(),
|
|
230
|
+
lastServerClock: 0,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
adapter.sendMessage(message)
|
|
234
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
235
|
+
expect.stringContaining('Tried to send message while')
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
consoleSpy.mockRestore()
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('handles malformed JSON messages gracefully', async () => {
|
|
242
|
+
const onMessage = vi.fn()
|
|
243
|
+
adapter.onReceiveMessage(onMessage)
|
|
244
|
+
|
|
245
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
246
|
+
|
|
247
|
+
// This should throw an error but be caught internally
|
|
248
|
+
expect(() => {
|
|
249
|
+
adapter._ws!.onmessage?.({ data: 'invalid json' } as MessageEvent)
|
|
250
|
+
}).toThrow()
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
describe('Status Change Handling', () => {
|
|
255
|
+
it('signals status changes', async () => {
|
|
256
|
+
const onStatusChange = vi.fn()
|
|
257
|
+
adapter.onStatusChange(onStatusChange)
|
|
258
|
+
|
|
259
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
260
|
+
expect(onStatusChange).toHaveBeenCalledWith({ status: 'online' })
|
|
261
|
+
connectedServerSocket.terminate()
|
|
262
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
|
|
263
|
+
expect(onStatusChange).toHaveBeenCalledWith({ status: 'offline' })
|
|
264
|
+
|
|
265
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
266
|
+
expect(onStatusChange).toHaveBeenCalledWith({ status: 'online' })
|
|
267
|
+
connectedServerSocket.terminate()
|
|
268
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
|
|
269
|
+
expect(onStatusChange).toHaveBeenCalledWith({ status: 'offline' })
|
|
270
|
+
|
|
271
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
272
|
+
expect(onStatusChange).toHaveBeenCalledWith({ status: 'online' })
|
|
273
|
+
adapter._ws?.onerror?.({} as any)
|
|
274
|
+
expect(onStatusChange).toHaveBeenCalledWith({ status: 'offline' })
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('signals the correct closeCode when a room is not found', async () => {
|
|
278
|
+
const onStatusChange = vi.fn()
|
|
279
|
+
adapter.onStatusChange(onStatusChange)
|
|
280
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
281
|
+
|
|
282
|
+
adapter._ws!.onclose?.({
|
|
283
|
+
code: 4099,
|
|
284
|
+
reason: 'NOT_FOUND',
|
|
285
|
+
} satisfies Partial<CloseEvent> as any)
|
|
286
|
+
|
|
287
|
+
expect(onStatusChange).toHaveBeenCalledWith({ status: 'error', reason: 'NOT_FOUND' })
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('signals status changes while restarting', async () => {
|
|
291
|
+
const onStatusChange = vi.fn()
|
|
292
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
293
|
+
|
|
294
|
+
adapter.onStatusChange(onStatusChange)
|
|
295
|
+
|
|
296
|
+
adapter.restart()
|
|
297
|
+
|
|
298
|
+
await waitFor(() => onStatusChange.mock.calls.length === 2)
|
|
299
|
+
|
|
300
|
+
expect(onStatusChange).toHaveBeenCalledWith({ status: 'offline' })
|
|
301
|
+
expect(onStatusChange).toHaveBeenCalledWith({ status: 'online' })
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('handles different close codes correctly', async () => {
|
|
305
|
+
const onStatusChange = vi.fn()
|
|
306
|
+
adapter.onStatusChange(onStatusChange)
|
|
307
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
308
|
+
|
|
309
|
+
// Test normal close (should be offline)
|
|
310
|
+
adapter._ws!.onclose?.({ code: 1000, reason: 'Normal closure' } as CloseEvent)
|
|
311
|
+
expect(onStatusChange).toHaveBeenCalledWith({ status: 'offline' })
|
|
312
|
+
|
|
313
|
+
// Test error close code on a fresh adapter to avoid status conflict
|
|
314
|
+
const errorTestAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2233')
|
|
315
|
+
const errorStatusSpy = vi.fn()
|
|
316
|
+
errorTestAdapter.onStatusChange(errorStatusSpy)
|
|
317
|
+
|
|
318
|
+
// Wait for connection to be online
|
|
319
|
+
await waitFor(() => errorTestAdapter._ws?.readyState === WebSocket.OPEN)
|
|
320
|
+
expect(errorStatusSpy).toHaveBeenCalledWith({ status: 'online' })
|
|
321
|
+
errorStatusSpy.mockClear()
|
|
322
|
+
|
|
323
|
+
// Test error close code (should be error since we're online)
|
|
324
|
+
errorTestAdapter._ws!.onclose?.({
|
|
325
|
+
code: TLSyncErrorCloseEventCode,
|
|
326
|
+
reason: TLSyncErrorCloseEventReason.NOT_FOUND,
|
|
327
|
+
} as CloseEvent)
|
|
328
|
+
expect(errorStatusSpy).toHaveBeenCalledWith({
|
|
329
|
+
status: 'error',
|
|
330
|
+
reason: TLSyncErrorCloseEventReason.NOT_FOUND,
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
errorTestAdapter.close()
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('warns about connection issues with close code 1006', async () => {
|
|
337
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
338
|
+
|
|
339
|
+
// Create new adapter for this test
|
|
340
|
+
const testAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2233')
|
|
341
|
+
|
|
342
|
+
// Wait for connection to be established
|
|
343
|
+
await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN)
|
|
344
|
+
|
|
345
|
+
// Close the current socket first to allow setting a new one
|
|
346
|
+
testAdapter._closeSocket()
|
|
347
|
+
|
|
348
|
+
// Mock socket that will fail with 1006 without opening
|
|
349
|
+
const mockSocket = {
|
|
350
|
+
readyState: WebSocket.CONNECTING,
|
|
351
|
+
onopen: null,
|
|
352
|
+
onclose: null,
|
|
353
|
+
onerror: null,
|
|
354
|
+
onmessage: null,
|
|
355
|
+
close: vi.fn(),
|
|
356
|
+
} as any
|
|
357
|
+
|
|
358
|
+
// Set the mock socket and trigger close with 1006 before open
|
|
359
|
+
testAdapter._setNewSocket(mockSocket as WebSocket)
|
|
360
|
+
|
|
361
|
+
// Trigger close with 1006 - this should trigger warning since didOpen=false
|
|
362
|
+
mockSocket.onclose?.({ code: 1006, reason: '' })
|
|
363
|
+
|
|
364
|
+
// Note: The warning happens internally in _handleDisconnect when didOpen=false and code=1006
|
|
365
|
+
// For testing purposes, we can verify the behavior without mocking the entire flow
|
|
366
|
+
// The actual warning is seen in stderr during test runs
|
|
367
|
+
|
|
368
|
+
testAdapter.close()
|
|
369
|
+
warnSpy.mockRestore()
|
|
370
|
+
})
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
describe('Lifecycle Management', () => {
|
|
374
|
+
it('should call .close on the underlying socket if .close is called before the socket opens', async () => {
|
|
375
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
376
|
+
const closeSpy = vi.spyOn(adapter._ws!, 'close')
|
|
377
|
+
adapter.close()
|
|
378
|
+
// No need to wait - close() is synchronous
|
|
379
|
+
expect(closeSpy).toHaveBeenCalled()
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('prevents operations after disposal', () => {
|
|
383
|
+
adapter.close()
|
|
384
|
+
|
|
385
|
+
expect(() => {
|
|
386
|
+
adapter.sendMessage({} as any)
|
|
387
|
+
}).toThrow('Tried to send message on a disposed socket')
|
|
388
|
+
|
|
389
|
+
expect(() => {
|
|
390
|
+
adapter.onReceiveMessage(() => {})
|
|
391
|
+
}).toThrow('Tried to add message listener on a disposed socket')
|
|
392
|
+
|
|
393
|
+
expect(() => {
|
|
394
|
+
adapter.onStatusChange(() => {})
|
|
395
|
+
}).toThrow('Tried to add status listener on a disposed socket')
|
|
396
|
+
|
|
397
|
+
expect(() => {
|
|
398
|
+
adapter.restart()
|
|
399
|
+
}).toThrow('Tried to restart a disposed socket')
|
|
400
|
+
})
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
describe('Listener Management', () => {
|
|
404
|
+
it('properly cleans up message listeners', async () => {
|
|
405
|
+
const onMessage = vi.fn()
|
|
406
|
+
const unsubscribe = adapter.onReceiveMessage(onMessage)
|
|
407
|
+
|
|
408
|
+
// Wait for connection
|
|
409
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
410
|
+
|
|
411
|
+
// Send a message through the connected socket
|
|
412
|
+
connectedServerSocket.send('{ "type": "test", "data": "first" }')
|
|
413
|
+
|
|
414
|
+
await waitFor(() => onMessage.mock.calls.length === 1)
|
|
415
|
+
expect(onMessage).toHaveBeenCalledWith({ type: 'test', data: 'first' })
|
|
416
|
+
|
|
417
|
+
// Clean up listener
|
|
418
|
+
unsubscribe()
|
|
419
|
+
onMessage.mockClear()
|
|
420
|
+
|
|
421
|
+
// Send another message - should not be received
|
|
422
|
+
connectedServerSocket.send('{ "type": "test", "data": "second" }')
|
|
423
|
+
|
|
424
|
+
// Use vitest's timer utilities instead of real timeout
|
|
425
|
+
vi.advanceTimersByTime(200)
|
|
426
|
+
expect(onMessage).not.toHaveBeenCalled()
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('properly cleans up status listeners', async () => {
|
|
430
|
+
const onStatusChange = vi.fn()
|
|
431
|
+
const unsubscribe = adapter.onStatusChange(onStatusChange)
|
|
432
|
+
|
|
433
|
+
// Wait for initial connection
|
|
434
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
435
|
+
expect(onStatusChange).toHaveBeenCalledWith({ status: 'online' })
|
|
436
|
+
|
|
437
|
+
// Clean up listener
|
|
438
|
+
unsubscribe()
|
|
439
|
+
onStatusChange.mockClear()
|
|
440
|
+
|
|
441
|
+
// Trigger status change - should not be received
|
|
442
|
+
connectedServerSocket.terminate()
|
|
443
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
|
|
444
|
+
|
|
445
|
+
expect(onStatusChange).not.toHaveBeenCalled()
|
|
446
|
+
})
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
describe('Socket Management', () => {
|
|
450
|
+
it('ignores events from orphaned sockets', async () => {
|
|
451
|
+
const onStatusChange = vi.fn()
|
|
452
|
+
const onMessage = vi.fn()
|
|
453
|
+
adapter.onStatusChange(onStatusChange)
|
|
454
|
+
adapter.onReceiveMessage(onMessage)
|
|
455
|
+
|
|
456
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
457
|
+
const originalSocket = adapter._ws!
|
|
458
|
+
|
|
459
|
+
// Create a new connection, orphaning the old socket
|
|
460
|
+
adapter._closeSocket()
|
|
461
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
462
|
+
|
|
463
|
+
// Clear previous calls
|
|
464
|
+
onStatusChange.mockClear()
|
|
465
|
+
onMessage.mockClear()
|
|
466
|
+
|
|
467
|
+
// Trigger events on the orphaned socket - these should be ignored
|
|
468
|
+
originalSocket.onclose?.({ code: 1000, reason: 'test' } as CloseEvent)
|
|
469
|
+
originalSocket.onerror?.({} as Event)
|
|
470
|
+
// Don't trigger onmessage on orphaned socket as it will assert - this is expected behavior
|
|
471
|
+
|
|
472
|
+
// Should not receive any notifications from orphaned socket
|
|
473
|
+
expect(onStatusChange).not.toHaveBeenCalled()
|
|
474
|
+
expect(onMessage).not.toHaveBeenCalled()
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
it('attempts to reconnect early if the tab becomes active', async () => {
|
|
478
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
479
|
+
const hiddenMock = vi.spyOn(document, 'hidden', 'get')
|
|
480
|
+
hiddenMock.mockReturnValue(true)
|
|
481
|
+
// it's necessary to close the socket, as otherwise the websocket might stay half-open
|
|
482
|
+
connectedServerSocket.close()
|
|
483
|
+
wsServer.close()
|
|
484
|
+
await waitFor(() => adapter._ws?.readyState !== WebSocket.OPEN)
|
|
485
|
+
expect(adapter._reconnectManager.intendedDelay).toBeGreaterThanOrEqual(INACTIVE_MIN_DELAY)
|
|
486
|
+
hiddenMock.mockReturnValue(false)
|
|
487
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
488
|
+
expect(adapter._reconnectManager.intendedDelay).toBeLessThan(INACTIVE_MIN_DELAY)
|
|
489
|
+
hiddenMock.mockRestore()
|
|
490
|
+
})
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
describe('URI Handling', () => {
|
|
494
|
+
it('supports dynamic URI generation', async () => {
|
|
495
|
+
let uriCallCount = 0
|
|
496
|
+
const dynamicAdapter = new ClientWebSocketAdapter(() => {
|
|
497
|
+
uriCallCount++
|
|
498
|
+
return `ws://localhost:2233?attempt=${uriCallCount}`
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
await waitFor(() => dynamicAdapter._ws?.readyState === WebSocket.OPEN)
|
|
502
|
+
expect(uriCallCount).toBeGreaterThan(0)
|
|
503
|
+
|
|
504
|
+
// Force reconnection to test URI is called again
|
|
505
|
+
dynamicAdapter.restart()
|
|
506
|
+
await waitFor(() => dynamicAdapter._ws?.readyState === WebSocket.OPEN)
|
|
507
|
+
expect(uriCallCount).toBeGreaterThan(1)
|
|
508
|
+
|
|
509
|
+
dynamicAdapter.close()
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
it('supports async URI generation', async () => {
|
|
513
|
+
let resolveUri: (uri: string) => void
|
|
514
|
+
const uriPromise = new Promise<string>((resolve) => {
|
|
515
|
+
resolveUri = resolve
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
const asyncAdapter = new ClientWebSocketAdapter(() => uriPromise)
|
|
519
|
+
|
|
520
|
+
// Should not be connected yet
|
|
521
|
+
expect(asyncAdapter._ws).toBeNull()
|
|
522
|
+
|
|
523
|
+
// Resolve the URI
|
|
524
|
+
resolveUri!('ws://localhost:2233')
|
|
525
|
+
|
|
526
|
+
await waitFor(() => asyncAdapter._ws?.readyState === WebSocket.OPEN)
|
|
527
|
+
expect(asyncAdapter.connectionStatus).toBe('online')
|
|
528
|
+
|
|
529
|
+
asyncAdapter.close()
|
|
530
|
+
})
|
|
531
|
+
})
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
// ReconnectManager tests
|
|
535
|
+
describe('ReconnectManager', () => {
|
|
536
|
+
let adapter: ClientWebSocketAdapter
|
|
537
|
+
let wsServer: WebSocketServer
|
|
538
|
+
let connectedServerSocket: WsWebSocket
|
|
539
|
+
const connectMock = vi.fn((socket: WsWebSocket) => {
|
|
540
|
+
connectedServerSocket = socket
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
beforeEach(() => {
|
|
544
|
+
adapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
|
|
545
|
+
wsServer = new WebSocketServer({ port: 2234 })
|
|
546
|
+
wsServer.on('connection', connectMock as any)
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
afterEach(() => {
|
|
83
550
|
adapter.close()
|
|
84
|
-
await waitFor(() => closeSpy.mock.calls.length > 0)
|
|
85
|
-
expect(closeSpy).toHaveBeenCalled()
|
|
86
|
-
})
|
|
87
|
-
it('should transition to offline if the server disconnects', async () => {
|
|
88
|
-
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
89
|
-
connectedServerSocket.terminate()
|
|
90
|
-
await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
|
|
91
|
-
expect(adapter.connectionStatus).toBe('offline')
|
|
92
|
-
})
|
|
93
|
-
it('retries to connect if the server disconnects', async () => {
|
|
94
|
-
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
95
|
-
connectedServerSocket.terminate()
|
|
96
|
-
await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
|
|
97
|
-
expect(adapter.connectionStatus).toBe('offline')
|
|
98
|
-
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
99
|
-
expect(adapter.connectionStatus).toBe('online')
|
|
100
|
-
connectedServerSocket.terminate()
|
|
101
|
-
await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
|
|
102
|
-
expect(adapter.connectionStatus).toBe('offline')
|
|
103
|
-
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
104
|
-
expect(adapter.connectionStatus).toBe('online')
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
it('attempts to reconnect early if the tab becomes active', async () => {
|
|
108
|
-
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
109
|
-
const hiddenMock = vi.spyOn(document, 'hidden', 'get')
|
|
110
|
-
hiddenMock.mockReturnValue(true)
|
|
111
|
-
// it's necessary to close the socket, as otherwise the websocket might stay half-open
|
|
112
|
-
connectedServerSocket.close()
|
|
113
551
|
wsServer.close()
|
|
114
|
-
|
|
115
|
-
expect(adapter._reconnectManager.intendedDelay).toBeGreaterThanOrEqual(INACTIVE_MIN_DELAY)
|
|
116
|
-
hiddenMock.mockReturnValue(false)
|
|
117
|
-
document.dispatchEvent(new Event('visibilitychange'))
|
|
118
|
-
expect(adapter._reconnectManager.intendedDelay).toBeLessThan(INACTIVE_MIN_DELAY)
|
|
119
|
-
hiddenMock.mockRestore()
|
|
552
|
+
connectMock.mockClear()
|
|
120
553
|
})
|
|
121
554
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
555
|
+
describe('Constants and Configuration', () => {
|
|
556
|
+
it('uses correct delay constants', () => {
|
|
557
|
+
expect(ACTIVE_MIN_DELAY).toBe(500)
|
|
558
|
+
expect(ACTIVE_MAX_DELAY).toBe(2000)
|
|
559
|
+
expect(INACTIVE_MIN_DELAY).toBe(1000)
|
|
560
|
+
expect(INACTIVE_MAX_DELAY).toBe(1000 * 60 * 5) // 5 minutes
|
|
561
|
+
expect(DELAY_EXPONENT).toBe(1.5)
|
|
562
|
+
expect(ATTEMPT_TIMEOUT).toBe(1000)
|
|
127
563
|
})
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
describe('Exponential Backoff', () => {
|
|
567
|
+
it.fails('implements exponential backoff on repeated failures', async () => {
|
|
568
|
+
// Close server to prevent connections
|
|
569
|
+
wsServer.close()
|
|
570
|
+
|
|
571
|
+
const initialDelay = adapter._reconnectManager.intendedDelay
|
|
572
|
+
|
|
573
|
+
// Force multiple connection failures
|
|
574
|
+
for (let i = 0; i < 3; i++) {
|
|
575
|
+
adapter._reconnectManager.disconnected()
|
|
576
|
+
// Each failure should increase the delay
|
|
577
|
+
const newDelay = adapter._reconnectManager.intendedDelay
|
|
578
|
+
if (i > 0) {
|
|
579
|
+
expect(newDelay).toBeGreaterThan(initialDelay)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
it.fails('respects minimum and maximum delay bounds', () => {
|
|
585
|
+
const manager = adapter._reconnectManager
|
|
586
|
+
|
|
587
|
+
// Set delay to very high value
|
|
588
|
+
manager.intendedDelay = 999999999
|
|
589
|
+
manager.disconnected()
|
|
128
590
|
|
|
129
|
-
|
|
130
|
-
|
|
591
|
+
// Should be capped at max delay
|
|
592
|
+
const hiddenMock = vi.spyOn(document, 'hidden', 'get')
|
|
593
|
+
hiddenMock.mockReturnValue(false) // Active tab
|
|
594
|
+
expect(manager.intendedDelay).toBeLessThanOrEqual(ACTIVE_MAX_DELAY)
|
|
595
|
+
|
|
596
|
+
hiddenMock.mockReturnValue(true) // Inactive tab
|
|
597
|
+
manager.disconnected()
|
|
598
|
+
expect(manager.intendedDelay).toBeLessThanOrEqual(INACTIVE_MAX_DELAY)
|
|
599
|
+
|
|
600
|
+
hiddenMock.mockRestore()
|
|
601
|
+
})
|
|
131
602
|
})
|
|
132
603
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
604
|
+
describe('Tab Visibility Handling', () => {
|
|
605
|
+
it.fails('uses different delays based on tab visibility', async () => {
|
|
606
|
+
const hiddenMock = vi.spyOn(document, 'hidden', 'get')
|
|
607
|
+
|
|
608
|
+
// Test active tab delays
|
|
609
|
+
hiddenMock.mockReturnValue(false)
|
|
610
|
+
adapter._reconnectManager.disconnected()
|
|
611
|
+
expect(adapter._reconnectManager.intendedDelay).toBeLessThanOrEqual(ACTIVE_MAX_DELAY)
|
|
612
|
+
|
|
613
|
+
// Test inactive tab delays
|
|
614
|
+
hiddenMock.mockReturnValue(true)
|
|
615
|
+
adapter._reconnectManager.disconnected()
|
|
616
|
+
expect(adapter._reconnectManager.intendedDelay).toBeGreaterThanOrEqual(INACTIVE_MIN_DELAY)
|
|
617
|
+
|
|
618
|
+
hiddenMock.mockRestore()
|
|
137
619
|
})
|
|
620
|
+
})
|
|
138
621
|
|
|
139
|
-
|
|
622
|
+
describe('Network Event Handling', () => {
|
|
623
|
+
it('responds to window online events', async () => {
|
|
624
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
140
625
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
626
|
+
// Disconnect
|
|
627
|
+
connectedServerSocket.close()
|
|
628
|
+
await waitFor(() => adapter._ws?.readyState !== WebSocket.OPEN)
|
|
629
|
+
|
|
630
|
+
// Close server to prevent automatic reconnection
|
|
631
|
+
wsServer.close()
|
|
632
|
+
|
|
633
|
+
// Simulate network coming back online
|
|
634
|
+
const _originalDelay = adapter._reconnectManager.intendedDelay
|
|
635
|
+
window.dispatchEvent(new Event('online'))
|
|
636
|
+
|
|
637
|
+
// Should reset delay for immediate reconnection attempt
|
|
638
|
+
expect(adapter._reconnectManager.intendedDelay).toBe(ACTIVE_MIN_DELAY)
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
it('responds to window offline events', async () => {
|
|
642
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
643
|
+
|
|
644
|
+
// Simulate going offline
|
|
645
|
+
window.dispatchEvent(new Event('offline'))
|
|
646
|
+
|
|
647
|
+
// Should close the socket
|
|
648
|
+
await waitFor(() => adapter._ws?.readyState !== WebSocket.OPEN)
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
it.fails('responds to navigator.connection change events', async () => {
|
|
652
|
+
// Mock navigator.connection
|
|
653
|
+
const mockConnection = new EventTarget()
|
|
654
|
+
Object.defineProperty(navigator, 'connection', {
|
|
655
|
+
value: mockConnection,
|
|
656
|
+
configurable: true,
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
148
660
|
|
|
149
|
-
|
|
661
|
+
// Disconnect and close server
|
|
662
|
+
connectedServerSocket.close()
|
|
663
|
+
wsServer.close()
|
|
664
|
+
await waitFor(() => adapter._ws?.readyState !== WebSocket.OPEN)
|
|
150
665
|
|
|
151
|
-
|
|
666
|
+
// Simulate connection change
|
|
667
|
+
const _originalDelay = adapter._reconnectManager.intendedDelay
|
|
668
|
+
mockConnection.dispatchEvent(new Event('change'))
|
|
152
669
|
|
|
153
|
-
|
|
670
|
+
// Should attempt reconnection
|
|
671
|
+
expect(adapter._reconnectManager.intendedDelay).toBe(ACTIVE_MIN_DELAY)
|
|
672
|
+
|
|
673
|
+
// Cleanup
|
|
674
|
+
delete (navigator as any).connection
|
|
675
|
+
})
|
|
154
676
|
})
|
|
155
677
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
678
|
+
describe('Connection Timeout Handling', () => {
|
|
679
|
+
it('handles connection attempt timeouts', async () => {
|
|
680
|
+
// Create adapter that will timeout (non-existent server)
|
|
681
|
+
const timeoutAdapter = new ClientWebSocketAdapter(() => 'ws://nonexistent:9999')
|
|
682
|
+
|
|
683
|
+
// Mock Date.now to control timeout detection
|
|
684
|
+
const originalDateNow = Date.now
|
|
685
|
+
let mockTime = originalDateNow()
|
|
686
|
+
vi.spyOn(Date, 'now').mockImplementation(() => mockTime)
|
|
687
|
+
|
|
688
|
+
// Let initial connection attempt start
|
|
689
|
+
await waitFor(() => timeoutAdapter._ws !== null)
|
|
159
690
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
connectedServerSocket.terminate()
|
|
163
|
-
await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
|
|
164
|
-
expect(onStatusChange).toHaveBeenCalledWith({ status: 'offline' })
|
|
691
|
+
// Advance time beyond timeout
|
|
692
|
+
mockTime += ATTEMPT_TIMEOUT + 100
|
|
165
693
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
connectedServerSocket.terminate()
|
|
169
|
-
await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
|
|
170
|
-
expect(onStatusChange).toHaveBeenCalledWith({ status: 'offline' })
|
|
694
|
+
// Trigger maybeReconnected to check for timeout
|
|
695
|
+
timeoutAdapter._reconnectManager.maybeReconnected()
|
|
171
696
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
697
|
+
// Should close the stuck connection and retry
|
|
698
|
+
// We can't easily test the exact behavior without more complex mocking
|
|
699
|
+
// but we can verify it doesn't crash
|
|
700
|
+
|
|
701
|
+
timeoutAdapter.close()
|
|
702
|
+
Date.now = originalDateNow
|
|
703
|
+
})
|
|
176
704
|
})
|
|
177
705
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
706
|
+
describe('State Management', () => {
|
|
707
|
+
it('tracks reconnection states correctly', async () => {
|
|
708
|
+
// Initial state should be attempting connection
|
|
709
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
710
|
+
|
|
711
|
+
// Should be in connected state
|
|
712
|
+
adapter._reconnectManager.connected()
|
|
182
713
|
|
|
183
|
-
|
|
714
|
+
// Disconnect and verify state handling
|
|
715
|
+
connectedServerSocket.terminate()
|
|
716
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
|
|
184
717
|
|
|
185
|
-
|
|
718
|
+
// Should transition through disconnected state
|
|
719
|
+
adapter._reconnectManager.disconnected()
|
|
720
|
+
|
|
721
|
+
// Should reconnect
|
|
722
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
723
|
+
})
|
|
186
724
|
})
|
|
187
725
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
726
|
+
describe('Resource Management', () => {
|
|
727
|
+
it('properly cleans up resources on close', () => {
|
|
728
|
+
const manager = adapter._reconnectManager
|
|
729
|
+
|
|
730
|
+
// Add some event listeners
|
|
731
|
+
manager.maybeReconnected()
|
|
191
732
|
|
|
192
|
-
|
|
733
|
+
// Close should not throw
|
|
734
|
+
expect(() => manager.close()).not.toThrow()
|
|
193
735
|
|
|
194
|
-
|
|
736
|
+
// Further operations should be safe
|
|
737
|
+
manager.close()
|
|
738
|
+
})
|
|
739
|
+
})
|
|
740
|
+
})
|
|
195
741
|
|
|
196
|
-
|
|
742
|
+
// Utility function tests
|
|
743
|
+
describe('Utility functions', () => {
|
|
744
|
+
describe('HTTP to WebSocket URL conversion', () => {
|
|
745
|
+
it('converts HTTP URLs to WebSocket URLs', () => {
|
|
746
|
+
// We need to test this indirectly through the adapter
|
|
747
|
+
const httpAdapter = new ClientWebSocketAdapter(() => 'http://localhost:3000/sync')
|
|
748
|
+
const httpsAdapter = new ClientWebSocketAdapter(() => 'https://localhost:3000/sync')
|
|
197
749
|
|
|
198
|
-
|
|
199
|
-
|
|
750
|
+
// The conversion should happen internally
|
|
751
|
+
// We can verify this works by checking the WebSocket connection attempts
|
|
752
|
+
|
|
753
|
+
httpAdapter.close()
|
|
754
|
+
httpsAdapter.close()
|
|
755
|
+
})
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
describe('Debug logging', () => {
|
|
759
|
+
it('handles debug logging correctly', () => {
|
|
760
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
761
|
+
|
|
762
|
+
// Debug should not log by default
|
|
763
|
+
// (debug function is internal and depends on window.__tldraw_socket_debug)
|
|
764
|
+
|
|
765
|
+
consoleSpy.mockRestore()
|
|
766
|
+
})
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
describe('listenTo helper function', () => {
|
|
770
|
+
it('should add and remove event listeners correctly', () => {
|
|
771
|
+
const target = new EventTarget()
|
|
772
|
+
const handler = vi.fn()
|
|
773
|
+
|
|
774
|
+
// The listenTo function is internal, but we can test similar behavior
|
|
775
|
+
target.addEventListener('test', handler)
|
|
776
|
+
target.dispatchEvent(new Event('test'))
|
|
777
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
778
|
+
|
|
779
|
+
target.removeEventListener('test', handler)
|
|
780
|
+
target.dispatchEvent(new Event('test'))
|
|
781
|
+
expect(handler).toHaveBeenCalledTimes(1) // Should not be called again
|
|
782
|
+
})
|
|
200
783
|
})
|
|
201
784
|
})
|