@voidhash/mimic 0.0.4 → 0.0.6
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/.turbo/turbo-build.log +202 -198
- package/dist/Document.cjs +1 -2
- package/dist/Document.d.cts +9 -3
- package/dist/Document.d.cts.map +1 -1
- package/dist/Document.d.mts +9 -3
- package/dist/Document.d.mts.map +1 -1
- package/dist/Document.mjs +1 -2
- package/dist/Document.mjs.map +1 -1
- package/dist/Presence.d.mts.map +1 -1
- package/dist/Primitive.d.cts +2 -2
- package/dist/Primitive.d.mts +2 -2
- package/dist/_virtual/_@oxc-project_runtime@0.103.0/helpers/objectWithoutProperties.cjs +15 -0
- package/dist/_virtual/_@oxc-project_runtime@0.103.0/helpers/objectWithoutProperties.mjs +15 -0
- package/dist/_virtual/_@oxc-project_runtime@0.103.0/helpers/objectWithoutPropertiesLoose.cjs +14 -0
- package/dist/_virtual/_@oxc-project_runtime@0.103.0/helpers/objectWithoutPropertiesLoose.mjs +13 -0
- package/dist/client/ClientDocument.cjs +17 -12
- package/dist/client/ClientDocument.d.mts.map +1 -1
- package/dist/client/ClientDocument.mjs +17 -12
- package/dist/client/ClientDocument.mjs.map +1 -1
- package/dist/client/WebSocketTransport.cjs +6 -6
- package/dist/client/WebSocketTransport.mjs +6 -6
- package/dist/client/WebSocketTransport.mjs.map +1 -1
- package/dist/primitives/Tree.cjs +58 -8
- package/dist/primitives/Tree.d.cts +99 -10
- package/dist/primitives/Tree.d.cts.map +1 -1
- package/dist/primitives/Tree.d.mts +99 -10
- package/dist/primitives/Tree.d.mts.map +1 -1
- package/dist/primitives/Tree.mjs +58 -8
- package/dist/primitives/Tree.mjs.map +1 -1
- package/dist/primitives/shared.d.cts +9 -0
- package/dist/primitives/shared.d.cts.map +1 -1
- package/dist/primitives/shared.d.mts +9 -0
- package/dist/primitives/shared.d.mts.map +1 -1
- package/dist/primitives/shared.mjs.map +1 -1
- package/dist/server/ServerDocument.cjs +1 -1
- package/dist/server/ServerDocument.d.cts +3 -3
- package/dist/server/ServerDocument.d.cts.map +1 -1
- package/dist/server/ServerDocument.d.mts +3 -3
- package/dist/server/ServerDocument.d.mts.map +1 -1
- package/dist/server/ServerDocument.mjs +1 -1
- package/dist/server/ServerDocument.mjs.map +1 -1
- package/package.json +2 -2
- package/src/Document.ts +18 -5
- package/src/client/ClientDocument.ts +20 -21
- package/src/client/WebSocketTransport.ts +9 -9
- package/src/primitives/Tree.ts +213 -19
- package/src/primitives/shared.ts +10 -1
- package/src/server/ServerDocument.ts +4 -3
- package/tests/client/ClientDocument.test.ts +309 -2
- package/tests/client/WebSocketTransport.test.ts +228 -3
- package/tests/primitives/Tree.test.ts +296 -17
- package/tests/server/ServerDocument.test.ts +1 -1
- package/tsconfig.json +1 -1
|
@@ -201,18 +201,325 @@ describe("ClientDocument", () => {
|
|
|
201
201
|
expect(transport.sentTransactions[0]!.ops[0]!.kind).toBe("number.set");
|
|
202
202
|
});
|
|
203
203
|
|
|
204
|
-
it("should
|
|
204
|
+
it("should queue transactions when not connected", () => {
|
|
205
205
|
const client = ClientDocument.make({
|
|
206
206
|
schema: TestSchema,
|
|
207
207
|
transport,
|
|
208
208
|
initialState: { title: "", count: 0, items: [] },
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
+
// Transactions should work offline - they get queued in the transport
|
|
212
|
+
client.transaction((root) => {
|
|
213
|
+
root.title.set("Test");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// State should be optimistically updated
|
|
217
|
+
expect(client.get()?.title).toBe("Test");
|
|
218
|
+
// Transaction is pending
|
|
219
|
+
expect(client.hasPendingChanges()).toBe(true);
|
|
220
|
+
// Transaction was sent to transport (it will queue it)
|
|
221
|
+
expect(transport.sentTransactions.length).toBe(1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("should queue multiple transactions when not connected", () => {
|
|
225
|
+
const client = ClientDocument.make({
|
|
226
|
+
schema: TestSchema,
|
|
227
|
+
transport,
|
|
228
|
+
initialState: { title: "", count: 0, items: [] },
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Create multiple transactions while offline
|
|
232
|
+
client.transaction((root) => {
|
|
233
|
+
root.title.set("First");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
client.transaction((root) => {
|
|
237
|
+
root.count.set(10);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
client.transaction((root) => {
|
|
241
|
+
root.title.set("Second");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// All state changes should be applied optimistically
|
|
245
|
+
expect(client.get()?.title).toBe("Second");
|
|
246
|
+
expect(client.get()?.count).toBe(10);
|
|
247
|
+
// All transactions are pending
|
|
248
|
+
expect(client.getPendingCount()).toBe(3);
|
|
249
|
+
// All transactions were sent to transport
|
|
250
|
+
expect(transport.sentTransactions.length).toBe(3);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("should throw when not ready (no initial state)", () => {
|
|
254
|
+
const client = ClientDocument.make({
|
|
255
|
+
schema: TestSchema,
|
|
256
|
+
transport,
|
|
257
|
+
// No initial state - client is not ready until snapshot received
|
|
258
|
+
});
|
|
259
|
+
|
|
211
260
|
expect(() => {
|
|
212
261
|
client.transaction((root) => {
|
|
213
262
|
root.title.set("Test");
|
|
214
263
|
});
|
|
215
|
-
}).toThrow("
|
|
264
|
+
}).toThrow("Client is not ready");
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("offline transaction handling", () => {
|
|
269
|
+
it("should confirm queued transactions after reconnection", async () => {
|
|
270
|
+
const client = ClientDocument.make({
|
|
271
|
+
schema: TestSchema,
|
|
272
|
+
transport,
|
|
273
|
+
initialState: { title: "", count: 0, items: [] },
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Create transaction while offline
|
|
277
|
+
client.transaction((root) => {
|
|
278
|
+
root.title.set("Offline Change");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
expect(client.hasPendingChanges()).toBe(true);
|
|
282
|
+
const pendingTx = transport.sentTransactions[0]!;
|
|
283
|
+
|
|
284
|
+
// Connect and simulate server confirming the transaction
|
|
285
|
+
await client.connect();
|
|
286
|
+
|
|
287
|
+
// Server broadcasts our transaction (confirming it)
|
|
288
|
+
transport.simulateServerMessage({
|
|
289
|
+
type: "transaction",
|
|
290
|
+
transaction: pendingTx,
|
|
291
|
+
version: 1,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Transaction should be confirmed
|
|
295
|
+
expect(client.hasPendingChanges()).toBe(false);
|
|
296
|
+
expect(client.get()?.title).toBe("Offline Change");
|
|
297
|
+
expect(client.getServerState()?.title).toBe("Offline Change");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("should handle multiple queued transactions being confirmed in order", async () => {
|
|
301
|
+
const client = ClientDocument.make({
|
|
302
|
+
schema: TestSchema,
|
|
303
|
+
transport,
|
|
304
|
+
initialState: { title: "", count: 0, items: [] },
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Create multiple transactions while offline
|
|
308
|
+
client.transaction((root) => {
|
|
309
|
+
root.title.set("First");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
client.transaction((root) => {
|
|
313
|
+
root.count.set(42);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
expect(client.getPendingCount()).toBe(2);
|
|
317
|
+
const tx1 = transport.sentTransactions[0]!;
|
|
318
|
+
const tx2 = transport.sentTransactions[1]!;
|
|
319
|
+
|
|
320
|
+
// Connect
|
|
321
|
+
await client.connect();
|
|
322
|
+
|
|
323
|
+
// Server confirms first transaction
|
|
324
|
+
transport.simulateServerMessage({
|
|
325
|
+
type: "transaction",
|
|
326
|
+
transaction: tx1,
|
|
327
|
+
version: 1,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(client.getPendingCount()).toBe(1);
|
|
331
|
+
expect(client.getServerState()?.title).toBe("First");
|
|
332
|
+
|
|
333
|
+
// Server confirms second transaction
|
|
334
|
+
transport.simulateServerMessage({
|
|
335
|
+
type: "transaction",
|
|
336
|
+
transaction: tx2,
|
|
337
|
+
version: 2,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
expect(client.getPendingCount()).toBe(0);
|
|
341
|
+
expect(client.getServerState()?.count).toBe(42);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("should handle rejection of queued transactions", async () => {
|
|
345
|
+
let rejectedTx: Transaction.Transaction | null = null;
|
|
346
|
+
let rejectionReason: string | null = null;
|
|
347
|
+
|
|
348
|
+
const client = ClientDocument.make({
|
|
349
|
+
schema: TestSchema,
|
|
350
|
+
transport,
|
|
351
|
+
initialState: { title: "Original", count: 0, items: [] },
|
|
352
|
+
onRejection: (tx, reason) => {
|
|
353
|
+
rejectedTx = tx;
|
|
354
|
+
rejectionReason = reason;
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Create transaction while offline
|
|
359
|
+
client.transaction((root) => {
|
|
360
|
+
root.title.set("Offline Change");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const pendingTx = transport.sentTransactions[0]!;
|
|
364
|
+
|
|
365
|
+
// Connect
|
|
366
|
+
await client.connect();
|
|
367
|
+
|
|
368
|
+
// Server rejects the transaction
|
|
369
|
+
transport.simulateServerMessage({
|
|
370
|
+
type: "error",
|
|
371
|
+
transactionId: pendingTx.id,
|
|
372
|
+
reason: "Conflict with another user",
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// Transaction should be removed from pending
|
|
376
|
+
expect(client.hasPendingChanges()).toBe(false);
|
|
377
|
+
// Optimistic state should revert to server state
|
|
378
|
+
expect(client.get()?.title).toBe("Original");
|
|
379
|
+
// Rejection callback should be called
|
|
380
|
+
expect(rejectedTx).not.toBeNull();
|
|
381
|
+
expect(rejectionReason).toBe("Conflict with another user");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("should rebase queued transactions against concurrent server changes", async () => {
|
|
385
|
+
const client = ClientDocument.make({
|
|
386
|
+
schema: TestSchema,
|
|
387
|
+
transport,
|
|
388
|
+
initialState: { title: "", count: 0, items: [] },
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Create transaction while offline
|
|
392
|
+
client.transaction((root) => {
|
|
393
|
+
root.title.set("My Title");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
await client.connect();
|
|
397
|
+
|
|
398
|
+
// Another user's change comes in first
|
|
399
|
+
const otherUserTx = Transaction.make([
|
|
400
|
+
{
|
|
401
|
+
kind: "number.set" as const,
|
|
402
|
+
path: OperationPath.make("count"),
|
|
403
|
+
payload: 100,
|
|
404
|
+
},
|
|
405
|
+
]);
|
|
406
|
+
|
|
407
|
+
transport.simulateServerMessage({
|
|
408
|
+
type: "transaction",
|
|
409
|
+
transaction: otherUserTx,
|
|
410
|
+
version: 1,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// Our transaction should still be pending
|
|
414
|
+
expect(client.hasPendingChanges()).toBe(true);
|
|
415
|
+
// Server state should reflect other user's change
|
|
416
|
+
expect(client.getServerState()?.count).toBe(100);
|
|
417
|
+
// Optimistic state should have both changes
|
|
418
|
+
expect(client.get()?.count).toBe(100);
|
|
419
|
+
expect(client.get()?.title).toBe("My Title");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("should work with disconnect and reconnect cycle", async () => {
|
|
423
|
+
const client = ClientDocument.make({
|
|
424
|
+
schema: TestSchema,
|
|
425
|
+
transport,
|
|
426
|
+
initialState: { title: "", count: 0, items: [] },
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// Connect first
|
|
430
|
+
await client.connect();
|
|
431
|
+
|
|
432
|
+
client.transaction((root) => {
|
|
433
|
+
root.title.set("Online Change");
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Disconnect
|
|
437
|
+
client.disconnect();
|
|
438
|
+
|
|
439
|
+
// Create transaction while disconnected
|
|
440
|
+
client.transaction((root) => {
|
|
441
|
+
root.count.set(50);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// State should still be optimistically updated
|
|
445
|
+
expect(client.get()?.title).toBe("Online Change");
|
|
446
|
+
expect(client.get()?.count).toBe(50);
|
|
447
|
+
expect(client.getPendingCount()).toBe(2);
|
|
448
|
+
|
|
449
|
+
// Reconnect
|
|
450
|
+
await client.connect();
|
|
451
|
+
|
|
452
|
+
// Server confirms both transactions
|
|
453
|
+
transport.simulateServerMessage({
|
|
454
|
+
type: "transaction",
|
|
455
|
+
transaction: transport.sentTransactions[0]!,
|
|
456
|
+
version: 1,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
transport.simulateServerMessage({
|
|
460
|
+
type: "transaction",
|
|
461
|
+
transaction: transport.sentTransactions[1]!,
|
|
462
|
+
version: 2,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
expect(client.hasPendingChanges()).toBe(false);
|
|
466
|
+
expect(client.getServerState()?.title).toBe("Online Change");
|
|
467
|
+
expect(client.getServerState()?.count).toBe(50);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("should preserve pending transactions during brief disconnection", async () => {
|
|
471
|
+
const client = ClientDocument.make({
|
|
472
|
+
schema: TestSchema,
|
|
473
|
+
transport,
|
|
474
|
+
initialState: { title: "", count: 0, items: [] },
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
await client.connect();
|
|
478
|
+
|
|
479
|
+
// Create a transaction
|
|
480
|
+
client.transaction((root) => {
|
|
481
|
+
root.title.set("Before Disconnect");
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const pendingCount = client.getPendingCount();
|
|
485
|
+
expect(pendingCount).toBe(1);
|
|
486
|
+
|
|
487
|
+
// Simulate brief disconnection
|
|
488
|
+
client.disconnect();
|
|
489
|
+
|
|
490
|
+
// Pending transactions should still be there
|
|
491
|
+
expect(client.getPendingCount()).toBe(pendingCount);
|
|
492
|
+
expect(client.get()?.title).toBe("Before Disconnect");
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("should handle multiple field changes while offline", () => {
|
|
496
|
+
const client = ClientDocument.make({
|
|
497
|
+
schema: TestSchema,
|
|
498
|
+
transport,
|
|
499
|
+
initialState: { title: "", count: 0, items: [] },
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// Create multiple transactions affecting different fields while offline
|
|
503
|
+
client.transaction((root) => {
|
|
504
|
+
root.title.set("First Title");
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
client.transaction((root) => {
|
|
508
|
+
root.count.set(10);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
client.transaction((root) => {
|
|
512
|
+
root.title.set("Final Title");
|
|
513
|
+
root.count.set(20);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// All changes should be applied optimistically
|
|
517
|
+
expect(client.get()?.title).toBe("Final Title");
|
|
518
|
+
expect(client.get()?.count).toBe(20);
|
|
519
|
+
|
|
520
|
+
// All transactions queued
|
|
521
|
+
expect(transport.sentTransactions.length).toBe(3);
|
|
522
|
+
expect(client.getPendingCount()).toBe(3);
|
|
216
523
|
});
|
|
217
524
|
});
|
|
218
525
|
|
|
@@ -349,6 +349,196 @@ describe("WebSocketTransport", () => {
|
|
|
349
349
|
const sent = JSON.parse(newWs.sentMessages[1]!);
|
|
350
350
|
expect(sent.type).toBe("submit");
|
|
351
351
|
});
|
|
352
|
+
|
|
353
|
+
it("should queue messages when disconnected (never connected)", () => {
|
|
354
|
+
const transport = WebSocketTransport.make({
|
|
355
|
+
url: "ws://localhost:8080",
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Queue message before connecting
|
|
359
|
+
const tx = Transaction.make([
|
|
360
|
+
{
|
|
361
|
+
kind: "string.set" as const,
|
|
362
|
+
path: OperationPath.make("title"),
|
|
363
|
+
payload: "offline",
|
|
364
|
+
},
|
|
365
|
+
]);
|
|
366
|
+
transport.send(tx);
|
|
367
|
+
|
|
368
|
+
// No WebSocket created yet
|
|
369
|
+
expect(MockWebSocket.instances.length).toBe(0);
|
|
370
|
+
|
|
371
|
+
// Message should be queued internally
|
|
372
|
+
// (We can't directly verify the queue, but we can verify it's sent after connect)
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("should send queued messages after initial connection", async () => {
|
|
376
|
+
const transport = WebSocketTransport.make({
|
|
377
|
+
url: "ws://localhost:8080",
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Queue messages before connecting
|
|
381
|
+
const tx1 = Transaction.make([
|
|
382
|
+
{
|
|
383
|
+
kind: "string.set" as const,
|
|
384
|
+
path: OperationPath.make("title"),
|
|
385
|
+
payload: "first",
|
|
386
|
+
},
|
|
387
|
+
]);
|
|
388
|
+
const tx2 = Transaction.make([
|
|
389
|
+
{
|
|
390
|
+
kind: "number.set" as const,
|
|
391
|
+
path: OperationPath.make("count"),
|
|
392
|
+
payload: 42,
|
|
393
|
+
},
|
|
394
|
+
]);
|
|
395
|
+
transport.send(tx1);
|
|
396
|
+
transport.send(tx2);
|
|
397
|
+
|
|
398
|
+
// Now connect
|
|
399
|
+
const connectPromise = transport.connect();
|
|
400
|
+
const ws = MockWebSocket.getLatest()!;
|
|
401
|
+
await ws.simulateOpenWithAuth();
|
|
402
|
+
await connectPromise;
|
|
403
|
+
|
|
404
|
+
// Should have sent: auth + 2 queued transactions
|
|
405
|
+
expect(ws.sentMessages.length).toBe(3);
|
|
406
|
+
|
|
407
|
+
const authMsg = JSON.parse(ws.sentMessages[0]!);
|
|
408
|
+
expect(authMsg.type).toBe("auth");
|
|
409
|
+
|
|
410
|
+
const sent1 = JSON.parse(ws.sentMessages[1]!);
|
|
411
|
+
expect(sent1.type).toBe("submit");
|
|
412
|
+
expect(sent1.transaction.id).toBe(tx1.id);
|
|
413
|
+
|
|
414
|
+
const sent2 = JSON.parse(ws.sentMessages[2]!);
|
|
415
|
+
expect(sent2.type).toBe("submit");
|
|
416
|
+
expect(sent2.transaction.id).toBe(tx2.id);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("should queue multiple messages during disconnection", async () => {
|
|
420
|
+
const transport = WebSocketTransport.make({
|
|
421
|
+
url: "ws://localhost:8080",
|
|
422
|
+
autoReconnect: true,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const connectPromise = transport.connect();
|
|
426
|
+
const ws = MockWebSocket.getLatest()!;
|
|
427
|
+
await ws.simulateOpenWithAuth();
|
|
428
|
+
await connectPromise;
|
|
429
|
+
|
|
430
|
+
// Simulate connection lost
|
|
431
|
+
ws.simulateClose(1006, "Connection lost");
|
|
432
|
+
|
|
433
|
+
// Queue multiple messages during disconnection
|
|
434
|
+
const tx1 = Transaction.make([
|
|
435
|
+
{
|
|
436
|
+
kind: "string.set" as const,
|
|
437
|
+
path: OperationPath.make("title"),
|
|
438
|
+
payload: "change1",
|
|
439
|
+
},
|
|
440
|
+
]);
|
|
441
|
+
const tx2 = Transaction.make([
|
|
442
|
+
{
|
|
443
|
+
kind: "string.set" as const,
|
|
444
|
+
path: OperationPath.make("title"),
|
|
445
|
+
payload: "change2",
|
|
446
|
+
},
|
|
447
|
+
]);
|
|
448
|
+
const tx3 = Transaction.make([
|
|
449
|
+
{
|
|
450
|
+
kind: "number.set" as const,
|
|
451
|
+
path: OperationPath.make("count"),
|
|
452
|
+
payload: 100,
|
|
453
|
+
},
|
|
454
|
+
]);
|
|
455
|
+
|
|
456
|
+
transport.send(tx1);
|
|
457
|
+
transport.send(tx2);
|
|
458
|
+
transport.send(tx3);
|
|
459
|
+
|
|
460
|
+
// Reconnect
|
|
461
|
+
vi.advanceTimersByTime(1000);
|
|
462
|
+
const newWs = MockWebSocket.getLatest()!;
|
|
463
|
+
await newWs.simulateOpenWithAuth();
|
|
464
|
+
|
|
465
|
+
// Should have sent: auth + 3 queued transactions
|
|
466
|
+
expect(newWs.sentMessages.length).toBe(4);
|
|
467
|
+
|
|
468
|
+
const authMsg = JSON.parse(newWs.sentMessages[0]!);
|
|
469
|
+
expect(authMsg.type).toBe("auth");
|
|
470
|
+
|
|
471
|
+
// Verify all transactions were sent in order
|
|
472
|
+
const sent1 = JSON.parse(newWs.sentMessages[1]!);
|
|
473
|
+
expect(sent1.transaction.id).toBe(tx1.id);
|
|
474
|
+
|
|
475
|
+
const sent2 = JSON.parse(newWs.sentMessages[2]!);
|
|
476
|
+
expect(sent2.transaction.id).toBe(tx2.id);
|
|
477
|
+
|
|
478
|
+
const sent3 = JSON.parse(newWs.sentMessages[3]!);
|
|
479
|
+
expect(sent3.transaction.id).toBe(tx3.id);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("should preserve queue across multiple reconnection attempts", async () => {
|
|
483
|
+
const transport = WebSocketTransport.make({
|
|
484
|
+
url: "ws://localhost:8080",
|
|
485
|
+
autoReconnect: true,
|
|
486
|
+
reconnectDelay: 100,
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
const connectPromise = transport.connect();
|
|
490
|
+
const ws = MockWebSocket.getLatest()!;
|
|
491
|
+
await ws.simulateOpenWithAuth();
|
|
492
|
+
await connectPromise;
|
|
493
|
+
|
|
494
|
+
// Simulate connection lost
|
|
495
|
+
ws.simulateClose(1006, "Connection lost");
|
|
496
|
+
|
|
497
|
+
// Queue message
|
|
498
|
+
const tx = Transaction.make([
|
|
499
|
+
{
|
|
500
|
+
kind: "string.set" as const,
|
|
501
|
+
path: OperationPath.make("title"),
|
|
502
|
+
payload: "important",
|
|
503
|
+
},
|
|
504
|
+
]);
|
|
505
|
+
transport.send(tx);
|
|
506
|
+
|
|
507
|
+
// First reconnection attempt fails
|
|
508
|
+
vi.advanceTimersByTime(100);
|
|
509
|
+
MockWebSocket.getLatest()!.simulateClose(1006, "Failed again");
|
|
510
|
+
|
|
511
|
+
// Second reconnection attempt succeeds
|
|
512
|
+
vi.advanceTimersByTime(200);
|
|
513
|
+
const finalWs = MockWebSocket.getLatest()!;
|
|
514
|
+
await finalWs.simulateOpenWithAuth();
|
|
515
|
+
|
|
516
|
+
// Queued message should still be sent
|
|
517
|
+
expect(finalWs.sentMessages.length).toBe(2);
|
|
518
|
+
const sent = JSON.parse(finalWs.sentMessages[1]!);
|
|
519
|
+
expect(sent.type).toBe("submit");
|
|
520
|
+
expect(sent.transaction.id).toBe(tx.id);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("should queue snapshot requests when disconnected", async () => {
|
|
524
|
+
const transport = WebSocketTransport.make({
|
|
525
|
+
url: "ws://localhost:8080",
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// Queue snapshot request before connecting
|
|
529
|
+
transport.requestSnapshot();
|
|
530
|
+
|
|
531
|
+
// Connect
|
|
532
|
+
const connectPromise = transport.connect();
|
|
533
|
+
const ws = MockWebSocket.getLatest()!;
|
|
534
|
+
await ws.simulateOpenWithAuth();
|
|
535
|
+
await connectPromise;
|
|
536
|
+
|
|
537
|
+
// Should have sent: auth + request_snapshot
|
|
538
|
+
expect(ws.sentMessages.length).toBe(2);
|
|
539
|
+
const sent = JSON.parse(ws.sentMessages[1]!);
|
|
540
|
+
expect(sent.type).toBe("request_snapshot");
|
|
541
|
+
});
|
|
352
542
|
});
|
|
353
543
|
|
|
354
544
|
describe("requestSnapshot", () => {
|
|
@@ -811,16 +1001,51 @@ describe("WebSocketTransport", () => {
|
|
|
811
1001
|
expect(sent.data).toEqual({ cursor: { x: 50, y: 75 } });
|
|
812
1002
|
});
|
|
813
1003
|
|
|
814
|
-
it("should
|
|
1004
|
+
it("should queue presence_set when disconnected and send after connect", async () => {
|
|
815
1005
|
const transport = WebSocketTransport.make({
|
|
816
1006
|
url: "ws://localhost:8080",
|
|
817
1007
|
});
|
|
818
1008
|
|
|
819
|
-
//
|
|
1009
|
+
// Queue presence before connecting
|
|
820
1010
|
transport.sendPresenceSet({ x: 100, y: 200 });
|
|
821
1011
|
|
|
822
|
-
// No WebSocket created
|
|
1012
|
+
// No WebSocket created yet
|
|
823
1013
|
expect(MockWebSocket.instances.length).toBe(0);
|
|
1014
|
+
|
|
1015
|
+
// Now connect
|
|
1016
|
+
const connectPromise = transport.connect();
|
|
1017
|
+
const ws = MockWebSocket.getLatest()!;
|
|
1018
|
+
await ws.simulateOpenWithAuth();
|
|
1019
|
+
await connectPromise;
|
|
1020
|
+
|
|
1021
|
+
// Should have sent: auth + presence_set
|
|
1022
|
+
expect(ws.sentMessages.length).toBe(2);
|
|
1023
|
+
const sent = JSON.parse(ws.sentMessages[1]!);
|
|
1024
|
+
expect(sent.type).toBe("presence_set");
|
|
1025
|
+
expect(sent.data).toEqual({ x: 100, y: 200 });
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it("should only keep latest presence_set when multiple are queued", async () => {
|
|
1029
|
+
const transport = WebSocketTransport.make({
|
|
1030
|
+
url: "ws://localhost:8080",
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
// Queue multiple presence updates before connecting
|
|
1034
|
+
transport.sendPresenceSet({ x: 100, y: 200 });
|
|
1035
|
+
transport.sendPresenceSet({ x: 150, y: 250 });
|
|
1036
|
+
transport.sendPresenceSet({ x: 200, y: 300 });
|
|
1037
|
+
|
|
1038
|
+
// Now connect
|
|
1039
|
+
const connectPromise = transport.connect();
|
|
1040
|
+
const ws = MockWebSocket.getLatest()!;
|
|
1041
|
+
await ws.simulateOpenWithAuth();
|
|
1042
|
+
await connectPromise;
|
|
1043
|
+
|
|
1044
|
+
// Should have sent: auth + only the latest presence_set
|
|
1045
|
+
expect(ws.sentMessages.length).toBe(2);
|
|
1046
|
+
const sent = JSON.parse(ws.sentMessages[1]!);
|
|
1047
|
+
expect(sent.type).toBe("presence_set");
|
|
1048
|
+
expect(sent.data).toEqual({ x: 200, y: 300 });
|
|
824
1049
|
});
|
|
825
1050
|
});
|
|
826
1051
|
|