@epicenter/sync 0.1.0
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/LICENSE +666 -0
- package/README.md +107 -0
- package/package.json +43 -0
- package/src/index.ts +39 -0
- package/src/protocol.test.ts +776 -0
- package/src/protocol.ts +612 -0
- package/src/rpc-errors.ts +89 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol Unit Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests y-websocket-compatible protocol helpers used by the server sync endpoint.
|
|
5
|
+
* Coverage focuses on message encoding/decoding, compatibility with y-protocols,
|
|
6
|
+
* and end-to-end synchronization behavior under common and edge conditions.
|
|
7
|
+
*
|
|
8
|
+
* Key behaviors:
|
|
9
|
+
* - Sync and awareness frames encode/decode with expected wire formats.
|
|
10
|
+
* - Handshake and incremental updates converge document state across peers.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import {
|
|
15
|
+
Awareness,
|
|
16
|
+
applyAwarenessUpdate,
|
|
17
|
+
encodeAwarenessUpdate,
|
|
18
|
+
} from 'y-protocols/awareness';
|
|
19
|
+
import * as Y from 'yjs';
|
|
20
|
+
import { RpcError } from './rpc-errors';
|
|
21
|
+
import {
|
|
22
|
+
decodeMessageType,
|
|
23
|
+
decodeRpcMessage,
|
|
24
|
+
decodeSyncMessage,
|
|
25
|
+
decodeSyncStatus,
|
|
26
|
+
encodeAwareness,
|
|
27
|
+
encodeAwarenessStates,
|
|
28
|
+
encodeQueryAwareness,
|
|
29
|
+
encodeRpcRequest,
|
|
30
|
+
encodeRpcResponse,
|
|
31
|
+
encodeSyncStatus,
|
|
32
|
+
encodeSyncStep1,
|
|
33
|
+
encodeSyncStep2,
|
|
34
|
+
encodeSyncUpdate,
|
|
35
|
+
handleSyncPayload,
|
|
36
|
+
MESSAGE_TYPE,
|
|
37
|
+
RPC_TYPE,
|
|
38
|
+
SYNC_MESSAGE_TYPE,
|
|
39
|
+
} from './protocol';
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// MESSAGE_TYPE Constants
|
|
43
|
+
// ============================================================================
|
|
44
|
+
|
|
45
|
+
describe('MESSAGE_TYPE constants', () => {
|
|
46
|
+
test('match y-websocket protocol values', () => {
|
|
47
|
+
// These values are defined by y-websocket and must not change
|
|
48
|
+
expect(MESSAGE_TYPE.SYNC).toBe(0);
|
|
49
|
+
expect(MESSAGE_TYPE.AWARENESS).toBe(1);
|
|
50
|
+
expect(MESSAGE_TYPE.AUTH).toBe(2);
|
|
51
|
+
expect(MESSAGE_TYPE.QUERY_AWARENESS).toBe(3);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('SYNC_STATUS is 100 (custom extension for version tracking)', () => {
|
|
55
|
+
expect(MESSAGE_TYPE.SYNC_STATUS).toBe(100);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// SYNC_STATUS Encode/Decode Tests
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
describe('SYNC_STATUS encode/decode', () => {
|
|
64
|
+
test('encodeSyncStatus produces correct message type', () => {
|
|
65
|
+
const message = encodeSyncStatus(42);
|
|
66
|
+
expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.SYNC_STATUS);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('round-trip: encode then decode preserves localVersion', () => {
|
|
70
|
+
const version = 12345;
|
|
71
|
+
const encoded = encodeSyncStatus(version);
|
|
72
|
+
const decoded = decodeSyncStatus(encoded);
|
|
73
|
+
expect(decoded).toBe(version);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('round-trip with version 0', () => {
|
|
77
|
+
const encoded = encodeSyncStatus(0);
|
|
78
|
+
const decoded = decodeSyncStatus(encoded);
|
|
79
|
+
expect(decoded).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('round-trip with large version number', () => {
|
|
83
|
+
const version = 1_000_000;
|
|
84
|
+
const encoded = encodeSyncStatus(version);
|
|
85
|
+
const decoded = decodeSyncStatus(encoded);
|
|
86
|
+
expect(decoded).toBe(version);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('decodeSyncStatus throws on non-SYNC_STATUS message', () => {
|
|
90
|
+
const doc = createDoc();
|
|
91
|
+
const syncMessage = encodeSyncStep1({ doc });
|
|
92
|
+
expect(() => decodeSyncStatus(syncMessage)).toThrow(
|
|
93
|
+
'Expected SYNC_STATUS message (100), got 0',
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// RPC Encode/Decode Tests
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
describe('RPC protocol', () => {
|
|
103
|
+
test('MESSAGE_TYPE.RPC is 101', () => {
|
|
104
|
+
expect(MESSAGE_TYPE.RPC).toBe(101);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('RPC_TYPE constants', () => {
|
|
108
|
+
expect(RPC_TYPE.REQUEST).toBe(0);
|
|
109
|
+
expect(RPC_TYPE.RESPONSE).toBe(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('round-trip: encode/decode RPC REQUEST', () => {
|
|
113
|
+
const encoded = encodeRpcRequest({
|
|
114
|
+
requestId: 42,
|
|
115
|
+
targetClientId: 100,
|
|
116
|
+
requesterClientId: 200,
|
|
117
|
+
action: 'tabs.close',
|
|
118
|
+
input: { tabIds: [1, 2, 3] },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(decodeMessageType(encoded)).toBe(MESSAGE_TYPE.RPC);
|
|
122
|
+
|
|
123
|
+
const decoded = decodeRpcMessage(encoded);
|
|
124
|
+
expect(decoded.type).toBe('request');
|
|
125
|
+
if (decoded.type === 'request') {
|
|
126
|
+
expect(decoded.requestId).toBe(42);
|
|
127
|
+
expect(decoded.targetClientId).toBe(100);
|
|
128
|
+
expect(decoded.requesterClientId).toBe(200);
|
|
129
|
+
expect(decoded.action).toBe('tabs.close');
|
|
130
|
+
expect(decoded.input).toEqual({ tabIds: [1, 2, 3] });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('round-trip: encode/decode RPC RESPONSE', () => {
|
|
135
|
+
const encoded = encodeRpcResponse({
|
|
136
|
+
requestId: 42,
|
|
137
|
+
requesterClientId: 200,
|
|
138
|
+
result: { data: { closedCount: 3 }, error: null },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(decodeMessageType(encoded)).toBe(MESSAGE_TYPE.RPC);
|
|
142
|
+
|
|
143
|
+
const decoded = decodeRpcMessage(encoded);
|
|
144
|
+
expect(decoded.type).toBe('response');
|
|
145
|
+
if (decoded.type === 'response') {
|
|
146
|
+
expect(decoded.requestId).toBe(42);
|
|
147
|
+
expect(decoded.requesterClientId).toBe(200);
|
|
148
|
+
expect(decoded.result).toEqual({ data: { closedCount: 3 }, error: null });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('REQUEST with null input', () => {
|
|
153
|
+
const encoded = encodeRpcRequest({
|
|
154
|
+
requestId: 0,
|
|
155
|
+
targetClientId: 50,
|
|
156
|
+
requesterClientId: 60,
|
|
157
|
+
action: 'devices.list',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const decoded = decodeRpcMessage(encoded);
|
|
161
|
+
expect(decoded.type).toBe('request');
|
|
162
|
+
if (decoded.type === 'request') {
|
|
163
|
+
expect(decoded.input).toBeNull();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('RESPONSE with error', () => {
|
|
168
|
+
const encoded = encodeRpcResponse({
|
|
169
|
+
requestId: 7,
|
|
170
|
+
requesterClientId: 300,
|
|
171
|
+
result: RpcError.PeerOffline(),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const decoded = decodeRpcMessage(encoded);
|
|
175
|
+
expect(decoded.type).toBe('response');
|
|
176
|
+
if (decoded.type === 'response') {
|
|
177
|
+
expect(decoded.result.data).toBeNull();
|
|
178
|
+
expect(decoded.result.error).toMatchObject({
|
|
179
|
+
name: 'PeerOffline',
|
|
180
|
+
message: 'Target peer is not connected',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('decodeRpcMessage discriminates REQUEST vs RESPONSE', () => {
|
|
186
|
+
const request = encodeRpcRequest({
|
|
187
|
+
requestId: 1,
|
|
188
|
+
targetClientId: 10,
|
|
189
|
+
requesterClientId: 20,
|
|
190
|
+
action: 'test',
|
|
191
|
+
});
|
|
192
|
+
const response = encodeRpcResponse({
|
|
193
|
+
requestId: 1,
|
|
194
|
+
requesterClientId: 20,
|
|
195
|
+
result: { data: 'ok', error: null },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(decodeRpcMessage(request).type).toBe('request');
|
|
199
|
+
expect(decodeRpcMessage(response).type).toBe('response');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('decodeRpcMessage throws on non-RPC message', () => {
|
|
203
|
+
const syncMessage = encodeSyncStep1({ doc: new Y.Doc() });
|
|
204
|
+
expect(() => decodeRpcMessage(syncMessage)).toThrow(
|
|
205
|
+
'Expected RPC message (101), got 0',
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('SYNC_MESSAGE_TYPE constants', () => {
|
|
211
|
+
test('have expected numeric values', () => {
|
|
212
|
+
expect(SYNC_MESSAGE_TYPE.STEP1).toBe(0);
|
|
213
|
+
expect(SYNC_MESSAGE_TYPE.STEP2).toBe(1);
|
|
214
|
+
expect(SYNC_MESSAGE_TYPE.UPDATE).toBe(2);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ============================================================================
|
|
219
|
+
// MESSAGE_SYNC Tests
|
|
220
|
+
// ============================================================================
|
|
221
|
+
|
|
222
|
+
describe('MESSAGE_SYNC', () => {
|
|
223
|
+
describe('encodeSyncStep1', () => {
|
|
224
|
+
test('encodes empty document', () => {
|
|
225
|
+
const doc = createDoc();
|
|
226
|
+
const message = encodeSyncStep1({ doc });
|
|
227
|
+
const decoded = decodeSyncMessage(message);
|
|
228
|
+
|
|
229
|
+
expect(decoded.type).toBe('step1');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('encodes document with content', () => {
|
|
233
|
+
const doc = createDoc((d) => {
|
|
234
|
+
d.getMap('data').set('key', 'value');
|
|
235
|
+
});
|
|
236
|
+
const message = encodeSyncStep1({ doc });
|
|
237
|
+
const decoded = decodeSyncMessage(message);
|
|
238
|
+
|
|
239
|
+
expect(decoded.type).toBe('step1');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('state vector changes after modification', () => {
|
|
243
|
+
const doc = createDoc();
|
|
244
|
+
const message1 = encodeSyncStep1({ doc });
|
|
245
|
+
|
|
246
|
+
doc.getMap('data').set('key', 'value');
|
|
247
|
+
const message2 = encodeSyncStep1({ doc });
|
|
248
|
+
|
|
249
|
+
// Different state vectors = different messages
|
|
250
|
+
expect(message1).not.toEqual(message2);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('can be decoded by y-protocols', () => {
|
|
254
|
+
const doc = createDoc((d) => {
|
|
255
|
+
d.getMap('test').set('foo', 'bar');
|
|
256
|
+
});
|
|
257
|
+
const message = encodeSyncStep1({ doc });
|
|
258
|
+
const decoded = decodeSyncMessage(message);
|
|
259
|
+
|
|
260
|
+
expect(decoded.type).toBe('step1');
|
|
261
|
+
if (decoded.type === 'step1') {
|
|
262
|
+
expect(decoded.stateVector).toBeInstanceOf(Uint8Array);
|
|
263
|
+
expect(decoded.stateVector.length).toBeGreaterThan(0);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe('encodeSyncStep2', () => {
|
|
269
|
+
test('encodes document diff', () => {
|
|
270
|
+
const doc = createDoc((d) => {
|
|
271
|
+
d.getMap('data').set('key', 'value');
|
|
272
|
+
});
|
|
273
|
+
const message = encodeSyncStep2({ doc });
|
|
274
|
+
const decoded = decodeSyncMessage(message);
|
|
275
|
+
|
|
276
|
+
expect(decoded.type).toBe('step2');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('contains update data', () => {
|
|
280
|
+
const doc = createDoc((d) => {
|
|
281
|
+
d.getMap('data').set('key', 'value');
|
|
282
|
+
});
|
|
283
|
+
const message = encodeSyncStep2({ doc });
|
|
284
|
+
const decoded = decodeSyncMessage(message);
|
|
285
|
+
|
|
286
|
+
expect(decoded.type).toBe('step2');
|
|
287
|
+
if (decoded.type === 'step2') {
|
|
288
|
+
expect(decoded.update.length).toBeGreaterThan(0);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('encodeSyncUpdate', () => {
|
|
294
|
+
test('encodes incremental update', () => {
|
|
295
|
+
const doc = createDoc();
|
|
296
|
+
let capturedUpdate: Uint8Array | null = null;
|
|
297
|
+
|
|
298
|
+
doc.on('updateV2', (update: Uint8Array) => {
|
|
299
|
+
capturedUpdate = update;
|
|
300
|
+
});
|
|
301
|
+
doc.getMap('data').set('key', 'value');
|
|
302
|
+
|
|
303
|
+
expect(capturedUpdate).not.toBeNull();
|
|
304
|
+
if (!capturedUpdate) {
|
|
305
|
+
throw new Error('Expected captured update after document mutation');
|
|
306
|
+
}
|
|
307
|
+
const message = encodeSyncUpdate({ update: capturedUpdate });
|
|
308
|
+
const decoded = decodeSyncMessage(message);
|
|
309
|
+
|
|
310
|
+
expect(decoded.type).toBe('update');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('handles empty update', () => {
|
|
314
|
+
const message = encodeSyncUpdate({ update: new Uint8Array(0) });
|
|
315
|
+
|
|
316
|
+
expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.SYNC);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe('handleSyncPayload', () => {
|
|
321
|
+
test('responds to sync step 1 with sync step 2', () => {
|
|
322
|
+
const serverDoc = createDoc((d) => {
|
|
323
|
+
d.getMap('data').set('server', 'content');
|
|
324
|
+
});
|
|
325
|
+
const clientDoc = createDoc();
|
|
326
|
+
|
|
327
|
+
const response = handleSyncPayload({
|
|
328
|
+
syncType: SYNC_MESSAGE_TYPE.STEP1,
|
|
329
|
+
payload: Y.encodeStateVector(clientDoc),
|
|
330
|
+
doc: serverDoc,
|
|
331
|
+
origin: 'test-client',
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(response).not.toBeNull();
|
|
335
|
+
if (!response) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
'Expected sync step 2 response for sync step 1 payload',
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
const decoded = decodeSyncMessage(response);
|
|
341
|
+
expect(decoded.type).toBe('step2');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('returns null for sync step 2 (no response needed)', () => {
|
|
345
|
+
const serverDoc = createDoc();
|
|
346
|
+
const clientDoc = createDoc((d) => {
|
|
347
|
+
d.getMap('data').set('client', 'content');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const response = handleSyncPayload({
|
|
351
|
+
syncType: SYNC_MESSAGE_TYPE.STEP2,
|
|
352
|
+
payload: Y.encodeStateAsUpdateV2(clientDoc),
|
|
353
|
+
doc: serverDoc,
|
|
354
|
+
origin: 'test-client',
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
expect(response).toBeNull();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test('returns null for sync update (no response needed)', () => {
|
|
361
|
+
const serverDoc = createDoc();
|
|
362
|
+
const updateV2 = Y.encodeStateAsUpdateV2(
|
|
363
|
+
createDoc((d) => d.getMap('data').set('key', 'value')),
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
const response = handleSyncPayload({
|
|
367
|
+
syncType: SYNC_MESSAGE_TYPE.UPDATE,
|
|
368
|
+
payload: updateV2,
|
|
369
|
+
doc: serverDoc,
|
|
370
|
+
origin: 'test-client',
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
expect(response).toBeNull();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('applies update to document', () => {
|
|
377
|
+
const serverDoc = createDoc();
|
|
378
|
+
const clientDoc = createDoc((d) => {
|
|
379
|
+
d.getMap('data').set('key', 'value');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
handleSyncPayload({
|
|
383
|
+
syncType: SYNC_MESSAGE_TYPE.UPDATE,
|
|
384
|
+
payload: Y.encodeStateAsUpdateV2(clientDoc),
|
|
385
|
+
doc: serverDoc,
|
|
386
|
+
origin: 'test-client',
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
expect(serverDoc.getMap('data').get('key')).toBe('value');
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// ============================================================================
|
|
395
|
+
// MESSAGE_AWARENESS Tests
|
|
396
|
+
// ============================================================================
|
|
397
|
+
|
|
398
|
+
describe('MESSAGE_AWARENESS', () => {
|
|
399
|
+
describe('encodeAwarenessStates', () => {
|
|
400
|
+
test('encodes single client state', () => {
|
|
401
|
+
const doc = createDoc();
|
|
402
|
+
const awareness = new Awareness(doc);
|
|
403
|
+
awareness.setLocalState({ name: 'User 1', cursor: { x: 10, y: 20 } });
|
|
404
|
+
|
|
405
|
+
const message = encodeAwarenessStates({
|
|
406
|
+
awareness,
|
|
407
|
+
clients: [awareness.clientID],
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test('encodes complex nested state', () => {
|
|
414
|
+
const doc = createDoc();
|
|
415
|
+
const awareness = new Awareness(doc);
|
|
416
|
+
awareness.setLocalState({
|
|
417
|
+
user: { name: 'Test', color: '#ff0000' },
|
|
418
|
+
cursor: { position: { x: 100, y: 200 }, selection: [0, 10] },
|
|
419
|
+
metadata: { version: 1, flags: ['active'] },
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const message = encodeAwarenessStates({
|
|
423
|
+
awareness,
|
|
424
|
+
clients: [awareness.clientID],
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test('handles special characters in state', () => {
|
|
431
|
+
const doc = createDoc();
|
|
432
|
+
const awareness = new Awareness(doc);
|
|
433
|
+
awareness.setLocalState({
|
|
434
|
+
name: 'User with "quotes" and \'apostrophes\'',
|
|
435
|
+
emoji: '🎉🚀',
|
|
436
|
+
newlines: 'line1\nline2',
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const message = encodeAwarenessStates({
|
|
440
|
+
awareness,
|
|
441
|
+
clients: [awareness.clientID],
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('handles large awareness state', () => {
|
|
448
|
+
const doc = createDoc();
|
|
449
|
+
const awareness = new Awareness(doc);
|
|
450
|
+
awareness.setLocalState({
|
|
451
|
+
largeArray: Array(1000).fill('item'),
|
|
452
|
+
largeString: 'x'.repeat(10000),
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const message = encodeAwarenessStates({
|
|
456
|
+
awareness,
|
|
457
|
+
clients: [awareness.clientID],
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
|
|
461
|
+
expect(message.length).toBeGreaterThan(10000);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
describe('encodeAwareness', () => {
|
|
466
|
+
test('wraps raw awareness update', () => {
|
|
467
|
+
const doc = createDoc();
|
|
468
|
+
const awareness = new Awareness(doc);
|
|
469
|
+
awareness.setLocalState({ name: 'Test' });
|
|
470
|
+
|
|
471
|
+
const update = encodeAwarenessUpdate(awareness, [awareness.clientID]);
|
|
472
|
+
const message = encodeAwareness({ update });
|
|
473
|
+
|
|
474
|
+
expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
describe('awareness protocol compatibility', () => {
|
|
479
|
+
test('encoded awareness can be applied to another instance', () => {
|
|
480
|
+
const doc1 = createDoc();
|
|
481
|
+
const awareness1 = new Awareness(doc1);
|
|
482
|
+
awareness1.setLocalState({ name: 'User 1' });
|
|
483
|
+
|
|
484
|
+
const doc2 = createDoc();
|
|
485
|
+
const awareness2 = new Awareness(doc2);
|
|
486
|
+
|
|
487
|
+
// Encode from awareness1
|
|
488
|
+
const update = encodeAwarenessUpdate(awareness1, [awareness1.clientID]);
|
|
489
|
+
|
|
490
|
+
// Apply to awareness2
|
|
491
|
+
applyAwarenessUpdate(awareness2, update, 'remote');
|
|
492
|
+
|
|
493
|
+
// awareness2 should have awareness1's state
|
|
494
|
+
const states = awareness2.getStates();
|
|
495
|
+
expect(states.has(awareness1.clientID)).toBe(true);
|
|
496
|
+
expect(states.get(awareness1.clientID)).toEqual({ name: 'User 1' });
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('null state removes client (disconnect)', () => {
|
|
500
|
+
const doc = createDoc();
|
|
501
|
+
const awareness = new Awareness(doc);
|
|
502
|
+
awareness.setLocalState({ name: 'User' });
|
|
503
|
+
|
|
504
|
+
expect(awareness.getStates().has(awareness.clientID)).toBe(true);
|
|
505
|
+
|
|
506
|
+
// Setting null removes the state
|
|
507
|
+
awareness.setLocalState(null);
|
|
508
|
+
|
|
509
|
+
expect(awareness.getStates().has(awareness.clientID)).toBe(false);
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// ============================================================================
|
|
515
|
+
// MESSAGE_QUERY_AWARENESS Tests
|
|
516
|
+
// ============================================================================
|
|
517
|
+
|
|
518
|
+
describe('MESSAGE_QUERY_AWARENESS', () => {
|
|
519
|
+
test('query awareness message is single byte', () => {
|
|
520
|
+
const message = encodeQueryAwareness();
|
|
521
|
+
|
|
522
|
+
expect(message.length).toBe(1);
|
|
523
|
+
expect(message[0]).toBe(MESSAGE_TYPE.QUERY_AWARENESS);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// ============================================================================
|
|
528
|
+
// Decoder Tests
|
|
529
|
+
// ============================================================================
|
|
530
|
+
|
|
531
|
+
describe('decodeSyncMessage', () => {
|
|
532
|
+
test('decodes sync step 1 message', () => {
|
|
533
|
+
const doc = createDoc((d) => d.getMap('test').set('key', 'value'));
|
|
534
|
+
const encoded = encodeSyncStep1({ doc });
|
|
535
|
+
const decoded = decodeSyncMessage(encoded);
|
|
536
|
+
|
|
537
|
+
expect(decoded.type).toBe('step1');
|
|
538
|
+
if (decoded.type === 'step1') {
|
|
539
|
+
expect(decoded.stateVector).toBeInstanceOf(Uint8Array);
|
|
540
|
+
expect(decoded.stateVector.length).toBeGreaterThan(0);
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('decodes sync step 2 message', () => {
|
|
545
|
+
const doc = createDoc((d) => d.getMap('test').set('key', 'value'));
|
|
546
|
+
const encoded = encodeSyncStep2({ doc });
|
|
547
|
+
const decoded = decodeSyncMessage(encoded);
|
|
548
|
+
|
|
549
|
+
expect(decoded.type).toBe('step2');
|
|
550
|
+
if (decoded.type === 'step2') {
|
|
551
|
+
expect(decoded.update).toBeInstanceOf(Uint8Array);
|
|
552
|
+
expect(decoded.update.length).toBeGreaterThan(0);
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test('decodes sync update message', () => {
|
|
557
|
+
const doc = createDoc();
|
|
558
|
+
let capturedUpdate: Uint8Array | null = null;
|
|
559
|
+
doc.on('updateV2', (update: Uint8Array) => {
|
|
560
|
+
capturedUpdate = update;
|
|
561
|
+
});
|
|
562
|
+
doc.getMap('test').set('key', 'value');
|
|
563
|
+
|
|
564
|
+
if (!capturedUpdate) {
|
|
565
|
+
throw new Error('Expected captured update after document mutation');
|
|
566
|
+
}
|
|
567
|
+
const encoded = encodeSyncUpdate({ update: capturedUpdate });
|
|
568
|
+
const decoded = decodeSyncMessage(encoded);
|
|
569
|
+
|
|
570
|
+
expect(decoded.type).toBe('update');
|
|
571
|
+
if (decoded.type === 'update') {
|
|
572
|
+
expect(decoded.update).toBeInstanceOf(Uint8Array);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test('throws on non-SYNC message type', () => {
|
|
577
|
+
const doc = createDoc();
|
|
578
|
+
const awareness = new Awareness(doc);
|
|
579
|
+
awareness.setLocalState({ name: 'Test' });
|
|
580
|
+
const awarenessMessage = encodeAwarenessStates({
|
|
581
|
+
awareness,
|
|
582
|
+
clients: [awareness.clientID],
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
expect(() => decodeSyncMessage(awarenessMessage)).toThrow(
|
|
586
|
+
'Expected SYNC message (0), got 1',
|
|
587
|
+
);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test('roundtrip: encode then decode preserves data', () => {
|
|
591
|
+
const doc = createDoc((d) => {
|
|
592
|
+
d.getMap('users').set('alice', { name: 'Alice', age: 30 });
|
|
593
|
+
d.getArray('items').push(['item1', 'item2']);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
// Test step 1 roundtrip
|
|
597
|
+
const step1 = encodeSyncStep1({ doc });
|
|
598
|
+
const decodedStep1 = decodeSyncMessage(step1);
|
|
599
|
+
expect(decodedStep1.type).toBe('step1');
|
|
600
|
+
|
|
601
|
+
// Test step 2 roundtrip
|
|
602
|
+
const step2 = encodeSyncStep2({ doc });
|
|
603
|
+
const decodedStep2 = decodeSyncMessage(step2);
|
|
604
|
+
expect(decodedStep2.type).toBe('step2');
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
describe('decodeMessageType', () => {
|
|
609
|
+
test('decodes SYNC message type', () => {
|
|
610
|
+
const doc = createDoc();
|
|
611
|
+
const message = encodeSyncStep1({ doc });
|
|
612
|
+
expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.SYNC);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test('decodes AWARENESS message type', () => {
|
|
616
|
+
const doc = createDoc();
|
|
617
|
+
const awareness = new Awareness(doc);
|
|
618
|
+
awareness.setLocalState({ name: 'Test' });
|
|
619
|
+
const message = encodeAwarenessStates({
|
|
620
|
+
awareness,
|
|
621
|
+
clients: [awareness.clientID],
|
|
622
|
+
});
|
|
623
|
+
expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.AWARENESS);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test('decodes QUERY_AWARENESS message type', () => {
|
|
627
|
+
const message = encodeQueryAwareness();
|
|
628
|
+
expect(decodeMessageType(message)).toBe(MESSAGE_TYPE.QUERY_AWARENESS);
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// ============================================================================
|
|
633
|
+
// Full Sync Protocol Tests
|
|
634
|
+
// ============================================================================
|
|
635
|
+
|
|
636
|
+
describe('full sync protocol', () => {
|
|
637
|
+
test('complete handshake syncs server content to client', () => {
|
|
638
|
+
const serverDoc = createDoc((d) => {
|
|
639
|
+
d.getMap('notes').set('note1', 'Hello from server');
|
|
640
|
+
});
|
|
641
|
+
const clientDoc = createDoc();
|
|
642
|
+
|
|
643
|
+
// Server handles client's state vector and responds with sync step 2 (V2 update)
|
|
644
|
+
const serverResponse = handleSyncPayload({
|
|
645
|
+
syncType: SYNC_MESSAGE_TYPE.STEP1,
|
|
646
|
+
payload: Y.encodeStateVector(clientDoc),
|
|
647
|
+
doc: serverDoc,
|
|
648
|
+
origin: 'client',
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
expect(serverResponse).not.toBeNull();
|
|
652
|
+
if (!serverResponse) {
|
|
653
|
+
throw new Error('Expected server sync response during handshake');
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Client applies server's V2 response
|
|
657
|
+
const decoded = decodeSyncMessage(serverResponse);
|
|
658
|
+
expect(decoded.type).toBe('step2');
|
|
659
|
+
if (decoded.type === 'step2') {
|
|
660
|
+
Y.applyUpdateV2(clientDoc, decoded.update, 'server');
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Client should have server's content
|
|
664
|
+
expect(clientDoc.getMap('notes').get('note1')).toBe('Hello from server');
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test('bidirectional sync merges both documents', () => {
|
|
668
|
+
const doc1 = createDoc((d) => d.getMap('data').set('from1', 'value1'));
|
|
669
|
+
const doc2 = createDoc((d) => d.getMap('data').set('from2', 'value2'));
|
|
670
|
+
|
|
671
|
+
// Full bidirectional sync using Yjs V2 pattern
|
|
672
|
+
syncDocs(doc1, doc2);
|
|
673
|
+
|
|
674
|
+
expect(doc1.getMap('data').get('from1')).toBe('value1');
|
|
675
|
+
expect(doc1.getMap('data').get('from2')).toBe('value2');
|
|
676
|
+
expect(doc2.getMap('data').get('from1')).toBe('value1');
|
|
677
|
+
expect(doc2.getMap('data').get('from2')).toBe('value2');
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test('incremental updates are applied correctly', () => {
|
|
681
|
+
const doc1 = createDoc();
|
|
682
|
+
const doc2 = createDoc();
|
|
683
|
+
|
|
684
|
+
// Capture V2 updates from doc1
|
|
685
|
+
const updates: Uint8Array[] = [];
|
|
686
|
+
doc1.on('updateV2', (update: Uint8Array) => {
|
|
687
|
+
updates.push(update);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
// Make changes
|
|
691
|
+
doc1.getMap('data').set('key1', 'value1');
|
|
692
|
+
doc1.getMap('data').set('key2', 'value2');
|
|
693
|
+
doc1.getArray('list').push(['item1', 'item2']);
|
|
694
|
+
|
|
695
|
+
// Apply V2 updates to doc2
|
|
696
|
+
for (const update of updates) {
|
|
697
|
+
Y.applyUpdateV2(doc2, update);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
expect(doc2.getMap('data').get('key1')).toBe('value1');
|
|
701
|
+
expect(doc2.getMap('data').get('key2')).toBe('value2');
|
|
702
|
+
expect(doc2.getArray('list').toArray()).toEqual(['item1', 'item2']);
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
// ============================================================================
|
|
707
|
+
// Edge Cases
|
|
708
|
+
// ============================================================================
|
|
709
|
+
|
|
710
|
+
describe('edge cases', () => {
|
|
711
|
+
test('handles large document (1000+ operations)', () => {
|
|
712
|
+
const doc = createDoc((d) => {
|
|
713
|
+
const arr = d.getArray<string>('items');
|
|
714
|
+
for (let i = 0; i < 1000; i++) {
|
|
715
|
+
arr.push([`item-${i}`]);
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
// Sync step 1 contains state vector (compact), not full content
|
|
720
|
+
const syncStep1 = encodeSyncStep1({ doc });
|
|
721
|
+
expect(decodeSyncMessage(syncStep1).type).toBe('step1');
|
|
722
|
+
|
|
723
|
+
// Sync step 2 contains actual document content
|
|
724
|
+
const syncStep2 = encodeSyncStep2({ doc });
|
|
725
|
+
expect(decodeSyncMessage(syncStep2).type).toBe('step2');
|
|
726
|
+
expect(syncStep2.length).toBeGreaterThan(1000);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test('handles concurrent modifications (CRDT merge)', () => {
|
|
730
|
+
const doc1 = createDoc();
|
|
731
|
+
const doc2 = createDoc();
|
|
732
|
+
|
|
733
|
+
// Both modify same key concurrently
|
|
734
|
+
doc1.getMap('data').set('key', 'value1');
|
|
735
|
+
doc2.getMap('data').set('key', 'value2');
|
|
736
|
+
|
|
737
|
+
// Sync should resolve deterministically
|
|
738
|
+
syncDocs(doc1, doc2);
|
|
739
|
+
|
|
740
|
+
// Both should have same value (CRDT resolution)
|
|
741
|
+
const val1 = doc1.getMap('data').get('key');
|
|
742
|
+
const val2 = doc2.getMap('data').get('key');
|
|
743
|
+
expect(val1).toBe(val2);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
test('empty document produces valid sync step 1', () => {
|
|
747
|
+
const doc = createDoc();
|
|
748
|
+
const message = encodeSyncStep1({ doc });
|
|
749
|
+
const decoded = decodeSyncMessage(message);
|
|
750
|
+
|
|
751
|
+
expect(decoded.type).toBe('step1');
|
|
752
|
+
if (decoded.type === 'step1') {
|
|
753
|
+
// Even empty docs have a state vector (contains clientID info)
|
|
754
|
+
expect(decoded.stateVector).toBeInstanceOf(Uint8Array);
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
// ============================================================================
|
|
760
|
+
// Test Utilities (hoisted - placed at bottom for readability)
|
|
761
|
+
// ============================================================================
|
|
762
|
+
|
|
763
|
+
/** Create a Y.Doc with optional initial content */
|
|
764
|
+
function createDoc(init?: (doc: Y.Doc) => void): Y.Doc {
|
|
765
|
+
const doc = new Y.Doc();
|
|
766
|
+
if (init) init(doc);
|
|
767
|
+
return doc;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** Sync two documents bidirectionally (standard Yjs test pattern, V2) */
|
|
771
|
+
function syncDocs(doc1: Y.Doc, doc2: Y.Doc): void {
|
|
772
|
+
const state1 = Y.encodeStateAsUpdateV2(doc1);
|
|
773
|
+
const state2 = Y.encodeStateAsUpdateV2(doc2);
|
|
774
|
+
Y.applyUpdateV2(doc1, state2);
|
|
775
|
+
Y.applyUpdateV2(doc2, state1);
|
|
776
|
+
}
|