@tldraw/sync-core 5.2.0-canary.4a316fdfb2bb → 5.2.0-canary.4e00299c3e28

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