@tldraw/sync-core 5.2.0-next.79b13319d317 → 5.2.0-next.7b677d60c152

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 (53) hide show
  1. package/README.md +4 -0
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/ClientWebSocketAdapter.js +7 -1
  4. package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
  5. package/dist-cjs/lib/TLSocketRoom.js +4 -1
  6. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  7. package/dist-cjs/lib/TLSyncClient.js +2 -3
  8. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  9. package/dist-cjs/lib/TLSyncRoom.js +27 -3
  10. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  11. package/dist-esm/index.mjs +1 -1
  12. package/dist-esm/lib/ClientWebSocketAdapter.mjs +7 -1
  13. package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
  14. package/dist-esm/lib/TLSocketRoom.mjs +4 -1
  15. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  16. package/dist-esm/lib/TLSyncClient.mjs +2 -4
  17. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  18. package/dist-esm/lib/TLSyncRoom.mjs +27 -3
  19. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  20. package/package.json +9 -6
  21. package/src/lib/ClientWebSocketAdapter.test.ts +502 -505
  22. package/src/lib/ClientWebSocketAdapter.ts +7 -1
  23. package/src/lib/DurableObjectSqliteSyncWrapper.test.ts +102 -0
  24. package/src/lib/InMemorySyncStorage.test.ts +294 -0
  25. package/src/lib/MicrotaskNotifier.test.ts +79 -254
  26. package/src/lib/{NodeSqliteSyncWrapper.test.ts → NodeSqliteWrapper.test.ts} +29 -25
  27. package/src/lib/SQLiteSyncStorage.test.ts +239 -0
  28. package/src/lib/ServerSocketAdapter.test.ts +60 -199
  29. package/src/lib/TLSocketRoom.ts +6 -1
  30. package/src/lib/TLSyncClient.test.ts +1127 -846
  31. package/src/lib/TLSyncClient.ts +4 -4
  32. package/src/lib/TLSyncRoom.ts +34 -4
  33. package/src/lib/TLSyncStorage.test.ts +225 -0
  34. package/src/lib/chunk.test.ts +372 -0
  35. package/src/lib/diff.test.ts +885 -0
  36. package/src/lib/interval.test.ts +43 -0
  37. package/src/lib/protocol.test.ts +43 -0
  38. package/src/lib/recordDiff.test.ts +159 -0
  39. package/src/test/TLSocketRoom.test.ts +1109 -894
  40. package/src/test/TLSyncRoom.test.ts +1754 -870
  41. package/src/test/storageContractSuite.ts +618 -0
  42. package/src/test/upgradeDowngrade.test.ts +140 -40
  43. package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
  44. package/src/lib/RoomSession.test.ts +0 -101
  45. package/src/lib/computeTombstonePruning.test.ts +0 -352
  46. package/src/lib/server-types.test.ts +0 -44
  47. package/src/test/InMemorySyncStorage.test.ts +0 -1780
  48. package/src/test/SQLiteSyncStorage.test.ts +0 -1485
  49. package/src/test/chunk.test.ts +0 -385
  50. package/src/test/customMessages.test.ts +0 -36
  51. package/src/test/diff.test.ts +0 -784
  52. package/src/test/presenceMode.test.ts +0 -149
  53. 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,150 @@ 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
+ })
247
408
 
248
- await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
249
-
250
- // This should throw an error but be caught internally
251
- expect(() => {
252
- adapter._ws!.onmessage?.({ data: 'invalid json' } as MessageEvent)
253
- }).toThrow()
409
+ await waitFor(() => onMessage.mock.calls.length === 1)
410
+ expect(onMessage).toHaveBeenCalledWith({ type: 'message', data: 'hello' })
254
411
  })
255
- })
256
412
 
