@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.
- package/DOCS.md +662 -0
- package/README.md +9 -1
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/TLSocketRoom.js +4 -1
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +2 -3
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +3 -2
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/TLSocketRoom.mjs +4 -1
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +2 -4
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +3 -2
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/package.json +9 -8
- package/src/lib/ClientWebSocketAdapter.test.ts +486 -508
- package/src/lib/DurableObjectSqliteSyncWrapper.test.ts +102 -0
- package/src/lib/InMemorySyncStorage.test.ts +294 -0
- package/src/lib/MicrotaskNotifier.test.ts +79 -254
- package/src/lib/{NodeSqliteSyncWrapper.test.ts → NodeSqliteWrapper.test.ts} +29 -25
- package/src/lib/SQLiteSyncStorage.test.ts +239 -0
- package/src/lib/ServerSocketAdapter.test.ts +60 -199
- package/src/lib/TLSocketRoom.ts +6 -1
- package/src/lib/TLSyncClient.test.ts +1127 -846
- package/src/lib/TLSyncClient.ts +4 -4
- package/src/lib/TLSyncRoom.ts +5 -2
- package/src/lib/TLSyncStorage.test.ts +225 -0
- package/src/lib/chunk.test.ts +372 -0
- package/src/lib/diff.test.ts +885 -0
- package/src/lib/interval.test.ts +43 -0
- package/src/lib/protocol.test.ts +43 -0
- package/src/lib/recordDiff.test.ts +159 -0
- package/src/test/TLSocketRoom.test.ts +1109 -894
- package/src/test/TLSyncRoom.test.ts +1735 -893
- package/src/test/storageContractSuite.ts +618 -0
- package/src/test/upgradeDowngrade.test.ts +137 -37
- package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
- package/src/lib/RoomSession.test.ts +0 -101
- package/src/lib/computeTombstonePruning.test.ts +0 -352
- package/src/lib/server-types.test.ts +0 -44
- package/src/test/InMemorySyncStorage.test.ts +0 -1780
- package/src/test/SQLiteSyncStorage.test.ts +0 -1485
- package/src/test/chunk.test.ts +0 -385
- package/src/test/customMessages.test.ts +0 -36
- package/src/test/diff.test.ts +0 -784
- package/src/test/presenceMode.test.ts +0 -149
- 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
|
-
|
|
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('
|
|
60
|
-
it('
|
|
61
|
-
|
|
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('
|
|
65
|
-
|
|
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('
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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('
|
|
77
|
-
|
|
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('
|
|
82
|
-
it('
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
90
|
-
|
|
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('
|
|
94
|
-
|
|
95
|
-
|
|
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('
|
|
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
|
-
|
|
110
|
-
expect(adapter.connectionStatus).toBe('online')
|
|
207
|
+
expect(adapter.connectionStatus).toBe('offline')
|
|
111
208
|
})
|
|
112
209
|
|
|
113
|
-
it('
|
|
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('
|
|
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
|
-
|
|
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('
|
|
136
|
-
it('
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
179
|
-
const
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
215
|
-
testAdapter.sendMessage(message)
|
|
382
|
+
adapter.sendMessage(connectMessage())
|
|
216
383
|
|
|
217
|
-
|
|
218
|
-
|
|
384
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
385
|
+
expect.stringContaining('Tried to send message while')
|
|
386
|
+
)
|
|
219
387
|
})
|
|
220
388
|
|
|
221
|
-
it('
|
|
222
|
-
|
|
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.
|
|
237
|
-
expect(
|
|
238
|
-
|
|
239
|
-
)
|
|
392
|
+
adapter._closeSocket()
|
|
393
|
+
expect(adapter._ws).toBeNull()
|
|
394
|
+
consoleWarnSpy.mockClear()
|
|
240
395
|
|
|
241
|
-
|
|
396
|
+
expect(() => adapter.sendMessage(connectMessage())).not.toThrow()
|
|
397
|
+
expect(consoleWarnSpy).not.toHaveBeenCalled()
|
|
242
398
|
})
|
|
399
|
+
})
|
|
243
400
|
|
|
244
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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).
|
|
304
|
-
expect(onStatusChange).
|
|
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('
|
|
377
|
-
it('
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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).
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
//
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
expect(
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
641
|
-
|
|
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
|
-
|
|
645
|
-
|
|
647
|
+
it('[RM3] the window offline event closes the active socket', async () => {
|
|
648
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
646
649
|
|
|
647
|
-
|
|
648
|
-
window.dispatchEvent(new Event('offline'))
|
|
650
|
+
window.dispatchEvent(new Event('offline'))
|
|
649
651
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
652
|
+
expect(adapter._ws).toBeNull()
|
|
653
|
+
expect(adapter.connectionStatus).toBe('offline')
|
|
654
|
+
})
|
|
653
655
|
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
659
|
+
connectedServerSocket.close()
|
|
660
|
+
await waitFor(() => adapter._ws?.readyState !== WebSocket.OPEN)
|
|
663
661
|
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
674
|
-
expect(adapter._reconnectManager.intendedDelay).toBe(ACTIVE_MIN_DELAY)
|
|
670
|
+
window.dispatchEvent(new Event('online'))
|
|
675
671
|
|
|
676
|
-
|
|
677
|
-
delete (navigator as any).connection
|
|
678
|
-
})
|
|
672
|
+
expect(manager.intendedDelay).toBe(ACTIVE_MIN_DELAY)
|
|
679
673
|
})
|
|
680
674
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
// but we can verify it doesn't crash
|
|
685
|
+
hiddenMock.mockReturnValue(false)
|
|
686
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
703
687
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
})
|
|
688
|
+
expect(adapter._reconnectManager.intendedDelay).toBeLessThan(INACTIVE_MIN_DELAY)
|
|
689
|
+
hiddenMock.mockRestore()
|
|
707
690
|
})
|
|
708
691
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
715
|
-
adapter._reconnectManager.connected()
|
|
698
|
+
adapter._reconnectManager.maybeReconnected()
|
|
716
699
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
737
|
-
|
|
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
|
-
|
|
740
|
-
manager.close()
|
|
741
|
-
})
|
|
742
|
-
})
|
|
743
|
-
})
|
|
716
|
+
adapter._reconnectManager.maybeReconnected()
|
|
744
717
|
|
|
745
|
-
//
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
//
|
|
754
|
-
//
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
}
|
|
727
|
+
expect(fake.close).toHaveBeenCalled()
|
|
728
|
+
expect(adapter._ws).toBeNull()
|
|
729
|
+
} finally {
|
|
730
|
+
dateSpy.mockRestore()
|
|
731
|
+
}
|
|
759
732
|
})
|
|
760
733
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
766
|
-
|
|
738
|
+
const fake = mockSocket()
|
|
739
|
+
adapter._setNewSocket(fake)
|
|
740
|
+
;(adapter._reconnectManager as any).lastAttemptStart = Date.now() - ATTEMPT_TIMEOUT - 100
|
|
767
741
|
|
|
768
|
-
|
|
769
|
-
|
|
742
|
+
adapter._reconnectManager.maybeReconnected()
|
|
743
|
+
|
|
744
|
+
expect(fake.close).toHaveBeenCalled()
|
|
745
|
+
expect(adapter._ws).toBeNull()
|
|
770
746
|
})
|
|
771
747
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
778
|
-
target.addEventListener('test', handler)
|
|
779
|
-
target.dispatchEvent(new Event('test'))
|
|
780
|
-
expect(handler).toHaveBeenCalledTimes(1)
|
|
752
|
+
testAdapter.close()
|
|
781
753
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
})
|