@tldraw/sync-core 5.2.0-next.ee0fa4d6244f → 5.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DOCS.md +662 -0
- package/README.md +9 -1
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/ClientWebSocketAdapter.js +7 -1
- package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
- 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 +27 -3
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/ClientWebSocketAdapter.mjs +7 -1
- package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
- 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 +27 -3
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/package.json +12 -8
- package/src/lib/ClientWebSocketAdapter.test.ts +502 -505
- package/src/lib/ClientWebSocketAdapter.ts +7 -1
- 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 +34 -4
- 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 +1754 -870
- package/src/test/storageContractSuite.ts +618 -0
- package/src/test/upgradeDowngrade.test.ts +140 -40
- 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,150 @@ 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
|
+
})
|
|
247
408
|
|
|
248
|
-
await waitFor(() =>
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
adapter.
|
|
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
|
-
|
|
264
|
-
connectedServerSocket.terminate()
|
|
265
|
-
await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED)
|
|
266
|
-
expect(onStatusChange).toHaveBeenCalledWith({ status: 'offline' })
|
|
419
|
+
warnOnceMock.mockClear()
|
|
267
420
|
|
|
268
|
-
|
|
269
|
-
|
|
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(() =>
|
|
275
|
-
expect(
|
|
276
|
-
adapter.
|
|
277
|
-
expect(
|
|
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('
|
|
281
|
-
const
|
|
282
|
-
adapter.
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
442
|
+
unsubscribe()
|
|
443
|
+
onMessage.mockClear()
|
|
302
444
|
|
|
303
|
-
|
|
304
|
-
|
|
445
|
+
connectedServerSocket.send('{ "type": "test", "data": "second" }')
|
|
446
|
+
vi.advanceTimersByTime(200)
|
|
447
|
+
expect(onMessage).not.toHaveBeenCalled()
|
|
305
448
|
})
|
|
306
449
|
|
|
307
|
-
it('
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
expect(errorStatusSpy).toHaveBeenCalledWith({ status: 'online' })
|
|
324
|
-
errorStatusSpy.mockClear()
|
|
457
|
+
unsubscribe()
|
|
458
|
+
onStatusChange.mockClear()
|
|
325
459
|
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
463
|
+
expect(onStatusChange).not.toHaveBeenCalled()
|
|
337
464
|
})
|
|
465
|
+
})
|
|
338
466
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
362
|
-
|
|
472
|
+
const onStatusChange = vi.fn()
|
|
473
|
+
adapter.onStatusChange(onStatusChange)
|
|
363
474
|
|
|
364
|
-
|
|
365
|
-
mockSocket.onclose?.({ code: 1006, reason: '' })
|
|
475
|
+
adapter.restart()
|
|
366
476
|
|
|
367
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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('
|
|
377
|
-
it('
|
|
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('
|
|
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
|
-
|
|
513
|
+
it('[CW9] close is idempotent', async () => {
|
|
437
514
|
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
438
|
-
|
|
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('
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
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
|
|
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).
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
//
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
expect(
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
641
|
-
|
|
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
|
-
|
|
645
|
-
|
|
666
|
+
it('[RM3] the window offline event closes the active socket', async () => {
|
|
667
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
646
668
|
|
|
647
|
-
|
|
648
|
-
window.dispatchEvent(new Event('offline'))
|
|
669
|
+
window.dispatchEvent(new Event('offline'))
|
|
649
670
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
671
|
+
expect(adapter._ws).toBeNull()
|
|
672
|
+
expect(adapter.connectionStatus).toBe('offline')
|
|
673
|
+
})
|
|
653
674
|
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
678
|
+
connectedServerSocket.close()
|
|
679
|
+
await waitFor(() => adapter._ws?.readyState !== WebSocket.OPEN)
|
|
663
680
|
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
674
|
-
expect(adapter._reconnectManager.intendedDelay).toBe(ACTIVE_MIN_DELAY)
|
|
689
|
+
window.dispatchEvent(new Event('online'))
|
|
675
690
|
|
|
676
|
-
|
|
677
|
-
delete (navigator as any).connection
|
|
678
|
-
})
|
|
691
|
+
expect(manager.intendedDelay).toBe(ACTIVE_MIN_DELAY)
|
|
679
692
|
})
|
|
680
693
|
|
|
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()
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
// but we can verify it doesn't crash
|
|
704
|
+
hiddenMock.mockReturnValue(false)
|
|
705
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
703
706
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
})
|
|
707
|
+
expect(adapter._reconnectManager.intendedDelay).toBeLessThan(INACTIVE_MIN_DELAY)
|
|
708
|
+
hiddenMock.mockRestore()
|
|
707
709
|
})
|
|
708
710
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
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
|
-
|
|
725
|
-
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
737
|
-
|
|
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
|
-
|
|
740
|
-
manager.close()
|
|
741
|
-
})
|
|
742
|
-
})
|
|
743
|
-
})
|
|
735
|
+
adapter._reconnectManager.maybeReconnected()
|
|
744
736
|
|
|
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')
|
|
737
|
+
// young attempt: the socket is left alone
|
|
738
|
+
expect(fake.close).not.toHaveBeenCalled()
|
|
739
|
+
expect(adapter._ws).toBe(fake)
|
|
752
740
|
|
|
753
|
-
//
|
|
754
|
-
//
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
}
|
|
746
|
+
expect(fake.close).toHaveBeenCalled()
|
|
747
|
+
expect(adapter._ws).toBeNull()
|
|
748
|
+
} finally {
|
|
749
|
+
dateSpy.mockRestore()
|
|
750
|
+
}
|
|
759
751
|
})
|
|
760
752
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
766
|
-
|
|
757
|
+
const fake = mockSocket()
|
|
758
|
+
adapter._setNewSocket(fake)
|
|
759
|
+
;(adapter._reconnectManager as any).lastAttemptStart = Date.now() - ATTEMPT_TIMEOUT - 100
|
|
767
760
|
|
|
768
|
-
|
|
769
|
-
|
|
761
|
+
adapter._reconnectManager.maybeReconnected()
|
|
762
|
+
|
|
763
|
+
expect(fake.close).toHaveBeenCalled()
|
|
764
|
+
expect(adapter._ws).toBeNull()
|
|
770
765
|
})
|
|
771
766
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
778
|
-
target.addEventListener('test', handler)
|
|
779
|
-
target.dispatchEvent(new Event('test'))
|
|
780
|
-
expect(handler).toHaveBeenCalledTimes(1)
|
|
771
|
+
testAdapter.close()
|
|
781
772
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
})
|