@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.
- package/README.md +17 -0
- package/package.json +33 -0
- package/src/Document.ts +256 -0
- package/src/FractionalIndex.ts +1249 -0
- package/src/Operation.ts +59 -0
- package/src/OperationDefinition.ts +23 -0
- package/src/OperationPath.ts +197 -0
- package/src/Presence.ts +142 -0
- package/src/Primitive.ts +32 -0
- package/src/Proxy.ts +8 -0
- package/src/ProxyEnvironment.ts +52 -0
- package/src/Transaction.ts +72 -0
- package/src/Transform.ts +13 -0
- package/src/client/ClientDocument.ts +1163 -0
- package/src/client/Rebase.ts +309 -0
- package/src/client/StateMonitor.ts +307 -0
- package/src/client/Transport.ts +318 -0
- package/src/client/WebSocketTransport.ts +572 -0
- package/src/client/errors.ts +145 -0
- package/src/client/index.ts +61 -0
- package/src/index.ts +12 -0
- package/src/primitives/Array.ts +457 -0
- package/src/primitives/Boolean.ts +128 -0
- package/src/primitives/Lazy.ts +89 -0
- package/src/primitives/Literal.ts +128 -0
- package/src/primitives/Number.ts +169 -0
- package/src/primitives/String.ts +189 -0
- package/src/primitives/Struct.ts +348 -0
- package/src/primitives/Tree.ts +1120 -0
- package/src/primitives/TreeNode.ts +113 -0
- package/src/primitives/Union.ts +329 -0
- package/src/primitives/shared.ts +122 -0
- package/src/server/ServerDocument.ts +267 -0
- package/src/server/errors.ts +90 -0
- package/src/server/index.ts +40 -0
- package/tests/Document.test.ts +556 -0
- package/tests/FractionalIndex.test.ts +377 -0
- package/tests/OperationPath.test.ts +151 -0
- package/tests/Presence.test.ts +321 -0
- package/tests/Primitive.test.ts +381 -0
- package/tests/client/ClientDocument.test.ts +1398 -0
- package/tests/client/WebSocketTransport.test.ts +992 -0
- package/tests/primitives/Array.test.ts +418 -0
- package/tests/primitives/Boolean.test.ts +126 -0
- package/tests/primitives/Lazy.test.ts +143 -0
- package/tests/primitives/Literal.test.ts +122 -0
- package/tests/primitives/Number.test.ts +133 -0
- package/tests/primitives/String.test.ts +128 -0
- package/tests/primitives/Struct.test.ts +311 -0
- package/tests/primitives/Tree.test.ts +467 -0
- package/tests/primitives/TreeNode.test.ts +50 -0
- package/tests/primitives/Union.test.ts +210 -0
- package/tests/server/ServerDocument.test.ts +528 -0
- package/tsconfig.build.json +24 -0
- package/tsconfig.json +8 -0
- package/tsdown.config.ts +18 -0
- 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
|
+
});
|