@voidhash/mimic 0.0.1-alpha.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.
Files changed (57) hide show
  1. package/README.md +17 -0
  2. package/package.json +33 -0
  3. package/src/Document.ts +256 -0
  4. package/src/FractionalIndex.ts +1249 -0
  5. package/src/Operation.ts +59 -0
  6. package/src/OperationDefinition.ts +23 -0
  7. package/src/OperationPath.ts +197 -0
  8. package/src/Presence.ts +142 -0
  9. package/src/Primitive.ts +32 -0
  10. package/src/Proxy.ts +8 -0
  11. package/src/ProxyEnvironment.ts +52 -0
  12. package/src/Transaction.ts +72 -0
  13. package/src/Transform.ts +13 -0
  14. package/src/client/ClientDocument.ts +1163 -0
  15. package/src/client/Rebase.ts +309 -0
  16. package/src/client/StateMonitor.ts +307 -0
  17. package/src/client/Transport.ts +318 -0
  18. package/src/client/WebSocketTransport.ts +572 -0
  19. package/src/client/errors.ts +145 -0
  20. package/src/client/index.ts +61 -0
  21. package/src/index.ts +12 -0
  22. package/src/primitives/Array.ts +457 -0
  23. package/src/primitives/Boolean.ts +128 -0
  24. package/src/primitives/Lazy.ts +89 -0
  25. package/src/primitives/Literal.ts +128 -0
  26. package/src/primitives/Number.ts +169 -0
  27. package/src/primitives/String.ts +189 -0
  28. package/src/primitives/Struct.ts +348 -0
  29. package/src/primitives/Tree.ts +1120 -0
  30. package/src/primitives/TreeNode.ts +113 -0
  31. package/src/primitives/Union.ts +329 -0
  32. package/src/primitives/shared.ts +122 -0
  33. package/src/server/ServerDocument.ts +267 -0
  34. package/src/server/errors.ts +90 -0
  35. package/src/server/index.ts +40 -0
  36. package/tests/Document.test.ts +556 -0
  37. package/tests/FractionalIndex.test.ts +377 -0
  38. package/tests/OperationPath.test.ts +151 -0
  39. package/tests/Presence.test.ts +321 -0
  40. package/tests/Primitive.test.ts +381 -0
  41. package/tests/client/ClientDocument.test.ts +1398 -0
  42. package/tests/client/WebSocketTransport.test.ts +992 -0
  43. package/tests/primitives/Array.test.ts +418 -0
  44. package/tests/primitives/Boolean.test.ts +126 -0
  45. package/tests/primitives/Lazy.test.ts +143 -0
  46. package/tests/primitives/Literal.test.ts +122 -0
  47. package/tests/primitives/Number.test.ts +133 -0
  48. package/tests/primitives/String.test.ts +128 -0
  49. package/tests/primitives/Struct.test.ts +311 -0
  50. package/tests/primitives/Tree.test.ts +467 -0
  51. package/tests/primitives/TreeNode.test.ts +50 -0
  52. package/tests/primitives/Union.test.ts +210 -0
  53. package/tests/server/ServerDocument.test.ts +528 -0
  54. package/tsconfig.build.json +24 -0
  55. package/tsconfig.json +8 -0
  56. package/tsdown.config.ts +18 -0
  57. package/vitest.mts +11 -0