257
- describe('Status Change Handling', () => {
258
- it('signals status changes', async () => {
259
- const onStatusChange = vi.fn()
260
- adapter.onStatusChange(onStatusChange)
413
+ it('[CW7] drops malformed JSON messages without closing the socket', async () => {
414
+ const warnOnceMock = vi.mocked(warnOnce)
415
+ const onMessage = vi.fn()
416
+ adapter.onReceiveMessage(onMessage)
261
417
 
262
418
  await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
263
- expect(onStatusChange).toHaveBeenCalledWith({ status: 'online' })
264
- connectedServerSocket.terminate()
265
- await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
266
- expect(onStatusChange).toHaveBeenCalledWith({ status: 'offline' })
419
+ warnOnceMock.mockClear()
267
420
 
268
- await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
269
- expect(onStatusChange).toHaveBeenCalledWith({ status: 'online' })
270
- connectedServerSocket.terminate()
271
- await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
272
- expect(onStatusChange).toHaveBeenCalledWith({ status: 'offline' })
421
+ connectedServerSocket.send('{ "type": "message",')
422
+ connectedServerSocket.send('{ "type": "message", "data": "hello" }')
273
423
 
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' })
424
+ await waitFor(() => onMessage.mock.calls.length === 1)
425
+ expect(onMessage).toHaveBeenCalledWith({ type: 'message', data: 'hello' })
426
+ expect(adapter.connectionStatus).toBe('online')
427
+ expect(warnOnceMock).toHaveBeenCalledWith(
428
+ 'Received malformed WebSocket message. Dropping message.'
429
+ )
278
430
  })
279
431
 
280
- it('signals the correct closeCode when a room is not found', async () => {
281
- const onStatusChange = vi.fn()
282
- adapter.onStatusChange(onStatusChange)
283
- await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
284
-
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
- })
432
+ it('[CW7] stops delivering messages after unsubscribe', async () => {
433
+ const onMessage = vi.fn()
434
+ const unsubscribe = adapter.onReceiveMessage(onMessage)
292
435
 
293
- it('signals status changes while restarting', async () => {
294
- const onStatusChange = vi.fn()
295
436
  await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
296
437
 
297
- adapter.onStatusChange(onStatusChange)
298
-
299
- adapter.restart()
438
+ connectedServerSocket.send('{ "type": "test", "data": "first" }')
439
+ await waitFor(() => onMessage.mock.calls.length === 1)
440
+ expect(onMessage).toHaveBeenCalledWith({ type: 'test', data: 'first' })
300
441
 
301
- await waitFor(() => onStatusChange.mock.calls.length === 2)
442
+ unsubscribe()
443
+ onMessage.mockClear()
302
444
 
303
- expect(onStatusChange).toHaveBeenCalledWith({ status: 'offline' })
304
- expect(onStatusChange).toHaveBeenCalledWith({ status: 'online' })
445
+ connectedServerSocket.send('{ "type": "test", "data": "second" }')
446
+ vi.advanceTimersByTime(200)
447
+ expect(onMessage).not.toHaveBeenCalled()
305
448
  })
306
449
 
307
- it('handles different close codes correctly', async () => {
450
+ it('[CW2] stops notifying status listeners after unsubscribe', async () => {
308
451
  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' })
452
+ const unsubscribe = adapter.onStatusChange(onStatusChange)
315
453
 
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)
454
+ await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
455
+ expect(onStatusChange).toHaveBeenCalledWith({ status: 'online' })
320
456
 
321
- // Wait for connection to be online
322
- await waitFor(() => errorTestAdapter._ws?.readyState === WebSocket.OPEN)
323
- expect(errorStatusSpy).toHaveBeenCalledWith({ status: 'online' })
324
- errorStatusSpy.mockClear()
457
+ unsubscribe()
458
+ onStatusChange.mockClear()
325
459
 
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
- })
460
+ connectedServerSocket.terminate()
461
+ await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
335
462
 
336
- errorTestAdapter.close()
463
+ expect(onStatusChange).not.toHaveBeenCalled()
337
464
  })
465
+ })
338
466
 
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
467
+ describe('restart (CW8)', () => {
468
+ it('[CW8] restart closes the current socket and reconnects, notifying offline then online', async () => {
469
+ await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
470
+ const prevWs = adapter._ws
360
471
 
361
- // Set the mock socket and trigger close with 1006 before open
362
- testAdapter._setNewSocket(mockSocket as WebSocket)
472
+ const onStatusChange = vi.fn()
473
+ adapter.onStatusChange(onStatusChange)
363
474
 
364
- // Trigger close with 1006 - this should trigger warning since didOpen=false
365
- mockSocket.onclose?.({ code: 1006, reason: '' })
475
+ adapter.restart()
366
476
 
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
477
+ await waitFor(() => onStatusChange.mock.calls.length === 2)
370
478
 
371
- testAdapter.close()
372
- warnSpy.mockRestore()
479
+ expect(onStatusChange.mock.calls[0][0]).toEqual({ status: 'offline' })
480
+ expect(onStatusChange.mock.calls[1][0]).toEqual({ status: 'online' })
481
+ expect(adapter._ws).not.toBe(prevWs)
373
482
  })
374
483
  })
375
484
 
376
- describe('Lifecycle Management', () => {
377
- it('should call .close on the underlying socket if .close is called before the socket opens', async () => {
485
+ describe('disposal (CW9)', () => {
486
+ it('[CW9] close closes the underlying socket', async () => {
378
487
  await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
379
488
  const closeSpy = vi.spyOn(adapter._ws!, 'close')
380
489
  adapter.close()
381
- // No need to wait - close() is synchronous
382
490
  expect(closeSpy).toHaveBeenCalled()
383
491
  })
384
492
 
385
- it('prevents operations after disposal', () => {
493
+ it('[CW6][CW9] sendMessage, restart, and listener registration throw after close', () => {
386
494
  adapter.close()
387
495
 
388
496
  expect(() => {
@@ -401,56 +509,16 @@ describe(ClientWebSocketAdapter, () => {
401
509
  adapter.restart()
402
510
  }).toThrow('Tried to restart a disposed socket')
403
511
  })
404
- })
405
-
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
412
- 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
512
 
436
- // Wait for initial connection
513
+ it('[CW9] close is idempotent', async () => {
437
514
  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()
515
+ adapter.close()
516
+ expect(() => adapter.close()).not.toThrow()
449
517
  })
450
518
  })
451
519
 
452
- describe('Socket Management', () => {
453
- it('ignores events from orphaned sockets', async () => {
520
+ describe('orphaned sockets (CW10)', () => {
521
+ it('[CW10] ignores events from orphaned sockets', async () => {
454
522
  const onStatusChange = vi.fn()
455
523
  const onMessage = vi.fn()
456
524
  adapter.onStatusChange(onStatusChange)
@@ -459,82 +527,25 @@ describe(ClientWebSocketAdapter, () => {
459
527
  await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
460
528
  const originalSocket = adapter._ws!
461
529
 
462
- // Create a new connection, orphaning the old socket
530
+ // create a new connection, orphaning the old socket
463
531
  adapter._closeSocket()
464
532
  await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
465
533
 
466
- // Clear previous calls
467
534
  onStatusChange.mockClear()
468
535
  onMessage.mockClear()
469
536
 
470
- // Trigger events on the orphaned socket - these should be ignored
537
+ // events on the orphaned socket must not change the adapter's status
471
538
  originalSocket.onclose?.({ code: 1000, reason: 'test' } as CloseEvent)
472
539
  originalSocket.onerror?.({} as Event)
473
- // Don't trigger onmessage on orphaned socket as it will assert - this is expected behavior
540
+ // (onmessage on an orphaned socket asserts by design, so it is not triggered here)
474
541
 
475
- // Should not receive any notifications from orphaned socket
476
542
  expect(onStatusChange).not.toHaveBeenCalled()
477
543
  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()
544
+ expect(adapter.connectionStatus).toBe('online')
533
545
  })
534
546
  })
535
547
  })
536
548
 
537
- // ReconnectManager tests
538
549
  describe('ReconnectManager', () => {
539
550
  let adapter: ClientWebSocketAdapter
540
551
  let wsServer: WebSocketServer
@@ -543,245 +554,231 @@ describe('ReconnectManager', () => {
543
554
  connectedServerSocket = socket
544
555
  })
545
556
 
557
+ let consoleWarnSpy: ReturnType<typeof vi.spyOn>
546
558
  beforeEach(() => {
559
+ consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
547
560
  adapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
548
561
  wsServer = new WebSocketServer({ port: 2234 })
549
562
  wsServer.on('connection', connectMock as any)
550
563
  })
551
564
 
552
565
  afterEach(() => {
566
+ consoleWarnSpy.mockRestore()
553
567
  adapter.close()
554
568
  wsServer.close()
555
569
  connectMock.mockClear()
556
570
  })
557
571
 
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
- })
572
+ it('[RM1] exposes the documented delay constants', () => {
573
+ expect(ACTIVE_MIN_DELAY).toBe(500)
574
+ expect(ACTIVE_MAX_DELAY).toBe(2000)
575
+ expect(INACTIVE_MIN_DELAY).toBe(1000)
576
+ expect(INACTIVE_MAX_DELAY).toBe(1000 * 60 * 5)
577
+ expect(DELAY_EXPONENT).toBe(1.5)
578
+ expect(ATTEMPT_TIMEOUT).toBe(1000)
567
579
  })
568
580
 
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
581
+ it('[RM1] reconnection delays back off exponentially, bounded by the active delays', () => {
582
+ const testAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
583
+ try {
584
+ const manager = testAdapter._reconnectManager
585
+ // make every disconnected() call take the "attempt now" branch
586
+ ;(manager as any).lastAttemptStart = Date.now() - 1_000_000
587
+ manager.intendedDelay = ACTIVE_MIN_DELAY
589
588
 
590
- // Set delay to very high value
591
- manager.intendedDelay = 999999999
592
589
  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
590
+ expect(manager.intendedDelay).toBe(ACTIVE_MIN_DELAY * DELAY_EXPONENT)
600
591
  manager.disconnected()
601
- expect(manager.intendedDelay).toBeLessThanOrEqual(INACTIVE_MAX_DELAY)
602
-
603
- hiddenMock.mockRestore()
604
- })
592
+ expect(manager.intendedDelay).toBe(ACTIVE_MIN_DELAY * DELAY_EXPONENT ** 2)
593
+ manager.disconnected()
594
+ expect(manager.intendedDelay).toBe(ACTIVE_MIN_DELAY * DELAY_EXPONENT ** 3)
595
+ // capped at the active maximum
596
+ manager.disconnected()
597
+ expect(manager.intendedDelay).toBe(ACTIVE_MAX_DELAY)
598
+ manager.disconnected()
599
+ expect(manager.intendedDelay).toBe(ACTIVE_MAX_DELAY)
600
+ } finally {
601
+ testAdapter.close()
602
+ }
605
603
  })
606
604
 
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)
605
+ it('[RM1] uses the inactive delay bounds when the tab is hidden', () => {
606
+ const hiddenMock = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true)
607
+ const testAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
608
+ try {
609
+ const manager = testAdapter._reconnectManager
610
+ ;(manager as any).lastAttemptStart = Date.now() - 1_000_000
615
611
 
616
- // Test inactive tab delays
617
- hiddenMock.mockReturnValue(true)
618
- adapter._reconnectManager.disconnected()
619
- expect(adapter._reconnectManager.intendedDelay).toBeGreaterThanOrEqual(INACTIVE_MIN_DELAY)
612
+ // the delay is clamped up to the inactive minimum before applying the exponent
613
+ manager.intendedDelay = 0
614
+ manager.disconnected()
615
+ expect(manager.intendedDelay).toBe(INACTIVE_MIN_DELAY * DELAY_EXPONENT)
620
616
 
617
+ // and capped at the inactive maximum
618
+ manager.intendedDelay = INACTIVE_MAX_DELAY
619
+ manager.disconnected()
620
+ expect(manager.intendedDelay).toBe(INACTIVE_MAX_DELAY)
621
+ } finally {
622
+ testAdapter.close()
621
623
  hiddenMock.mockRestore()
622
- })
624
+ }
623
625
  })
624
626
 
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'))
627
+ it('[RM1][RM2] reconnects with a new socket after the server drops the connection, repeatedly', async () => {
628
+ await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
629
+ const prevClientSocket = adapter._ws
630
+ const prevServerSocket = connectedServerSocket
631
+
632
+ prevServerSocket.terminate()
633
+ await waitFor(() => connectedServerSocket !== prevServerSocket)
634
+ // there is a race here, the server could've opened a new socket already, but it hasn't
635
+ // transitioned to OPEN yet, thus the second waitFor
636
+ await waitFor(() => connectedServerSocket.readyState === WebSocket.OPEN)
637
+ expect(adapter._ws).not.toBe(prevClientSocket)
638
+ await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
639
+ expect(adapter.connectionStatus).toBe('online')
640
+
641
+ // and again
642
+ connectedServerSocket.terminate()
643
+ await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
644
+ expect(adapter.connectionStatus).toBe('offline')
645
+ await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
646
+ expect(adapter.connectionStatus).toBe('online')
647
+ })
639
648
 
640
- // Should reset delay for immediate reconnection attempt
641
- expect(adapter._reconnectManager.intendedDelay).toBe(ACTIVE_MIN_DELAY)
642
- })
649
+ it('[RM2] a successful connection resets the intended delay to the active minimum', async () => {
650
+ await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
651
+ const manager = adapter._reconnectManager
652
+
653
+ // directly: connected() resets a backed-off delay
654
+ manager.intendedDelay = ACTIVE_MAX_DELAY
655
+ manager.connected()
656
+ expect(manager.intendedDelay).toBe(ACTIVE_MIN_DELAY)
657
+
658
+ // and through a real reconnect cycle
659
+ manager.intendedDelay = ACTIVE_MAX_DELAY
660
+ connectedServerSocket.terminate()
661
+ await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
662
+ await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
663
+ expect(manager.intendedDelay).toBe(ACTIVE_MIN_DELAY)
664
+ })
643
665
 
644
- it('responds to window offline events', async () => {
645
- await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
666
+ it('[RM3] the window offline event closes the active socket', async () => {
667
+ await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
646
668
 
647
- // Simulate going offline
648
- window.dispatchEvent(new Event('offline'))
669
+ window.dispatchEvent(new Event('offline'))
649
670
 
650
- // Should close the socket
651
- await waitFor(() => adapter._ws?.readyState !== WebSocket.OPEN)
652
- })
671
+ expect(adapter._ws).toBeNull()
672
+ expect(adapter.connectionStatus).toBe('offline')
673
+ })
653
674
 
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
- })
675
+ it('[RM4] the window online event resets the backoff for an immediate reconnect attempt', async () => {
676
+ await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
661
677
 
662
- await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
678
+ connectedServerSocket.close()
679
+ await waitFor(() => adapter._ws?.readyState !== WebSocket.OPEN)
663
680
 
664
- // Disconnect and close server
665
- connectedServerSocket.close()
666
- wsServer.close()
667
- await waitFor(() => adapter._ws?.readyState !== WebSocket.OPEN)
681
+ // close server to prevent an automatic reconnection winning the race
682
+ wsServer.close()
668
683
 
669
- // Simulate connection change
670
- const _originalDelay = adapter._reconnectManager.intendedDelay
671
- mockConnection.dispatchEvent(new Event('change'))
684
+ const manager = adapter._reconnectManager
685
+ manager.intendedDelay = ACTIVE_MAX_DELAY
686
+ // a recent attempt keeps maybeReconnected in the "honour the min delay" branch
687
+ ;(manager as any).lastAttemptStart = Date.now()
672
688
 
673
- // Should attempt reconnection
674
- expect(adapter._reconnectManager.intendedDelay).toBe(ACTIVE_MIN_DELAY)
689
+ window.dispatchEvent(new Event('online'))
675
690
 
676
- // Cleanup
677
- delete (navigator as any).connection
678
- })
691
+ expect(manager.intendedDelay).toBe(ACTIVE_MIN_DELAY)
679
692
  })
680
693
 
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()
694
+ it('[RM1][RM4] the document becoming visible triggers an early reconnect attempt', async () => {
695
+ await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
696
+ const hiddenMock = vi.spyOn(document, 'hidden', 'get')
697
+ hiddenMock.mockReturnValue(true)
698
+ // it's necessary to close the socket, as otherwise the websocket might stay half-open
699
+ connectedServerSocket.close()
700
+ wsServer.close()
701
+ await waitFor(() => adapter._ws?.readyState !== WebSocket.OPEN)
702
+ expect(adapter._reconnectManager.intendedDelay).toBeGreaterThanOrEqual(INACTIVE_MIN_DELAY)
699
703
 
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
704
+ hiddenMock.mockReturnValue(false)
705
+ document.dispatchEvent(new Event('visibilitychange'))
703
706
 
704
- timeoutAdapter.close()
705
- Date.now = originalDateNow
706
- })
707
+ expect(adapter._reconnectManager.intendedDelay).toBeLessThan(INACTIVE_MIN_DELAY)
708
+ hiddenMock.mockRestore()
707
709
  })
