@tldraw/sync-core 4.1.0-next.b6dfe9bccde9 → 4.1.0-next.b9999db71010
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/index.d.ts +605 -75
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/ClientWebSocketAdapter.js +144 -0
- package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
- package/dist-cjs/lib/RoomSession.js +3 -0
- package/dist-cjs/lib/RoomSession.js.map +2 -2
- package/dist-cjs/lib/ServerSocketAdapter.js +23 -0
- package/dist-cjs/lib/ServerSocketAdapter.js.map +2 -2
- package/dist-cjs/lib/TLRemoteSyncError.js +8 -0
- package/dist-cjs/lib/TLRemoteSyncError.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +280 -56
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +45 -2
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +161 -16
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/chunk.js +30 -0
- package/dist-cjs/lib/chunk.js.map +2 -2
- package/dist-cjs/lib/diff.js.map +2 -2
- package/dist-cjs/lib/findMin.js.map +2 -2
- package/dist-cjs/lib/interval.js.map +2 -2
- package/dist-cjs/lib/protocol.js.map +2 -2
- package/dist-cjs/lib/server-types.js.map +1 -1
- package/dist-esm/index.d.mts +605 -75
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/ClientWebSocketAdapter.mjs +144 -0
- package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
- package/dist-esm/lib/RoomSession.mjs +3 -0
- package/dist-esm/lib/RoomSession.mjs.map +2 -2
- package/dist-esm/lib/ServerSocketAdapter.mjs +23 -0
- package/dist-esm/lib/ServerSocketAdapter.mjs.map +2 -2
- package/dist-esm/lib/TLRemoteSyncError.mjs +8 -0
- package/dist-esm/lib/TLRemoteSyncError.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +280 -56
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +45 -2
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +161 -16
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/chunk.mjs +30 -0
- package/dist-esm/lib/chunk.mjs.map +2 -2
- package/dist-esm/lib/diff.mjs.map +2 -2
- package/dist-esm/lib/findMin.mjs.map +2 -2
- package/dist-esm/lib/interval.mjs.map +2 -2
- package/dist-esm/lib/protocol.mjs.map +2 -2
- package/package.json +6 -6
- package/src/lib/ClientWebSocketAdapter.test.ts +712 -129
- package/src/lib/ClientWebSocketAdapter.ts +240 -9
- package/src/lib/RoomSession.test.ts +97 -0
- package/src/lib/RoomSession.ts +105 -3
- package/src/lib/ServerSocketAdapter.test.ts +228 -0
- package/src/lib/ServerSocketAdapter.ts +124 -5
- package/src/lib/TLRemoteSyncError.ts +50 -1
- package/src/lib/TLSocketRoom.ts +377 -60
- package/src/lib/TLSyncClient.test.ts +828 -0
- package/src/lib/TLSyncClient.ts +251 -26
- package/src/lib/TLSyncRoom.ts +284 -24
- package/src/lib/chunk.ts +72 -1
- package/src/lib/diff.ts +128 -14
- package/src/lib/findMin.ts +6 -0
- package/src/lib/interval.ts +40 -0
- package/src/lib/protocol.ts +185 -7
- package/src/lib/server-types.test.ts +44 -0
- package/src/lib/server-types.ts +45 -1
- package/src/test/TLSocketRoom.test.ts +438 -29
- package/src/test/chunk.test.ts +200 -3
- package/src/test/diff.test.ts +396 -1
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
import { Atom, atom } from '@tldraw/state'
|
|
2
|
+
import { Store } from '@tldraw/store'
|
|
3
|
+
import {
|
|
4
|
+
DocumentRecordType,
|
|
5
|
+
PageRecordType,
|
|
6
|
+
TLDOCUMENT_ID,
|
|
7
|
+
TLRecord,
|
|
8
|
+
createTLSchema,
|
|
9
|
+
} from '@tldraw/tlschema'
|
|
10
|
+
/// <reference types="vitest" />
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
12
|
+
import { RecordOpType } from './diff'
|
|
13
|
+
import {
|
|
14
|
+
TLPushRequest,
|
|
15
|
+
TLSocketClientSentEvent,
|
|
16
|
+
TLSocketServerSentDataEvent,
|
|
17
|
+
TLSocketServerSentEvent,
|
|
18
|
+
getTlsyncProtocolVersion,
|
|
19
|
+
} from './protocol'
|
|
20
|
+
import {
|
|
21
|
+
TLPersistentClientSocket,
|
|
22
|
+
TLPresenceMode,
|
|
23
|
+
TLSyncClient,
|
|
24
|
+
TlSocketStatusChangeEvent,
|
|
25
|
+
} from './TLSyncClient'
|
|
26
|
+
|
|
27
|
+
// Mock store and schema setup
|
|
28
|
+
const schema = createTLSchema()
|
|
29
|
+
const protocolVersion = getTlsyncProtocolVersion()
|
|
30
|
+
type TestRecord = TLRecord
|
|
31
|
+
|
|
32
|
+
// Mock socket implementation for testing
|
|
33
|
+
class MockSocket implements TLPersistentClientSocket<TestRecord> {
|
|
34
|
+
connectionStatus: 'online' | 'offline' | 'error' = 'offline'
|
|
35
|
+
private messageListeners: Array<(msg: TLSocketServerSentEvent<TestRecord>) => void> = []
|
|
36
|
+
private statusListeners: Array<(event: TlSocketStatusChangeEvent) => void> = []
|
|
37
|
+
private sentMessages: TLSocketClientSentEvent<TestRecord>[] = []
|
|
38
|
+
|
|
39
|
+
sendMessage(msg: TLSocketClientSentEvent<TestRecord>): void {
|
|
40
|
+
if (this.connectionStatus !== 'online') {
|
|
41
|
+
throw new Error('Cannot send message when not online')
|
|
42
|
+
}
|
|
43
|
+
this.sentMessages.push(msg)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
onReceiveMessage(callback: (val: TLSocketServerSentEvent<TestRecord>) => void) {
|
|
47
|
+
this.messageListeners.push(callback)
|
|
48
|
+
return () => {
|
|
49
|
+
const index = this.messageListeners.indexOf(callback)
|
|
50
|
+
if (index >= 0) this.messageListeners.splice(index, 1)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
onStatusChange(callback: (params: TlSocketStatusChangeEvent) => void) {
|
|
55
|
+
this.statusListeners.push(callback)
|
|
56
|
+
return () => {
|
|
57
|
+
const index = this.statusListeners.indexOf(callback)
|
|
58
|
+
if (index >= 0) this.statusListeners.splice(index, 1)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
restart(): void {
|
|
63
|
+
this.connectionStatus = 'offline'
|
|
64
|
+
this._notifyStatus({ status: 'offline' })
|
|
65
|
+
// Simulate reconnection
|
|
66
|
+
setTimeout(() => {
|
|
67
|
+
this.connectionStatus = 'online'
|
|
68
|
+
this._notifyStatus({ status: 'online' })
|
|
69
|
+
}, 0)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Test helpers
|
|
73
|
+
mockServerMessage(message: TLSocketServerSentEvent<TestRecord>) {
|
|
74
|
+
this.messageListeners.forEach((listener) => listener(message))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
mockConnectionStatus(status: 'online' | 'offline' | 'error', reason?: string) {
|
|
78
|
+
this.connectionStatus = status
|
|
79
|
+
if (status === 'error') {
|
|
80
|
+
this._notifyStatus({ status: 'error', reason: reason || 'Unknown error' })
|
|
81
|
+
} else {
|
|
82
|
+
this._notifyStatus({ status: status as 'online' | 'offline' })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getSentMessages() {
|
|
87
|
+
return [...this.sentMessages]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getLastSentMessage() {
|
|
91
|
+
return this.sentMessages[this.sentMessages.length - 1]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
clearSentMessages() {
|
|
95
|
+
this.sentMessages = []
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private _notifyStatus(event: TlSocketStatusChangeEvent) {
|
|
99
|
+
this.statusListeners.forEach((listener) => listener(event))
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe('TLSyncClient', () => {
|
|
104
|
+
let store: Store<TestRecord, any>
|
|
105
|
+
let socket: MockSocket
|
|
106
|
+
let presence: Atom<TestRecord | null>
|
|
107
|
+
let presenceMode: Atom<TLPresenceMode>
|
|
108
|
+
let onLoad: (client: TLSyncClient<TestRecord, Store<TestRecord, any>>) => void
|
|
109
|
+
let onSyncError: (reason: string) => void
|
|
110
|
+
let onCustomMessageReceived: (data: any) => void
|
|
111
|
+
let onAfterConnect: (
|
|
112
|
+
client: TLSyncClient<TestRecord, Store<TestRecord, any>>,
|
|
113
|
+
details: { isReadonly: boolean }
|
|
114
|
+
) => void
|
|
115
|
+
let client: TLSyncClient<TestRecord, Store<TestRecord, any>>
|
|
116
|
+
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
vi.useFakeTimers()
|
|
119
|
+
|
|
120
|
+
// Create fresh store for each test
|
|
121
|
+
store = new Store<TestRecord, any>({
|
|
122
|
+
schema,
|
|
123
|
+
props: {
|
|
124
|
+
defaultName: 'test',
|
|
125
|
+
assets: {
|
|
126
|
+
upload: async () => ({ src: 'mock://test' }),
|
|
127
|
+
resolve: (asset: any) => asset.src || 'mock://resolved',
|
|
128
|
+
remove: async () => {},
|
|
129
|
+
},
|
|
130
|
+
onMount: () => {},
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Add basic document record
|
|
135
|
+
store.put([
|
|
136
|
+
DocumentRecordType.create({
|
|
137
|
+
id: TLDOCUMENT_ID,
|
|
138
|
+
gridSize: 10,
|
|
139
|
+
}),
|
|
140
|
+
])
|
|
141
|
+
|
|
142
|
+
socket = new MockSocket() as MockSocket & TLPersistentClientSocket<TestRecord>
|
|
143
|
+
presence = atom<TestRecord | null>('presence', null)
|
|
144
|
+
presenceMode = atom<TLPresenceMode>('presenceMode', 'full')
|
|
145
|
+
|
|
146
|
+
onLoad = vi.fn()
|
|
147
|
+
onSyncError = vi.fn()
|
|
148
|
+
onCustomMessageReceived = vi.fn()
|
|
149
|
+
onAfterConnect = vi.fn()
|
|
150
|
+
|
|
151
|
+
// Start socket as online by default
|
|
152
|
+
socket.connectionStatus = 'online'
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
afterEach(() => {
|
|
156
|
+
client?.close()
|
|
157
|
+
vi.useRealTimers()
|
|
158
|
+
vi.clearAllMocks()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
function createConnectMessage(
|
|
162
|
+
overrides: Partial<Extract<TLSocketServerSentEvent<TestRecord>, { type: 'connect' }>> = {}
|
|
163
|
+
): Extract<TLSocketServerSentEvent<TestRecord>, { type: 'connect' }> {
|
|
164
|
+
return {
|
|
165
|
+
type: 'connect',
|
|
166
|
+
connectRequestId: client.latestConnectRequestId!,
|
|
167
|
+
hydrationType: 'wipe_all',
|
|
168
|
+
protocolVersion,
|
|
169
|
+
schema: schema.serialize(),
|
|
170
|
+
isReadonly: false,
|
|
171
|
+
serverClock: 1,
|
|
172
|
+
diff: {},
|
|
173
|
+
...overrides,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function createClient(
|
|
178
|
+
overrides: Partial<{
|
|
179
|
+
store: Store<TestRecord, any>
|
|
180
|
+
socket: TLPersistentClientSocket<TestRecord>
|
|
181
|
+
presence: Atom<TestRecord | null>
|
|
182
|
+
presenceMode?: Atom<TLPresenceMode>
|
|
183
|
+
onLoad(self: TLSyncClient<TestRecord, Store<TestRecord, any>>): void
|
|
184
|
+
onSyncError(reason: string): void
|
|
185
|
+
onCustomMessageReceived?(data: any): void
|
|
186
|
+
onAfterConnect?(
|
|
187
|
+
self: TLSyncClient<TestRecord, Store<TestRecord, any>>,
|
|
188
|
+
details: { isReadonly: boolean }
|
|
189
|
+
): void
|
|
190
|
+
didCancel?(): boolean
|
|
191
|
+
}> = {}
|
|
192
|
+
): TLSyncClient<TestRecord, Store<TestRecord, any>> {
|
|
193
|
+
return new TLSyncClient<TestRecord, Store<TestRecord, any>>({
|
|
194
|
+
store,
|
|
195
|
+
socket,
|
|
196
|
+
presence,
|
|
197
|
+
presenceMode,
|
|
198
|
+
onLoad,
|
|
199
|
+
onSyncError,
|
|
200
|
+
onCustomMessageReceived,
|
|
201
|
+
onAfterConnect,
|
|
202
|
+
...overrides,
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
describe('Construction and Initialization', () => {
|
|
207
|
+
it('creates a client with required configuration', () => {
|
|
208
|
+
client = createClient()
|
|
209
|
+
expect(client).toBeInstanceOf(TLSyncClient)
|
|
210
|
+
expect(client.store).toBe(store)
|
|
211
|
+
expect(client.socket).toBe(socket)
|
|
212
|
+
expect(client.presenceState).toBe(presence)
|
|
213
|
+
expect(client.presenceMode).toBe(presenceMode)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('initializes with correct default state', () => {
|
|
217
|
+
client = createClient()
|
|
218
|
+
expect(client.isConnectedToRoom).toBe(false)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('sends connect message when socket is already online', () => {
|
|
222
|
+
client = createClient()
|
|
223
|
+
expect(socket.getSentMessages()).toHaveLength(1)
|
|
224
|
+
expect(socket.getLastSentMessage().type).toBe('connect')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('waits for socket to come online before sending connect message', () => {
|
|
228
|
+
socket.connectionStatus = 'offline'
|
|
229
|
+
client = createClient()
|
|
230
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
231
|
+
|
|
232
|
+
socket.mockConnectionStatus('online')
|
|
233
|
+
expect(socket.getSentMessages()).toHaveLength(1)
|
|
234
|
+
expect(socket.getLastSentMessage().type).toBe('connect')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('sets up window.tlsync for debugging', () => {
|
|
238
|
+
client = createClient()
|
|
239
|
+
expect((globalThis as any).tlsync).toBe(client)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('handles optional callbacks', () => {
|
|
243
|
+
client = createClient({
|
|
244
|
+
onCustomMessageReceived: undefined,
|
|
245
|
+
onAfterConnect: undefined,
|
|
246
|
+
})
|
|
247
|
+
expect(client).toBeInstanceOf(TLSyncClient)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('handles optional presence mode', () => {
|
|
251
|
+
client = createClient({
|
|
252
|
+
presenceMode: undefined,
|
|
253
|
+
})
|
|
254
|
+
expect(client.presenceMode).toBeUndefined()
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
describe('Connection Lifecycle', () => {
|
|
259
|
+
beforeEach(() => {
|
|
260
|
+
client = createClient()
|
|
261
|
+
socket.clearSentMessages()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('handles successful connection', () => {
|
|
265
|
+
const connectMessage = createConnectMessage()
|
|
266
|
+
|
|
267
|
+
socket.mockServerMessage(connectMessage)
|
|
268
|
+
|
|
269
|
+
expect(client.isConnectedToRoom).toBe(true)
|
|
270
|
+
expect(onLoad).toHaveBeenCalledWith(client)
|
|
271
|
+
expect(onAfterConnect).toHaveBeenCalledWith(client, { isReadonly: false })
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('handles connection with readonly mode', () => {
|
|
275
|
+
const connectMessage = createConnectMessage({ isReadonly: true })
|
|
276
|
+
|
|
277
|
+
socket.mockServerMessage(connectMessage)
|
|
278
|
+
|
|
279
|
+
expect(onAfterConnect).toHaveBeenCalledWith(client, { isReadonly: true })
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('handles socket going offline', () => {
|
|
283
|
+
// First connect
|
|
284
|
+
socket.mockServerMessage(createConnectMessage())
|
|
285
|
+
expect(client.isConnectedToRoom).toBe(true)
|
|
286
|
+
|
|
287
|
+
// Then go offline
|
|
288
|
+
socket.mockConnectionStatus('offline')
|
|
289
|
+
expect(client.isConnectedToRoom).toBe(false)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('handles socket errors', () => {
|
|
293
|
+
socket.mockConnectionStatus('error', 'Connection failed')
|
|
294
|
+
expect(onSyncError).toHaveBeenCalledWith('Connection failed')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('sends ping messages periodically', () => {
|
|
298
|
+
// Connect first
|
|
299
|
+
socket.mockServerMessage(createConnectMessage())
|
|
300
|
+
|
|
301
|
+
socket.clearSentMessages()
|
|
302
|
+
|
|
303
|
+
// Advance time to trigger ping
|
|
304
|
+
vi.advanceTimersByTime(5000)
|
|
305
|
+
expect(socket.getSentMessages()).toHaveLength(1)
|
|
306
|
+
expect(socket.getLastSentMessage().type).toBe('ping')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('resets connection if no server interaction for too long', () => {
|
|
310
|
+
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
311
|
+
|
|
312
|
+
// Connect first
|
|
313
|
+
socket.mockServerMessage(createConnectMessage())
|
|
314
|
+
|
|
315
|
+
// Advance time beyond health check threshold
|
|
316
|
+
vi.advanceTimersByTime(15000) // Greater than MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION
|
|
317
|
+
|
|
318
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
319
|
+
expect.stringContaining("Haven't heard from the server in a while")
|
|
320
|
+
)
|
|
321
|
+
expect(client.isConnectedToRoom).toBe(false)
|
|
322
|
+
|
|
323
|
+
consoleSpy.mockRestore()
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
describe('Message Handling', () => {
|
|
328
|
+
beforeEach(() => {
|
|
329
|
+
client = createClient()
|
|
330
|
+
// Connect first
|
|
331
|
+
socket.mockServerMessage(createConnectMessage())
|
|
332
|
+
socket.clearSentMessages()
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('handles pong messages', () => {
|
|
336
|
+
const pongMessage: Extract<TLSocketServerSentEvent<TestRecord>, { type: 'pong' }> = {
|
|
337
|
+
type: 'pong',
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
socket.mockServerMessage(pongMessage)
|
|
341
|
+
// Pong messages are just used to update lastServerInteractionTimestamp
|
|
342
|
+
// No specific assertion needed beyond not throwing
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('handles custom messages', () => {
|
|
346
|
+
const customData = { type: 'chat', message: 'Hello world' }
|
|
347
|
+
const customMessage: Extract<TLSocketServerSentEvent<TestRecord>, { type: 'custom' }> = {
|
|
348
|
+
type: 'custom',
|
|
349
|
+
data: customData,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
socket.mockServerMessage(customMessage)
|
|
353
|
+
|
|
354
|
+
expect(onCustomMessageReceived).toHaveBeenCalledWith(customData)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('handles data messages and triggers rebase', () => {
|
|
358
|
+
const dataMessage: Extract<TLSocketServerSentEvent<TestRecord>, { type: 'data' }> = {
|
|
359
|
+
type: 'data',
|
|
360
|
+
data: [
|
|
361
|
+
{
|
|
362
|
+
type: 'patch',
|
|
363
|
+
serverClock: 2,
|
|
364
|
+
diff: {},
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
socket.mockServerMessage(dataMessage)
|
|
370
|
+
// Rebase is throttled, so advance timers
|
|
371
|
+
vi.advanceTimersByTime(100)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('handles legacy patch messages', () => {
|
|
375
|
+
const patchMessage: Extract<TLSocketServerSentEvent<TestRecord>, { type: 'patch' }> = {
|
|
376
|
+
type: 'patch',
|
|
377
|
+
serverClock: 2,
|
|
378
|
+
diff: {},
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
socket.mockServerMessage(patchMessage)
|
|
382
|
+
vi.advanceTimersByTime(100)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('ignores messages when not connected to room', () => {
|
|
386
|
+
// Reset connection
|
|
387
|
+
client.isConnectedToRoom = false
|
|
388
|
+
|
|
389
|
+
const dataMessage: Extract<TLSocketServerSentEvent<TestRecord>, { type: 'data' }> = {
|
|
390
|
+
type: 'data',
|
|
391
|
+
data: [],
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
socket.mockServerMessage(dataMessage)
|
|
395
|
+
// Should not process the message or throw
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('handles incompatibility_error messages', () => {
|
|
399
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
400
|
+
|
|
401
|
+
const errorMessage: Extract<
|
|
402
|
+
TLSocketServerSentEvent<TestRecord>,
|
|
403
|
+
{ type: 'incompatibility_error' }
|
|
404
|
+
> = {
|
|
405
|
+
type: 'incompatibility_error',
|
|
406
|
+
reason: 'clientTooOld',
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
socket.mockServerMessage(errorMessage)
|
|
410
|
+
|
|
411
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
412
|
+
expect.stringContaining('incompatibility error is legacy')
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
consoleSpy.mockRestore()
|
|
416
|
+
})
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
describe('Store Synchronization', () => {
|
|
420
|
+
beforeEach(() => {
|
|
421
|
+
client = createClient()
|
|
422
|
+
// Connect first
|
|
423
|
+
socket.mockServerMessage(createConnectMessage())
|
|
424
|
+
socket.clearSentMessages()
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it('sends push requests for local changes', () => {
|
|
428
|
+
const pageId = PageRecordType.createId()
|
|
429
|
+
store.put([
|
|
430
|
+
PageRecordType.create({
|
|
431
|
+
id: pageId,
|
|
432
|
+
name: 'Test Page',
|
|
433
|
+
index: 'a1' as any,
|
|
434
|
+
}),
|
|
435
|
+
])
|
|
436
|
+
|
|
437
|
+
vi.advanceTimersByTime(100)
|
|
438
|
+
|
|
439
|
+
expect(socket.getSentMessages()).toHaveLength(1)
|
|
440
|
+
const message = socket.getLastSentMessage() as TLPushRequest<TestRecord>
|
|
441
|
+
expect(message.type).toBe('push')
|
|
442
|
+
expect(message.diff).toBeDefined()
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('does not send push requests when offline', () => {
|
|
446
|
+
socket.mockConnectionStatus('offline')
|
|
447
|
+
|
|
448
|
+
const pageId = PageRecordType.createId()
|
|
449
|
+
store.put([
|
|
450
|
+
PageRecordType.create({
|
|
451
|
+
id: pageId,
|
|
452
|
+
name: 'Test Page',
|
|
453
|
+
index: 'a1' as any,
|
|
454
|
+
}),
|
|
455
|
+
])
|
|
456
|
+
|
|
457
|
+
vi.advanceTimersByTime(100)
|
|
458
|
+
|
|
459
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('throttles push request sending', () => {
|
|
463
|
+
// Make multiple rapid changes without waiting for timers
|
|
464
|
+
for (let i = 0; i < 5; i++) {
|
|
465
|
+
const pageId = PageRecordType.createId()
|
|
466
|
+
store.put([
|
|
467
|
+
PageRecordType.create({
|
|
468
|
+
id: pageId,
|
|
469
|
+
name: `Test Page ${i}`,
|
|
470
|
+
index: `a${i}` as any,
|
|
471
|
+
}),
|
|
472
|
+
])
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// After throttle resolves
|
|
476
|
+
vi.advanceTimersByTime(100)
|
|
477
|
+
// Should have sent at least one message but possibly consolidated multiple changes
|
|
478
|
+
const messages = socket.getSentMessages()
|
|
479
|
+
expect(messages.length).toBeGreaterThan(0)
|
|
480
|
+
expect(messages.length).toBeLessThanOrEqual(5)
|
|
481
|
+
})
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
describe('Presence Management', () => {
|
|
485
|
+
let presenceRecord: TestRecord
|
|
486
|
+
|
|
487
|
+
beforeEach(() => {
|
|
488
|
+
// Mock a presence record type
|
|
489
|
+
presenceRecord = {
|
|
490
|
+
id: 'presence:user1' as any,
|
|
491
|
+
typeName: 'instance_presence',
|
|
492
|
+
cursor: { x: 100, y: 200 },
|
|
493
|
+
userName: 'Test User',
|
|
494
|
+
} as any
|
|
495
|
+
|
|
496
|
+
client = createClient()
|
|
497
|
+
|
|
498
|
+
// Connect first
|
|
499
|
+
socket.mockServerMessage(createConnectMessage())
|
|
500
|
+
socket.clearSentMessages()
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
it('sends presence updates when presence changes', () => {
|
|
504
|
+
presence.set(presenceRecord)
|
|
505
|
+
vi.advanceTimersByTime(100)
|
|
506
|
+
|
|
507
|
+
const messages = socket.getSentMessages()
|
|
508
|
+
const pushMessages = messages.filter(
|
|
509
|
+
(msg) => msg.type === 'push'
|
|
510
|
+
) as TLPushRequest<TestRecord>[]
|
|
511
|
+
expect(pushMessages.length).toBeGreaterThan(0)
|
|
512
|
+
|
|
513
|
+
const messageWithPresence = pushMessages.find((msg) => msg.presence)
|
|
514
|
+
expect(messageWithPresence).toBeDefined()
|
|
515
|
+
expect(messageWithPresence!.presence).toBeDefined()
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
it('does not send presence when mode is solo', () => {
|
|
519
|
+
presenceMode.set('solo')
|
|
520
|
+
presence.set(presenceRecord)
|
|
521
|
+
vi.advanceTimersByTime(100)
|
|
522
|
+
|
|
523
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
it('sends full presence on first update', () => {
|
|
527
|
+
presence.set(presenceRecord)
|
|
528
|
+
vi.advanceTimersByTime(100)
|
|
529
|
+
|
|
530
|
+
const messages = socket.getSentMessages()
|
|
531
|
+
const pushMessages = messages.filter(
|
|
532
|
+
(msg) => msg.type === 'push'
|
|
533
|
+
) as TLPushRequest<TestRecord>[]
|
|
534
|
+
const messageWithPresence = pushMessages.find((msg) => msg.presence)
|
|
535
|
+
|
|
536
|
+
expect(messageWithPresence).toBeDefined()
|
|
537
|
+
expect(messageWithPresence!.presence![0]).toBe(RecordOpType.Put)
|
|
538
|
+
expect(messageWithPresence!.presence![1]).toBe(presenceRecord)
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('sends presence diffs for subsequent updates', () => {
|
|
542
|
+
// Set initial presence
|
|
543
|
+
presence.set(presenceRecord)
|
|
544
|
+
vi.advanceTimersByTime(100)
|
|
545
|
+
socket.clearSentMessages()
|
|
546
|
+
|
|
547
|
+
// Update presence
|
|
548
|
+
const updatedPresence = { ...presenceRecord, cursor: { x: 150, y: 250 } }
|
|
549
|
+
presence.set(updatedPresence as TestRecord)
|
|
550
|
+
vi.advanceTimersByTime(100)
|
|
551
|
+
|
|
552
|
+
const messages = socket.getSentMessages()
|
|
553
|
+
const pushMessages = messages.filter(
|
|
554
|
+
(msg) => msg.type === 'push'
|
|
555
|
+
) as TLPushRequest<TestRecord>[]
|
|
556
|
+
const messageWithPresence = pushMessages.find((msg) => msg.presence)
|
|
557
|
+
|
|
558
|
+
expect(messageWithPresence).toBeDefined()
|
|
559
|
+
expect(messageWithPresence!.presence![0]).toBe(RecordOpType.Patch)
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it('does not send presence when offline', () => {
|
|
563
|
+
socket.mockConnectionStatus('offline')
|
|
564
|
+
|
|
565
|
+
presence.set(presenceRecord)
|
|
566
|
+
vi.advanceTimersByTime(100)
|
|
567
|
+
|
|
568
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
569
|
+
})
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
describe('Error Handling', () => {
|
|
573
|
+
beforeEach(() => {
|
|
574
|
+
client = createClient()
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('handles rebase errors gracefully', () => {
|
|
578
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
579
|
+
|
|
580
|
+
// Connect first
|
|
581
|
+
socket.mockServerMessage(createConnectMessage())
|
|
582
|
+
|
|
583
|
+
// Simulate a corrupted message that causes rebase to fail
|
|
584
|
+
const malformedMessage: TLSocketServerSentDataEvent<TestRecord> = {
|
|
585
|
+
type: 'push_result',
|
|
586
|
+
action: 'commit',
|
|
587
|
+
serverClock: 2,
|
|
588
|
+
clientClock: 999999, // Non-existent client clock
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
socket.mockServerMessage({
|
|
592
|
+
type: 'data',
|
|
593
|
+
data: [malformedMessage],
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
vi.advanceTimersByTime(100)
|
|
597
|
+
|
|
598
|
+
expect(consoleSpy).toHaveBeenCalled()
|
|
599
|
+
consoleSpy.mockRestore()
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it('handles store corruption recovery', () => {
|
|
603
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
604
|
+
|
|
605
|
+
// Connect first
|
|
606
|
+
socket.mockServerMessage(createConnectMessage())
|
|
607
|
+
|
|
608
|
+
// Clear initial messages
|
|
609
|
+
socket.clearSentMessages()
|
|
610
|
+
|
|
611
|
+
// Mock store as corrupted
|
|
612
|
+
vi.spyOn(store, 'isPossiblyCorrupted').mockReturnValue(true)
|
|
613
|
+
|
|
614
|
+
// Try to make a change
|
|
615
|
+
const pageId = PageRecordType.createId()
|
|
616
|
+
store.put([
|
|
617
|
+
PageRecordType.create({
|
|
618
|
+
id: pageId,
|
|
619
|
+
name: 'Test',
|
|
620
|
+
index: 'a1' as any,
|
|
621
|
+
}),
|
|
622
|
+
])
|
|
623
|
+
|
|
624
|
+
vi.advanceTimersByTime(100)
|
|
625
|
+
|
|
626
|
+
// Should not send new messages when store is corrupted
|
|
627
|
+
expect(socket.getSentMessages()).toHaveLength(0)
|
|
628
|
+
|
|
629
|
+
consoleSpy.mockRestore()
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
it('handles didCancel function', () => {
|
|
633
|
+
const didCancel = vi.fn(() => true)
|
|
634
|
+
client = createClient({ didCancel })
|
|
635
|
+
|
|
636
|
+
// Make a change that would trigger cancellation
|
|
637
|
+
const pageId = PageRecordType.createId()
|
|
638
|
+
store.put([
|
|
639
|
+
PageRecordType.create({
|
|
640
|
+
id: pageId,
|
|
641
|
+
name: 'Test',
|
|
642
|
+
index: 'a1' as any,
|
|
643
|
+
}),
|
|
644
|
+
])
|
|
645
|
+
|
|
646
|
+
expect(didCancel).toHaveBeenCalled()
|
|
647
|
+
})
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
describe('Cleanup and Disposal', () => {
|
|
651
|
+
beforeEach(() => {
|
|
652
|
+
client = createClient()
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
it('properly cleans up resources on close', () => {
|
|
656
|
+
client.close()
|
|
657
|
+
|
|
658
|
+
// Should not throw errors after close
|
|
659
|
+
expect(() => {
|
|
660
|
+
const pageId = PageRecordType.createId()
|
|
661
|
+
store.put([
|
|
662
|
+
PageRecordType.create({
|
|
663
|
+
id: pageId,
|
|
664
|
+
name: 'Test',
|
|
665
|
+
index: 'a1' as any,
|
|
666
|
+
}),
|
|
667
|
+
])
|
|
668
|
+
vi.advanceTimersByTime(100)
|
|
669
|
+
}).not.toThrow()
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
it('cancels throttled functions on close', () => {
|
|
673
|
+
// Reset and ensure we're connected
|
|
674
|
+
socket.clearSentMessages()
|
|
675
|
+
|
|
676
|
+
const pageId = PageRecordType.createId()
|
|
677
|
+
store.put([
|
|
678
|
+
PageRecordType.create({
|
|
679
|
+
id: pageId,
|
|
680
|
+
name: 'Test',
|
|
681
|
+
index: 'a1' as any,
|
|
682
|
+
}),
|
|
683
|
+
])
|
|
684
|
+
|
|
685
|
+
// Close before throttle resolves
|
|
686
|
+
client.close()
|
|
687
|
+
|
|
688
|
+
// Should not send new messages after close (except the connect message that was already sent)
|
|
689
|
+
const messagesBefore = socket.getSentMessages().length
|
|
690
|
+
vi.advanceTimersByTime(100)
|
|
691
|
+
expect(socket.getSentMessages().length).toBe(messagesBefore)
|
|
692
|
+
})
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
describe('Connection Recovery', () => {
|
|
696
|
+
beforeEach(() => {
|
|
697
|
+
client = createClient()
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
it('handles reconnection with speculative changes', () => {
|
|
701
|
+
// Connect initially
|
|
702
|
+
socket.mockServerMessage(createConnectMessage())
|
|
703
|
+
|
|
704
|
+
// Make local changes while online
|
|
705
|
+
const pageId = PageRecordType.createId()
|
|
706
|
+
store.put([
|
|
707
|
+
PageRecordType.create({
|
|
708
|
+
id: pageId,
|
|
709
|
+
name: 'Test',
|
|
710
|
+
index: 'a1' as any,
|
|
711
|
+
}),
|
|
712
|
+
])
|
|
713
|
+
|
|
714
|
+
// Go offline
|
|
715
|
+
socket.mockConnectionStatus('offline')
|
|
716
|
+
|
|
717
|
+
// Make more changes while offline
|
|
718
|
+
store.update(pageId, (p) => ({ ...p, name: 'Updated Offline' }))
|
|
719
|
+
|
|
720
|
+
// Reconnect
|
|
721
|
+
socket.mockConnectionStatus('online')
|
|
722
|
+
|
|
723
|
+
// Should send a new connect message
|
|
724
|
+
expect(socket.getSentMessages().some((msg) => msg.type === 'connect')).toBe(true)
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
it('handles wipe_all reconnection', () => {
|
|
728
|
+
// Connect initially with some data
|
|
729
|
+
const pageId = PageRecordType.createId()
|
|
730
|
+
store.put([
|
|
731
|
+
PageRecordType.create({
|
|
732
|
+
id: pageId,
|
|
733
|
+
name: 'Test',
|
|
734
|
+
index: 'a1' as any,
|
|
735
|
+
}),
|
|
736
|
+
])
|
|
737
|
+
|
|
738
|
+
socket.mockServerMessage(
|
|
739
|
+
createConnectMessage({
|
|
740
|
+
diff: {
|
|
741
|
+
[TLDOCUMENT_ID]: [
|
|
742
|
+
RecordOpType.Put,
|
|
743
|
+
DocumentRecordType.create({
|
|
744
|
+
id: TLDOCUMENT_ID,
|
|
745
|
+
gridSize: 20,
|
|
746
|
+
}),
|
|
747
|
+
],
|
|
748
|
+
},
|
|
749
|
+
})
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
// Should apply server data
|
|
753
|
+
const doc = store.get(TLDOCUMENT_ID)
|
|
754
|
+
expect(doc?.gridSize).toBe(20)
|
|
755
|
+
})
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
describe('Complex Scenarios', () => {
|
|
759
|
+
beforeEach(() => {
|
|
760
|
+
client = createClient()
|
|
761
|
+
// Connect first
|
|
762
|
+
socket.mockServerMessage(createConnectMessage())
|
|
763
|
+
socket.clearSentMessages()
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
it('handles rapid connection state changes', () => {
|
|
767
|
+
// Rapidly change connection states
|
|
768
|
+
socket.mockConnectionStatus('offline')
|
|
769
|
+
socket.mockConnectionStatus('online')
|
|
770
|
+
socket.mockConnectionStatus('error', 'Test error')
|
|
771
|
+
|
|
772
|
+
expect(onSyncError).toHaveBeenCalledWith('Test error')
|
|
773
|
+
})
|
|
774
|
+
|
|
775
|
+
it('handles multiple simultaneous presence and document changes', () => {
|
|
776
|
+
// Set presence
|
|
777
|
+
const presenceRecord = {
|
|
778
|
+
id: 'presence:user1' as any,
|
|
779
|
+
typeName: 'instance_presence',
|
|
780
|
+
cursor: { x: 100, y: 200 },
|
|
781
|
+
} as TestRecord
|
|
782
|
+
|
|
783
|
+
presence.set(presenceRecord)
|
|
784
|
+
|
|
785
|
+
// Make document changes
|
|
786
|
+
const pageId = PageRecordType.createId()
|
|
787
|
+
store.put([
|
|
788
|
+
PageRecordType.create({
|
|
789
|
+
id: pageId,
|
|
790
|
+
name: 'Test',
|
|
791
|
+
index: 'a1' as any,
|
|
792
|
+
}),
|
|
793
|
+
])
|
|
794
|
+
|
|
795
|
+
vi.advanceTimersByTime(100)
|
|
796
|
+
|
|
797
|
+
// Should send messages
|
|
798
|
+
const messages = socket.getSentMessages()
|
|
799
|
+
expect(messages.length).toBeGreaterThan(0)
|
|
800
|
+
|
|
801
|
+
// Check if any push messages contain presence or document data
|
|
802
|
+
const pushMessages = messages.filter(
|
|
803
|
+
(msg) => msg.type === 'push'
|
|
804
|
+
) as TLPushRequest<TestRecord>[]
|
|
805
|
+
const hasPresenceOrDocument = pushMessages.some((msg) => msg.presence || msg.diff)
|
|
806
|
+
expect(hasPresenceOrDocument).toBe(true)
|
|
807
|
+
})
|
|
808
|
+
|
|
809
|
+
it('handles server clock advancement correctly', () => {
|
|
810
|
+
// Send multiple server messages with advancing clocks
|
|
811
|
+
for (let i = 1; i <= 5; i++) {
|
|
812
|
+
socket.mockServerMessage({
|
|
813
|
+
type: 'data',
|
|
814
|
+
data: [
|
|
815
|
+
{
|
|
816
|
+
type: 'patch',
|
|
817
|
+
serverClock: i + 1,
|
|
818
|
+
diff: {},
|
|
819
|
+
},
|
|
820
|
+
],
|
|
821
|
+
})
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
vi.advanceTimersByTime(100)
|
|
825
|
+
// Should track the latest server clock internally
|
|
826
|
+
})
|
|
827
|
+
})
|
|
828
|
+
})
|