@@ -0,0 +1,992 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import * as Transaction from "../../src/Transaction";
3
+ import * as OperationPath from "../../src/OperationPath";
4
+ import * as WebSocketTransport from "../../src/client/WebSocketTransport";
5
+ import type * as Transport from "../../src/client/Transport";
6
+
7
+ // =============================================================================
8
+ // Mock CloseEvent (not available in Node.js)
9
+ // =============================================================================
10
+
11
+ class MockCloseEvent extends Event {
12
+ readonly code: number;
13
+ readonly reason: string;
14
+ readonly wasClean: boolean;
15
+
16
+ constructor(type: string, init?: { code?: number; reason?: string; wasClean?: boolean }) {
17
+ super(type);
18
+ this.code = init?.code ?? 1000;
19
+ this.reason = init?.reason ?? "";
20
+ this.wasClean = init?.wasClean ?? true;
21
+ }
22
+ }
23
+
24
+ // =============================================================================
25
+ // Mock WebSocket
26
+ // =============================================================================
27
+
28
+ type MockWebSocketEventHandler = ((event: Event) => void) | null;
29
+ type MockMessageHandler = ((event: MessageEvent) => void) | null;
30
+ type MockCloseHandler = ((event: MockCloseEvent) => void) | null;
31
+
32
+ class MockWebSocket {
33
+ static readonly CONNECTING = 0;
34
+ static readonly OPEN = 1;
35
+ static readonly CLOSING = 2;
36
+ static readonly CLOSED = 3;
37
+
38
+ readonly CONNECTING = 0;
39
+ readonly OPEN = 1;
40
+ readonly CLOSING = 2;
41
+ readonly CLOSED = 3;
42
+
43
+ url: string;
44
+ protocols: string | string[] | undefined;
45
+ readyState: number = MockWebSocket.CONNECTING;
46
+
47
+ onopen: MockWebSocketEventHandler = null;
48
+ onclose: MockCloseHandler = null;
49
+ onerror: MockWebSocketEventHandler = null;
50
+ onmessage: MockMessageHandler = null;
51
+
52
+ sentMessages: string[] = [];
53
+ static instances: MockWebSocket[] = [];
54
+
55
+ constructor(url: string, protocols?: string | string[]) {
56
+ this.url = url;
57
+ this.protocols = protocols;
58
+ MockWebSocket.instances.push(this);
59
+ }
60
+
61
+ send(data: string): void {
62
+ this.sentMessages.push(data);
63
+ }
64
+
65
+ close(code?: number, reason?: string): void {
66
+ this.readyState = MockWebSocket.CLOSED;
67
+ if (this.onclose) {
68
+ const event = new MockCloseEvent("close", { code: code ?? 1000, reason: reason ?? "" });
69
+ this.onclose(event);
70
+ }
71
+ }
72
+
73
+ // Test helpers
74
+ simulateOpen(): void {
75
+ this.readyState = MockWebSocket.OPEN;
76
+ if (this.onopen) {
77
+ this.onopen(new Event("open"));
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Simulates a complete connection: opens the socket and sends auth_result
83
+ * after the transport sends the auth message.
84
+ * This is needed because the transport always sends an auth message on open
85
+ * and waits for auth_result before completing the connection.
86
+ *
87
+ * Returns a Promise that resolves after auth is simulated.
88
+ */
89
+ simulateOpenWithAuth(): Promise<void> {
90
+ this.simulateOpen();
91
+ // The onopen handler is async and sends auth message
92
+ // We need to wait for it, then send auth_result
93
+ // Use Promise.resolve().then() to ensure we run after the microtask queue
94
+ return Promise.resolve().then(() => Promise.resolve()).then(() => {
95
+ this.simulateMessage({ type: "auth_result", success: true });
96
+ });
97
+ }
98
+
99
+ simulateMessage(data: unknown): void {
100
+ if (this.onmessage) {
101
+ this.onmessage(new MessageEvent("message", { data: JSON.stringify(data) }));
102
+ }
103
+ }
104
+
105
+ simulateClose(code = 1000, reason = ""): void {
106
+ this.readyState = MockWebSocket.CLOSED;
107
+ if (this.onclose) {
108
+ this.onclose(new MockCloseEvent("close", { code, reason }));
109
+ }
110
+ }
111
+
112
+ simulateError(): void {
113
+ if (this.onerror) {
114
+ this.onerror(new Event("error"));
115
+ }
116
+ }
117
+
118
+ static reset(): void {
119
+ MockWebSocket.instances = [];
120
+ }
121
+
122
+ static getLatest(): MockWebSocket | undefined {
123
+ return MockWebSocket.instances[MockWebSocket.instances.length - 1];
124
+ }
125
+ }
126
+
127
+ // Set up global mock
128
+ const originalWebSocket = globalThis.WebSocket;
129
+
130
+ beforeEach(() => {
131
+ MockWebSocket.reset();
132
+ // @ts-expect-error - Mocking global WebSocket
133
+ globalThis.WebSocket = MockWebSocket;
134
+ vi.useFakeTimers();
135
+ });
136
+
137
+ afterEach(() => {
138
+ globalThis.WebSocket = originalWebSocket;
139
+ vi.useRealTimers();
140
+ });
141
+
142
+ // =============================================================================
143
+ // WebSocketTransport Tests
144
+ // =============================================================================
145
+
146
+ describe("WebSocketTransport", () => {
147
+ describe("make", () => {
148
+ it("should create a transport with default options", () => {
149
+ const transport = WebSocketTransport.make({
150
+ url: "ws://localhost:8080",
151
+ });
152
+
153
+ expect(transport.isConnected()).toBe(false);
154
+ });
155
+ });
156
+
157
+ describe("connect", () => {
158
+ it("should establish connection to WebSocket server", async () => {
159
+ const transport = WebSocketTransport.make({
160
+ url: "ws://localhost:8080",
161
+ });
162
+
163
+ const connectPromise = transport.connect();
164
+
165
+ // Simulate WebSocket open
166
+ const ws = MockWebSocket.getLatest()!;
167
+ await ws.simulateOpenWithAuth();
168
+
169
+ await connectPromise;
170
+
171
+ expect(transport.isConnected()).toBe(true);
172
+ expect(ws.url).toBe("ws://localhost:8080");
173
+ });
174
+
175
+ it("should pass protocols to WebSocket", async () => {
176
+ const transport = WebSocketTransport.make({
177
+ url: "ws://localhost:8080",
178
+ protocols: ["mimic-v1"],
179
+ });
180
+
181
+ transport.connect();
182
+
183
+ const ws = MockWebSocket.getLatest()!;
184
+ expect(ws.protocols).toEqual(["mimic-v1"]);
185
+ });
186
+
187
+ it("should emit connected event", async () => {
188
+ let connectedEmitted = false;
189
+
190
+ const transport = WebSocketTransport.make({
191
+ url: "ws://localhost:8080",
192
+ onEvent: (event) => {
193
+ if (event.type === "connected") {
194
+ connectedEmitted = true;
195
+ }
196
+ },
197
+ });
198
+
199
+ const connectPromise = transport.connect();
200
+ await MockWebSocket.getLatest()!.simulateOpenWithAuth();
201
+ await connectPromise;
202
+
203
+ expect(connectedEmitted).toBe(true);
204
+ });
205
+
206
+ it("should reject on connection timeout", async () => {
207
+ const transport = WebSocketTransport.make({
208
+ url: "ws://localhost:8080",
209
+ connectionTimeout: 1000,
210
+ });
211
+
212
+ const connectPromise = transport.connect();
213
+
214
+ // Advance time past timeout
215
+ vi.advanceTimersByTime(1001);
216
+
217
+ await expect(connectPromise).rejects.toThrow("Connection failed");
218
+ });
219
+
220
+ it("should return immediately if already connected", async () => {
221
+ const transport = WebSocketTransport.make({
222
+ url: "ws://localhost:8080",
223
+ });
224
+
225
+ const connectPromise = transport.connect();
226
+ await MockWebSocket.getLatest()!.simulateOpenWithAuth();
227
+ await connectPromise;
228
+
229
+ // Should return immediately
230
+ await transport.connect();
231
+ expect(MockWebSocket.instances.length).toBe(1);
232
+ });
233
+ });
234
+
235
+ describe("disconnect", () => {
236
+ it("should close WebSocket connection", async () => {
237
+ const transport = WebSocketTransport.make({
238
+ url: "ws://localhost:8080",
239
+ });
240
+
241
+ const connectPromise = transport.connect();
242
+ await MockWebSocket.getLatest()!.simulateOpenWithAuth();
243
+ await connectPromise;
244
+
245
+ transport.disconnect();
246
+
247
+ expect(transport.isConnected()).toBe(false);
248
+ });
249
+
250
+ it("should emit disconnected event", async () => {
251
+ let disconnectedReason: string | undefined;
252
+
253
+ const transport = WebSocketTransport.make({
254
+ url: "ws://localhost:8080",
255
+ onEvent: (event) => {
256
+ if (event.type === "disconnected") {
257
+ disconnectedReason = event.reason;
258
+ }
259
+ },
260
+ });
261
+
262
+ const connectPromise = transport.connect();
263
+ await MockWebSocket.getLatest()!.simulateOpenWithAuth();
264
+ await connectPromise;
265
+
266
+ transport.disconnect();
267
+
268
+ expect(disconnectedReason).toBe("User disconnected");
269
+ });
270
+
271
+ it("should reject pending connect promise", async () => {
272
+ const transport = WebSocketTransport.make({
273
+ url: "ws://localhost:8080",
274
+ });
275
+
276
+ const connectPromise = transport.connect();
277
+
278
+ // Disconnect while connecting
279
+ transport.disconnect();
280
+
281
+ await expect(connectPromise).rejects.toThrow("Disconnected by user");
282
+ });
283
+ });
284
+
285
+ describe("send", () => {
286
+ it("should send transaction as JSON", async () => {
287
+ const transport = WebSocketTransport.make({
288
+ url: "ws://localhost:8080",
289
+ });
290
+
291
+ const connectPromise = transport.connect();
292
+ const ws = MockWebSocket.getLatest()!;
293
+ await ws.simulateOpenWithAuth();
294
+ await connectPromise;
295
+
296
+ const tx = Transaction.make([
297
+ {
298
+ kind: "string.set" as const,
299
+ path: OperationPath.make("title"),
300
+ payload: "test",
301
+ },
302
+ ]);
303
+
304
+ transport.send(tx);
305
+
306
+ // 2 messages: auth + submit
307
+ expect(ws.sentMessages.length).toBe(2);
308
+ const authMsg = JSON.parse(ws.sentMessages[0]!);
309
+ expect(authMsg.type).toBe("auth");
310
+ const sent = JSON.parse(ws.sentMessages[1]!);
311
+ expect(sent.type).toBe("submit");
312
+ expect(sent.transaction.id).toBe(tx.id);
313
+ });
314
+
315
+ it("should queue messages during reconnection", async () => {
316
+ const transport = WebSocketTransport.make({
317
+ url: "ws://localhost:8080",
318
+ autoReconnect: true,
319
+ });
320
+
321
+ const connectPromise = transport.connect();
322
+ const ws = MockWebSocket.getLatest()!;
323
+ await ws.simulateOpenWithAuth();
324
+ await connectPromise;
325
+
326
+ // Simulate connection lost
327
+ ws.simulateClose(1006, "Connection lost");
328
+
329
+ // Queue message during reconnection
330
+ const tx = Transaction.make([
331
+ {
332
+ kind: "string.set" as const,
333
+ path: OperationPath.make("title"),
334
+ payload: "queued",
335
+ },
336
+ ]);
337
+ transport.send(tx);
338
+
339
+ // Reconnect
340
+ vi.advanceTimersByTime(1000);
341
+ const newWs = MockWebSocket.getLatest()!;
342
+ await newWs.simulateOpenWithAuth();
343
+
344
+ // Queued message should be sent after auth
345
+ // 2 messages: auth + submit (queued message)
346
+ expect(newWs.sentMessages.length).toBe(2);
347
+ const authMsg = JSON.parse(newWs.sentMessages[0]!);
348
+ expect(authMsg.type).toBe("auth");
349
+ const sent = JSON.parse(newWs.sentMessages[1]!);
350
+ expect(sent.type).toBe("submit");
351
+ });
352
+ });
353
+
354
+ describe("requestSnapshot", () => {
355
+ it("should send snapshot request as JSON", async () => {
356
+ const transport = WebSocketTransport.make({
357
+ url: "ws://localhost:8080",
358
+ });
359
+
360
+ const connectPromise = transport.connect();
361
+ const ws = MockWebSocket.getLatest()!;
362
+ await ws.simulateOpenWithAuth();
363
+ await connectPromise;
364
+
365
+ transport.requestSnapshot();
366
+
367
+ // 2 messages: auth + request_snapshot
368
+ expect(ws.sentMessages.length).toBe(2);
369
+ const authMsg = JSON.parse(ws.sentMessages[0]!);
370
+ expect(authMsg.type).toBe("auth");
371
+ const sent = JSON.parse(ws.sentMessages[1]!);
372
+ expect(sent.type).toBe("request_snapshot");
373
+ });
374
+ });
375
+
376
+ describe("subscribe", () => {
377
+ it("should forward server messages to handlers", async () => {
378
+ const transport = WebSocketTransport.make({
379
+ url: "ws://localhost:8080",
380
+ });
381
+
382
+ const messages: Transport.ServerMessage[] = [];
383
+ transport.subscribe((msg) => messages.push(msg));
384
+
385
+ const connectPromise = transport.connect();
386
+ const ws = MockWebSocket.getLatest()!;
387
+ await ws.simulateOpenWithAuth();
388
+ await connectPromise;
389
+
390
+ ws.simulateMessage({
391
+ type: "transaction",
392
+ transaction: { id: "tx-1", ops: [], timestamp: Date.now() },
393
+ version: 1,
394
+ });
395
+
396
+ expect(messages.length).toBe(1);
397
+ expect(messages[0]!.type).toBe("transaction");
398
+ });
399
+
400
+ it("should allow unsubscribing", async () => {
401
+ const transport = WebSocketTransport.make({
402
+ url: "ws://localhost:8080",
403
+ });
404
+
405
+ const messages: Transport.ServerMessage[] = [];
406
+ const unsubscribe = transport.subscribe((msg) => messages.push(msg));
407
+
408
+ const connectPromise = transport.connect();
409
+ const ws = MockWebSocket.getLatest()!;
410
+ await ws.simulateOpenWithAuth();
411
+ await connectPromise;
412
+
413
+ unsubscribe();
414
+
415
+ ws.simulateMessage({
416
+ type: "snapshot",
417
+ state: {},
418
+ version: 1,
419
+ });
420
+
421
+ expect(messages.length).toBe(0);
422
+ });
423
+ });
424
+
425
+ describe("reconnection", () => {
426
+ it("should automatically reconnect on connection lost", async () => {
427
+ let reconnectingAttempt = 0;
428
+
429
+ const transport = WebSocketTransport.make({
430
+ url: "ws://localhost:8080",
431
+ autoReconnect: true,
432
+ reconnectDelay: 1000,
433
+ onEvent: (event) => {
434
+ if (event.type === "reconnecting") {
435
+ reconnectingAttempt = event.attempt;
436
+ }
437
+ },
438
+ });
439
+
440
+ const connectPromise = transport.connect();
441
+ const ws = MockWebSocket.getLatest()!;
442
+ await ws.simulateOpenWithAuth();
443
+ await connectPromise;
444
+
445
+ // Simulate connection lost
446
+ ws.simulateClose(1006, "Connection lost");
447
+
448
+ expect(reconnectingAttempt).toBe(1);
449
+
450
+ // Advance time to trigger reconnect
451
+ vi.advanceTimersByTime(1000);
452
+
453
+ // Should have created new WebSocket
454
+ expect(MockWebSocket.instances.length).toBe(2);
455
+
456
+ // Complete reconnection (needs auth too)
457
+ await MockWebSocket.getLatest()!.simulateOpenWithAuth();
458
+
459
+ expect(transport.isConnected()).toBe(true);
460
+ });
461
+
462
+ it("should use exponential backoff for reconnection", async () => {
463
+ const transport = WebSocketTransport.make({
464
+ url: "ws://localhost:8080",
465
+ autoReconnect: true,
466
+ reconnectDelay: 1000,
467
+ maxReconnectDelay: 30000,
468
+ });
469
+
470
+ const connectPromise = transport.connect();
471
+ await MockWebSocket.getLatest()!.simulateOpenWithAuth();
472
+ await connectPromise;
473
+
474
+ // First disconnection
475
+ MockWebSocket.getLatest()!.simulateClose();
476
+
477
+ // First retry after 1s (1000 * 2^0)
478
+ vi.advanceTimersByTime(999);
479
+ expect(MockWebSocket.instances.length).toBe(1);
480
+ vi.advanceTimersByTime(1);
481
+ expect(MockWebSocket.instances.length).toBe(2);
482
+
483
+ // Fail again
484
+ MockWebSocket.getLatest()!.simulateClose();
485
+
486
+ // Second retry after 2s (1000 * 2^1)
487
+ vi.advanceTimersByTime(1999);
488
+ expect(MockWebSocket.instances.length).toBe(2);
489
+ vi.advanceTimersByTime(1);
490
+ expect(MockWebSocket.instances.length).toBe(3);
491
+ });
492
+
493
+ it("should stop reconnecting after max attempts", async () => {
494
+ let finalDisconnectReason: string | undefined;
495
+
496
+ const transport = WebSocketTransport.make({
497
+ url: "ws://localhost:8080",
498
+ autoReconnect: true,
499
+ maxReconnectAttempts: 2,
500
+ reconnectDelay: 100,
501
+ onEvent: (event) => {
502
+ if (event.type === "disconnected") {
503
+ finalDisconnectReason = event.reason;
504
+ }
505
+ },
506
+ });
507
+
508
+ const connectPromise = transport.connect();
509
+ await MockWebSocket.getLatest()!.simulateOpenWithAuth();
510
+ await connectPromise;
511
+
512
+ // First disconnection
513
+ MockWebSocket.getLatest()!.simulateClose();
514
+ vi.advanceTimersByTime(100);
515
+ MockWebSocket.getLatest()!.simulateClose();
516
+ vi.advanceTimersByTime(200);
517
+ MockWebSocket.getLatest()!.simulateClose();
518
+
519
+ // Should have given up after 2 attempts
520
+ expect(finalDisconnectReason).toBe("Max reconnection attempts reached");
521
+ });
522
+
523
+ it("should not reconnect when autoReconnect is false", async () => {
524
+ let disconnected = false;
525
+
526
+ const transport = WebSocketTransport.make({
527
+ url: "ws://localhost:8080",
528
+ autoReconnect: false,
529
+ onEvent: (event) => {
530
+ if (event.type === "disconnected") {
531
+ disconnected = true;
532
+ }
533
+ },
534
+ });
535
+
536
+ const connectPromise = transport.connect();
537
+ await MockWebSocket.getLatest()!.simulateOpenWithAuth();
538
+ await connectPromise;
539
+
540
+ MockWebSocket.getLatest()!.simulateClose();
541
+
542
+ // Advance time - should not reconnect
543
+ vi.advanceTimersByTime(10000);
544
+
545
+ expect(MockWebSocket.instances.length).toBe(1);
546
+ expect(disconnected).toBe(true);
547
+ });
548
+ });
549
+
550
+ describe("heartbeat", () => {
551
+ it("should send ping at configured interval", async () => {
552
+ const transport = WebSocketTransport.make({
553
+ url: "ws://localhost:8080",
554
+ heartbeatInterval: 5000,
555
+ });
556
+
557
+ const connectPromise = transport.connect();
558
+ const ws = MockWebSocket.getLatest()!;
559
+ await ws.simulateOpenWithAuth();
560
+ await connectPromise;
561
+
562
+ // Advance time to trigger heartbeat
563
+ vi.advanceTimersByTime(5000);
564
+
565
+ // 2 messages: auth + ping
566
+ expect(ws.sentMessages.length).toBe(2);
567
+ const authMsg = JSON.parse(ws.sentMessages[0]!);
568
+ expect(authMsg.type).toBe("auth");
569
+ const sent = JSON.parse(ws.sentMessages[1]!);
570
+ expect(sent.type).toBe("ping");
571
+ });
572
+
573
+ it("should handle pong response", async () => {
574
+ const transport = WebSocketTransport.make({
575
+ url: "ws://localhost:8080",
576
+ heartbeatInterval: 5000,
577
+ heartbeatTimeout: 2000,
578
+ });
579
+
580
+ const connectPromise = transport.connect();
581
+ const ws = MockWebSocket.getLatest()!;
582
+ await ws.simulateOpenWithAuth();
583
+ await connectPromise;
584
+
585
+ // Trigger heartbeat
586
+ vi.advanceTimersByTime(5000);
587
+
588
+ // Respond with pong
589
+ ws.simulateMessage({ type: "pong" });
590
+
591
+ // Advance past timeout - should not disconnect
592
+ vi.advanceTimersByTime(3000);
593
+
594
+ expect(transport.isConnected()).toBe(true);
595
+ });
596
+
597
+ it("should trigger reconnection on heartbeat timeout", async () => {
598
+ let reconnecting = false;
599
+
600
+ const transport = WebSocketTransport.make({
601
+ url: "ws://localhost:8080",
602
+ autoReconnect: true,
603
+ heartbeatInterval: 5000,
604
+ heartbeatTimeout: 2000,
605
+ onEvent: (event) => {
606
+ if (event.type === "reconnecting") {
607
+ reconnecting = true;
608
+ }
609
+ },
610
+ });
611
+
612
+ const connectPromise = transport.connect();
613
+ const ws = MockWebSocket.getLatest()!;
614
+ await ws.simulateOpenWithAuth();
615
+ await connectPromise;
616
+
617
+ // Trigger heartbeat
618
+ vi.advanceTimersByTime(5000);
619
+
620
+ // No pong response - wait for timeout
621
+ vi.advanceTimersByTime(2000);
622
+
623
+ expect(reconnecting).toBe(true);
624
+ });
625
+ });
626
+
627
+ describe("authentication", () => {
628
+ it("should send auth message after connection with string token", async () => {
629
+ const transport = WebSocketTransport.make({
630
+ url: "ws://localhost:8080",
631
+ authToken: "test-token-123",
632
+ });
633
+
634
+ const connectPromise = transport.connect();
635
+ const ws = MockWebSocket.getLatest()!;
636
+ // Use simulateOpen() only - we want to test auth manually
637
+ ws.simulateOpen();
638
+
639
+ // Should send auth message
640
+ await vi.waitFor(() => {
641
+ expect(ws.sentMessages.length).toBe(1);
642
+ });
643
+
644
+ const authMessage = JSON.parse(ws.sentMessages[0]!);
645
+ expect(authMessage.type).toBe("auth");
646
+ expect(authMessage.token).toBe("test-token-123");
647
+
648
+ // Simulate auth success
649
+ ws.simulateMessage({ type: "auth_result", success: true });
650
+
651
+ await connectPromise;
652
+ expect(transport.isConnected()).toBe(true);
653
+ });
654
+
655
+ it("should send auth message after connection with function token", async () => {
656
+ const transport = WebSocketTransport.make({
657
+ url: "ws://localhost:8080",
658
+ authToken: () => "dynamic-token",
659
+ });
660
+
661
+ const connectPromise = transport.connect();
662
+ const ws = MockWebSocket.getLatest()!;
663
+ ws.simulateOpen();
664
+
665
+ await vi.waitFor(() => {
666
+ expect(ws.sentMessages.length).toBe(1);
667
+ });
668
+
669
+ const authMessage = JSON.parse(ws.sentMessages[0]!);
670
+ expect(authMessage.token).toBe("dynamic-token");
671
+
672
+ ws.simulateMessage({ type: "auth_result", success: true });
673
+ await connectPromise;
674
+ });
675
+
676
+ it("should send auth message with async function token", async () => {
677
+ const transport = WebSocketTransport.make({
678
+ url: "ws://localhost:8080",
679
+ authToken: async () => {
680
+ return "async-token";
681
+ },
682
+ });
683
+
684
+ const connectPromise = transport.connect();
685
+ const ws = MockWebSocket.getLatest()!;
686
+ ws.simulateOpen();
687
+
688
+ await vi.waitFor(() => {
689
+ expect(ws.sentMessages.length).toBe(1);
690
+ });
691
+
692
+ const authMessage = JSON.parse(ws.sentMessages[0]!);
693
+ expect(authMessage.token).toBe("async-token");
694
+
695
+ ws.simulateMessage({ type: "auth_result", success: true });
696
+ await connectPromise;
697
+ });
698
+
699
+ it("should send empty token auth message when no authToken provided", async () => {
700
+ const transport = WebSocketTransport.make({
701
+ url: "ws://localhost:8080",
702
+ // No authToken provided
703
+ });
704
+
705
+ const connectPromise = transport.connect();
706
+ const ws = MockWebSocket.getLatest()!;
707
+ ws.simulateOpen();
708
+
709
+ // Should send auth message with empty token
710
+ await vi.waitFor(() => {
711
+ expect(ws.sentMessages.length).toBe(1);
712
+ });
713
+
714
+ const authMessage = JSON.parse(ws.sentMessages[0]!);
715
+ expect(authMessage.type).toBe("auth");
716
+ expect(authMessage.token).toBe("");
717
+
718
+ // Simulate auth success
719
+ ws.simulateMessage({ type: "auth_result", success: true });
720
+
721
+ await connectPromise;
722
+ expect(transport.isConnected()).toBe(true);
723
+ });
724
+
725
+ it("should reject connection on auth failure", async () => {
726
+ let errorEmitted = false;
727
+
728
+ const transport = WebSocketTransport.make({
729
+ url: "ws://localhost:8080",
730
+ authToken: "bad-token",
731
+ onEvent: (event) => {
732
+ if (event.type === "error") {
733
+ errorEmitted = true;
734
+ }
735
+ },
736
+ });
737
+
738
+ const connectPromise = transport.connect();
739
+ const ws = MockWebSocket.getLatest()!;
740
+ ws.simulateOpen();
741
+
742
+ await vi.waitFor(() => {
743
+ expect(ws.sentMessages.length).toBe(1);
744
+ });
745
+
746
+ // Simulate auth failure
747
+ ws.simulateMessage({
748
+ type: "auth_result",
749
+ success: false,
750
+ error: "Invalid token",
751
+ });
752
+
753
+ await expect(connectPromise).rejects.toThrow("Invalid token");
754
+ expect(errorEmitted).toBe(true);
755
+ expect(transport.isConnected()).toBe(false);
756
+ });
757
+ });
758
+
759
+ describe("presence", () => {
760
+ describe("sendPresenceSet", () => {
761
+ it("should send presence_set message when connected", async () => {
762
+ const transport = WebSocketTransport.make({
763
+ url: "ws://localhost:8080",
764
+ });
765
+
766
+ const connectPromise = transport.connect();
767
+ const ws = MockWebSocket.getLatest()!;
768
+ await ws.simulateOpenWithAuth();
769
+ await connectPromise;
770
+
771
+ transport.sendPresenceSet({ x: 100, y: 200, name: "Alice" });
772
+
773
+ // 2 messages: auth + presence_set
774
+ expect(ws.sentMessages.length).toBe(2);
775
+ const authMsg = JSON.parse(ws.sentMessages[0]!);
776
+ expect(authMsg.type).toBe("auth");
777
+ const sent = JSON.parse(ws.sentMessages[1]!);
778
+ expect(sent.type).toBe("presence_set");
779
+ expect(sent.data).toEqual({ x: 100, y: 200, name: "Alice" });
780
+ });
781
+
782
+ it("should queue presence_set during reconnection", async () => {
783
+ const transport = WebSocketTransport.make({
784
+ url: "ws://localhost:8080",
785
+ autoReconnect: true,
786
+ });
787
+
788
+ const connectPromise = transport.connect();
789
+ const ws = MockWebSocket.getLatest()!;
790
+ await ws.simulateOpenWithAuth();
791
+ await connectPromise;
792
+
793
+ // Simulate connection lost
794
+ ws.simulateClose(1006, "Connection lost");
795
+
796
+ // Queue presence message during reconnection
797
+ transport.sendPresenceSet({ cursor: { x: 50, y: 75 } });
798
+
799
+ // Reconnect
800
+ vi.advanceTimersByTime(1000);
801
+ const newWs = MockWebSocket.getLatest()!;
802
+ await newWs.simulateOpenWithAuth();
803
+
804
+ // Queued message should be sent after auth
805
+ // 2 messages: auth + presence_set (queued message)
806
+ expect(newWs.sentMessages.length).toBe(2);
807
+ const authMsg = JSON.parse(newWs.sentMessages[0]!);
808
+ expect(authMsg.type).toBe("auth");
809
+ const sent = JSON.parse(newWs.sentMessages[1]!);
810
+ expect(sent.type).toBe("presence_set");
811
+ expect(sent.data).toEqual({ cursor: { x: 50, y: 75 } });
812
+ });
813
+
814
+ it("should not send when disconnected", async () => {
815
+ const transport = WebSocketTransport.make({
816
+ url: "ws://localhost:8080",
817
+ });
818
+
819
+ // Never connect - sendPresenceSet should be silently ignored
820
+ transport.sendPresenceSet({ x: 100, y: 200 });
821
+
822
+ // No WebSocket created, nothing sent
823
+ expect(MockWebSocket.instances.length).toBe(0);
824
+ });
825
+ });
826
+
827
+ describe("sendPresenceClear", () => {
828
+ it("should send presence_clear message when connected", async () => {
829
+ const transport = WebSocketTransport.make({
830
+ url: "ws://localhost:8080",
831
+ });
832
+
833
+ const connectPromise = transport.connect();
834
+ const ws = MockWebSocket.getLatest()!;
835
+ await ws.simulateOpenWithAuth();
836
+ await connectPromise;
837
+
838
+ transport.sendPresenceClear();
839
+
840
+ // 2 messages: auth + presence_clear
841
+ expect(ws.sentMessages.length).toBe(2);
842
+ const authMsg = JSON.parse(ws.sentMessages[0]!);
843
+ expect(authMsg.type).toBe("auth");
844
+ const sent = JSON.parse(ws.sentMessages[1]!);
845
+ expect(sent.type).toBe("presence_clear");
846
+ });
847
+
848
+ it("should queue presence_clear during reconnection", async () => {
849
+ const transport = WebSocketTransport.make({
850
+ url: "ws://localhost:8080",
851
+ autoReconnect: true,
852
+ });
853
+
854
+ const connectPromise = transport.connect();
855
+ const ws = MockWebSocket.getLatest()!;
856
+ await ws.simulateOpenWithAuth();
857
+ await connectPromise;
858
+
859
+ // Simulate connection lost
860
+ ws.simulateClose(1006, "Connection lost");
861
+
862
+ // Queue presence_clear during reconnection
863
+ transport.sendPresenceClear();
864
+
865
+ // Reconnect
866
+ vi.advanceTimersByTime(1000);
867
+ const newWs = MockWebSocket.getLatest()!;
868
+ await newWs.simulateOpenWithAuth();
869
+
870
+ // Queued message should be sent after auth
871
+ expect(newWs.sentMessages.length).toBe(2);
872
+ const sent = JSON.parse(newWs.sentMessages[1]!);
873
+ expect(sent.type).toBe("presence_clear");
874
+ });
875
+ });
876
+
877
+ describe("presence message forwarding", () => {
878
+ it("should forward presence_snapshot to subscribers", async () => {
879
+ const transport = WebSocketTransport.make({
880
+ url: "ws://localhost:8080",
881
+ });
882
+
883
+ const messages: Transport.ServerMessage[] = [];
884
+ transport.subscribe((msg) => messages.push(msg));
885
+
886
+ const connectPromise = transport.connect();
887
+ const ws = MockWebSocket.getLatest()!;
888
+ await ws.simulateOpenWithAuth();
889
+ await connectPromise;
890
+
891
+ ws.simulateMessage({
892
+ type: "presence_snapshot",
893
+ selfId: "conn-123",
894
+ presences: {
895
+ "conn-456": { data: { x: 10, y: 20 }, userId: "user-456" },
896
+ },
897
+ });
898
+
899
+ expect(messages.length).toBe(1);
900
+ expect(messages[0]!.type).toBe("presence_snapshot");
901
+ if (messages[0]!.type === "presence_snapshot") {
902
+ expect(messages[0]!.selfId).toBe("conn-123");
903
+ expect(messages[0]!.presences["conn-456"]).toEqual({
904
+ data: { x: 10, y: 20 },
905
+ userId: "user-456",
906
+ });
907
+ }
908
+ });
909
+
910
+ it("should forward presence_update to subscribers", async () => {
911
+ const transport = WebSocketTransport.make({
912
+ url: "ws://localhost:8080",
913
+ });
914
+
915
+ const messages: Transport.ServerMessage[] = [];
916
+ transport.subscribe((msg) => messages.push(msg));
917
+
918
+ const connectPromise = transport.connect();
919
+ const ws = MockWebSocket.getLatest()!;
920
+ await ws.simulateOpenWithAuth();
921
+ await connectPromise;
922
+
923
+ ws.simulateMessage({
924
+ type: "presence_update",
925
+ id: "conn-789",
926
+ data: { cursor: { x: 50, y: 100 } },
927
+ userId: "user-789",
928
+ });
929
+
930
+ expect(messages.length).toBe(1);
931
+ expect(messages[0]!.type).toBe("presence_update");
932
+ if (messages[0]!.type === "presence_update") {
933
+ expect(messages[0]!.id).toBe("conn-789");
934
+ expect(messages[0]!.data).toEqual({ cursor: { x: 50, y: 100 } });
935
+ expect(messages[0]!.userId).toBe("user-789");
936
+ }
937
+ });
938
+
939
+ it("should forward presence_remove to subscribers", async () => {
940
+ const transport = WebSocketTransport.make({
941
+ url: "ws://localhost:8080",
942
+ });
943
+
944
+ const messages: Transport.ServerMessage[] = [];
945
+ transport.subscribe((msg) => messages.push(msg));
946
+
947
+ const connectPromise = transport.connect();
948
+ const ws = MockWebSocket.getLatest()!;
949
+ await ws.simulateOpenWithAuth();
950
+ await connectPromise;
951
+
952
+ ws.simulateMessage({
953
+ type: "presence_remove",
954
+ id: "conn-disconnected",
955
+ });
956
+
957
+ expect(messages.length).toBe(1);
958
+ expect(messages[0]!.type).toBe("presence_remove");
959
+ if (messages[0]!.type === "presence_remove") {
960
+ expect(messages[0]!.id).toBe("conn-disconnected");
961
+ }
962
+ });
963
+
964
+ it("should forward presence_update without userId", async () => {
965
+ const transport = WebSocketTransport.make({
966
+ url: "ws://localhost:8080",
967
+ });
968
+
969
+ const messages: Transport.ServerMessage[] = [];
970
+ transport.subscribe((msg) => messages.push(msg));
971
+
972
+ const connectPromise = transport.connect();
973
+ const ws = MockWebSocket.getLatest()!;
974
+ await ws.simulateOpenWithAuth();
975
+ await connectPromise;
976
+
977
+ ws.simulateMessage({
978
+ type: "presence_update",
979
+ id: "conn-anon",
980
+ data: { status: "online" },
981
+ });
982
+
983
+ expect(messages.length).toBe(1);
984
+ if (messages[0]!.type === "presence_update") {
985
+ expect(messages[0]!.id).toBe("conn-anon");
986
+ expect(messages[0]!.data).toEqual({ status: "online" });
987
+ expect(messages[0]!.userId).toBeUndefined();
988
+ }
989
+ });
990
+ });
991
+ });
992
+ });