708
710
 
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)
713
-
714
- // Should be in connected state
715
- adapter._reconnectManager.connected()
711
+ it('[RM4] a socket that is already OPEN is left alone', async () => {
712
+ await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
713
+ const ws = adapter._ws
714
+ const onStatusChange = vi.fn()
715
+ adapter.onStatusChange(onStatusChange)
716
716
 
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()
717
+ adapter._reconnectManager.maybeReconnected()
723
718
 
724
- // Should reconnect
725
- await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
726
- })
719
+ expect(adapter._ws).toBe(ws)
720
+ expect(adapter._ws!.readyState).toBe(WebSocket.OPEN)
721
+ expect(onStatusChange).not.toHaveBeenCalled()
727
722
  })
728
723
 
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()
724
+ it('[RM4] a socket CONNECTING for less than ATTEMPT_TIMEOUT is left alone and rechecked later', async () => {
725
+ await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
726
+ adapter._closeSocket()
735
727
 
736
- // Close should not throw
737
- expect(() => manager.close()).not.toThrow()
728
+ let now = Date.now()
729
+ const dateSpy = vi.spyOn(Date, 'now').mockImplementation(() => now)
730
+ try {
731
+ const fake = mockSocket()
732
+ adapter._setNewSocket(fake)
733
+ ;(adapter._reconnectManager as any).lastAttemptStart = now
738
734
 
739
- // Further operations should be safe
740
- manager.close()
741
- })
742
- })
743
- })
735
+ adapter._reconnectManager.maybeReconnected()
744
736
 
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')
737
+ // young attempt: the socket is left alone
738
+ expect(fake.close).not.toHaveBeenCalled()
739
+ expect(adapter._ws).toBe(fake)
752
740
 
753
- // The conversion should happen internally
754
- // We can verify this works by checking the WebSocket connection attempts
741
+ // when the recheck fires after ATTEMPT_TIMEOUT, the still-CONNECTING socket
742
+ // is closed and a new attempt is made
743
+ now += ATTEMPT_TIMEOUT + 100
744
+ vi.advanceTimersByTime(ATTEMPT_TIMEOUT + 100)
755
745
 
756
- httpAdapter.close()
757
- httpsAdapter.close()
758
- })
746
+ expect(fake.close).toHaveBeenCalled()
747
+ expect(adapter._ws).toBeNull()
748
+ } finally {
749
+ dateSpy.mockRestore()
750
+ }
759
751
  })
760
752
 
761
- describe('Debug logging', () => {
762
- it('handles debug logging correctly', () => {
763
- const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
753
+ it('[RM4] a socket stuck in CONNECTING for longer than ATTEMPT_TIMEOUT is closed and retried', async () => {
754
+ await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
755
+ adapter._closeSocket()
764
756
 
765
- // Debug should not log by default
766
- // (debug function is internal and depends on window.__tldraw_socket_debug)
757
+ const fake = mockSocket()
758
+ adapter._setNewSocket(fake)
759
+ ;(adapter._reconnectManager as any).lastAttemptStart = Date.now() - ATTEMPT_TIMEOUT - 100
767
760
 
768
- consoleSpy.mockRestore()
769
- })
761
+ adapter._reconnectManager.maybeReconnected()
762
+
763
+ expect(fake.close).toHaveBeenCalled()
764
+ expect(adapter._ws).toBeNull()
770
765
  })
771
766
 
772
- describe('listenTo helper function', () => {
773
- it('should add and remove event listeners correctly', () => {
774
- const target = new EventTarget()
775
- const handler = vi.fn()
767
+ it('[RM5] close cancels timers and removes the reconnect event listeners', () => {
768
+ const testAdapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
769
+ const manager = testAdapter._reconnectManager
776
770
 
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)
771
+ testAdapter.close()
781
772
 
782
- target.removeEventListener('test', handler)
783
- target.dispatchEvent(new Event('test'))
784
- expect(handler).toHaveBeenCalledTimes(1) // Should not be called again
785
- })
773
+ // with the listeners removed, reconnect hints no longer touch the manager
774
+ manager.intendedDelay = 12345
775
+ window.dispatchEvent(new Event('online'))
776
+ document.dispatchEvent(new Event('visibilitychange'))
777
+ window.dispatchEvent(new Event('offline'))
778
+ expect(manager.intendedDelay).toBe(12345)
779
+ expect(testAdapter._ws).toBeNull()
780
+
781
+ // closing again is safe
782
+ expect(() => manager.close()).not.toThrow()
786
783
  })
787
784
  })