@fluxstack/live-client 0.5.0 → 0.5.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.
@@ -0,0 +1,359 @@
1
+ // Tests for client-side binary room frame handling
2
+ //
3
+ // Validates that the RoomManager correctly:
4
+ // - Parses binary room frames (0x02 ROOM_EVENT, 0x03 ROOM_STATE)
5
+ // - Decodes msgpack payloads
6
+ // - Dispatches to event handlers
7
+ // - Merges state updates
8
+
9
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
10
+ import { RoomManager } from '../rooms'
11
+ import type { RoomServerMessage } from '../rooms'
12
+
13
+ // ===== Helpers: build binary frames matching server format =====
14
+
15
+ const encoder = new TextEncoder()
16
+
17
+ // Minimal msgpack encoder (matches server output) — only what we need for tests
18
+ function msgpackEncode(value: unknown): Uint8Array {
19
+ const parts: Uint8Array[] = []
20
+ _encode(value, parts)
21
+ let total = 0
22
+ for (const p of parts) total += p.length
23
+ const result = new Uint8Array(total)
24
+ let off = 0
25
+ for (const p of parts) { result.set(p, off); off += p.length }
26
+ return result
27
+ }
28
+
29
+ function _encode(value: unknown, parts: Uint8Array[]): void {
30
+ if (value === null || value === undefined) { parts.push(new Uint8Array([0xc0])); return }
31
+ if (typeof value === 'boolean') { parts.push(new Uint8Array([value ? 0xc3 : 0xc2])); return }
32
+ if (typeof value === 'number') {
33
+ if (Number.isInteger(value) && value >= 0 && value < 128) {
34
+ parts.push(new Uint8Array([value])); return
35
+ }
36
+ if (Number.isInteger(value) && value >= -32 && value < 0) {
37
+ parts.push(new Uint8Array([value & 0xff])); return
38
+ }
39
+ // float64
40
+ const buf = new Uint8Array(9); buf[0] = 0xcb
41
+ new DataView(buf.buffer).setFloat64(1, value, false)
42
+ parts.push(buf); return
43
+ }
44
+ if (typeof value === 'string') {
45
+ const encoded = encoder.encode(value)
46
+ if (encoded.length < 32) parts.push(new Uint8Array([0xa0 | encoded.length]))
47
+ else { parts.push(new Uint8Array([0xd9, encoded.length])) }
48
+ parts.push(encoded); return
49
+ }
50
+ if (Array.isArray(value)) {
51
+ if (value.length < 16) parts.push(new Uint8Array([0x90 | value.length]))
52
+ else parts.push(new Uint8Array([0xdc, value.length >> 8, value.length & 0xff]))
53
+ for (const item of value) _encode(item, parts)
54
+ return
55
+ }
56
+ if (typeof value === 'object') {
57
+ const keys = Object.keys(value as Record<string, unknown>)
58
+ if (keys.length < 16) parts.push(new Uint8Array([0x80 | keys.length]))
59
+ else parts.push(new Uint8Array([0xde, keys.length >> 8, keys.length & 0xff]))
60
+ for (const key of keys) { _encode(key, parts); _encode((value as any)[key], parts) }
61
+ return
62
+ }
63
+ parts.push(new Uint8Array([0xc0]))
64
+ }
65
+
66
+ /** Build a binary room frame matching the server's wire format */
67
+ function buildTestFrame(
68
+ frameType: number,
69
+ componentId: string,
70
+ roomId: string,
71
+ event: string,
72
+ data: unknown,
73
+ ): Uint8Array {
74
+ const compIdBytes = encoder.encode(componentId)
75
+ const roomIdBytes = encoder.encode(roomId)
76
+ const eventBytes = encoder.encode(event)
77
+ const payload = msgpackEncode(data)
78
+
79
+ const totalLen = 1 + 1 + compIdBytes.length + 1 + roomIdBytes.length + 2 + eventBytes.length + payload.length
80
+ const frame = new Uint8Array(totalLen)
81
+ let offset = 0
82
+
83
+ frame[offset++] = frameType
84
+ frame[offset++] = compIdBytes.length
85
+ frame.set(compIdBytes, offset); offset += compIdBytes.length
86
+ frame[offset++] = roomIdBytes.length
87
+ frame.set(roomIdBytes, offset); offset += roomIdBytes.length
88
+ frame[offset++] = (eventBytes.length >> 8) & 0xff
89
+ frame[offset++] = eventBytes.length & 0xff
90
+ frame.set(eventBytes, offset); offset += eventBytes.length
91
+ frame.set(payload, offset)
92
+
93
+ return frame
94
+ }
95
+
96
+ const BINARY_ROOM_EVENT = 0x02
97
+ const BINARY_ROOM_STATE = 0x03
98
+
99
+ // ===== RoomManager binary frame tests =====
100
+
101
+ describe('RoomManager binary frame handling', () => {
102
+ let manager: RoomManager
103
+ let binaryHandler: ((frame: Uint8Array) => void) | null
104
+ let jsonHandlers: Set<(msg: RoomServerMessage) => void>
105
+
106
+ beforeEach(() => {
107
+ binaryHandler = null
108
+ jsonHandlers = new Set()
109
+
110
+ manager = new RoomManager({
111
+ componentId: 'comp-test-1',
112
+ sendMessage: vi.fn(),
113
+ sendMessageAndWait: vi.fn().mockResolvedValue({ success: true, state: {} }),
114
+ onMessage: (handler) => {
115
+ jsonHandlers.add(handler)
116
+ return () => { jsonHandlers.delete(handler) }
117
+ },
118
+ onBinaryMessage: (handler) => {
119
+ binaryHandler = handler
120
+ return () => { binaryHandler = null }
121
+ },
122
+ })
123
+ })
124
+
125
+ // Helper: join a room via JSON so the room exists in the manager
126
+ function joinRoom(roomId: string, initialState: any = {}) {
127
+ // Simulate the join by sending a JSON ROOM_JOINED message
128
+ const handle = manager.createHandle(roomId)
129
+ // Manually trigger ROOM_JOINED via JSON handler
130
+ for (const handler of jsonHandlers) {
131
+ handler({
132
+ type: 'ROOM_JOINED',
133
+ componentId: 'comp-test-1',
134
+ roomId,
135
+ event: '$room:joined',
136
+ data: { state: initialState },
137
+ timestamp: Date.now(),
138
+ })
139
+ }
140
+ return handle
141
+ }
142
+
143
+ describe('BINARY_ROOM_EVENT (0x02)', () => {
144
+ it('dispatches binary event to room handler', () => {
145
+ const handle = joinRoom('counter:global')
146
+ const eventHandler = vi.fn()
147
+ handle.on('counter:updated' as any, eventHandler)
148
+
149
+ // Send binary room event frame
150
+ const frame = buildTestFrame(BINARY_ROOM_EVENT, 'comp-test-1', 'counter:global', 'counter:updated', {
151
+ count: 42,
152
+ updatedBy: 'Alice',
153
+ })
154
+ binaryHandler!(frame)
155
+
156
+ expect(eventHandler).toHaveBeenCalledTimes(1)
157
+ expect(eventHandler).toHaveBeenCalledWith({ count: 42, updatedBy: 'Alice' })
158
+ })
159
+
160
+ it('ignores events for different componentId', () => {
161
+ const handle = joinRoom('counter:global')
162
+ const eventHandler = vi.fn()
163
+ handle.on('counter:updated' as any, eventHandler)
164
+
165
+ const frame = buildTestFrame(BINARY_ROOM_EVENT, 'comp-other', 'counter:global', 'counter:updated', { count: 1 })
166
+ binaryHandler!(frame)
167
+
168
+ expect(eventHandler).not.toHaveBeenCalled()
169
+ })
170
+
171
+ it('ignores events for non-existent room', () => {
172
+ // Don't join any room
173
+ const frame = buildTestFrame(BINARY_ROOM_EVENT, 'comp-test-1', 'unknown:room', 'some-event', {})
174
+ // Should not throw
175
+ binaryHandler!(frame)
176
+ })
177
+
178
+ it('dispatches to multiple handlers', () => {
179
+ const handle = joinRoom('game:room')
180
+ const handler1 = vi.fn()
181
+ const handler2 = vi.fn()
182
+ handle.on('score:updated' as any, handler1)
183
+ handle.on('score:updated' as any, handler2)
184
+
185
+ const frame = buildTestFrame(BINARY_ROOM_EVENT, 'comp-test-1', 'game:room', 'score:updated', { score: 100 })
186
+ binaryHandler!(frame)
187
+
188
+ expect(handler1).toHaveBeenCalledWith({ score: 100 })
189
+ expect(handler2).toHaveBeenCalledWith({ score: 100 })
190
+ })
191
+
192
+ it('handles handler errors without breaking other handlers', () => {
193
+ const handle = joinRoom('room:a')
194
+ const errorHandler = vi.fn(() => { throw new Error('test error') })
195
+ const goodHandler = vi.fn()
196
+ handle.on('evt' as any, errorHandler)
197
+ handle.on('evt' as any, goodHandler)
198
+
199
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
200
+
201
+ const frame = buildTestFrame(BINARY_ROOM_EVENT, 'comp-test-1', 'room:a', 'evt', { x: 1 })
202
+ binaryHandler!(frame)
203
+
204
+ expect(errorHandler).toHaveBeenCalled()
205
+ expect(goodHandler).toHaveBeenCalledWith({ x: 1 })
206
+ expect(consoleSpy).toHaveBeenCalled()
207
+
208
+ consoleSpy.mockRestore()
209
+ })
210
+ })
211
+
212
+ describe('BINARY_ROOM_STATE (0x03)', () => {
213
+ it('merges state update into room state', () => {
214
+ const handle = joinRoom('counter:global', { count: 0, onlineCount: 1, lastUpdatedBy: null })
215
+
216
+ const frame = buildTestFrame(BINARY_ROOM_STATE, 'comp-test-1', 'counter:global', '$state:update', {
217
+ state: { count: 42, lastUpdatedBy: 'Bob' },
218
+ })
219
+ binaryHandler!(frame)
220
+
221
+ expect(handle.state).toEqual({ count: 42, onlineCount: 1, lastUpdatedBy: 'Bob' })
222
+ })
223
+
224
+ it('triggers $state:change handlers', () => {
225
+ const handle = joinRoom('counter:global', { count: 0 })
226
+ const stateHandler = vi.fn()
227
+ // Internal event for state change
228
+ handle.on('$state:change' as any, stateHandler)
229
+
230
+ const frame = buildTestFrame(BINARY_ROOM_STATE, 'comp-test-1', 'counter:global', '$state:update', {
231
+ state: { count: 10 },
232
+ })
233
+ binaryHandler!(frame)
234
+
235
+ expect(stateHandler).toHaveBeenCalledWith({ count: 10 })
236
+ })
237
+
238
+ it('handles state without wrapper (data is the state directly)', () => {
239
+ const handle = joinRoom('room:x', { val: 0 })
240
+
241
+ // Some code paths send data directly without { state: ... } wrapper
242
+ const frame = buildTestFrame(BINARY_ROOM_STATE, 'comp-test-1', 'room:x', '$state:update', { val: 99 })
243
+ binaryHandler!(frame)
244
+
245
+ expect(handle.state).toEqual({ val: 99 })
246
+ })
247
+
248
+ it('deep merges nested state', () => {
249
+ const handle = joinRoom('game:1', {
250
+ players: { alice: { score: 10 } },
251
+ round: 1,
252
+ })
253
+
254
+ const frame = buildTestFrame(BINARY_ROOM_STATE, 'comp-test-1', 'game:1', '$state:update', {
255
+ state: { players: { alice: { score: 20 } } },
256
+ })
257
+ binaryHandler!(frame)
258
+
259
+ expect(handle.state).toEqual({
260
+ players: { alice: { score: 20 } },
261
+ round: 1,
262
+ })
263
+ })
264
+ })
265
+
266
+ describe('mixed binary + JSON', () => {
267
+ it('handles both binary and JSON messages for the same room', () => {
268
+ const handle = joinRoom('counter:global', { count: 0 })
269
+ const eventHandler = vi.fn()
270
+ handle.on('counter:updated' as any, eventHandler)
271
+
272
+ // Binary event
273
+ binaryHandler!(buildTestFrame(BINARY_ROOM_EVENT, 'comp-test-1', 'counter:global', 'counter:updated', { count: 1, updatedBy: 'Alice' }))
274
+
275
+ // JSON event
276
+ for (const handler of jsonHandlers) {
277
+ handler({
278
+ type: 'ROOM_EVENT',
279
+ componentId: 'comp-test-1',
280
+ roomId: 'counter:global',
281
+ event: 'counter:updated',
282
+ data: { count: 2, updatedBy: 'Bob' },
283
+ timestamp: Date.now(),
284
+ })
285
+ }
286
+
287
+ expect(eventHandler).toHaveBeenCalledTimes(2)
288
+ expect(eventHandler).toHaveBeenNthCalledWith(1, { count: 1, updatedBy: 'Alice' })
289
+ expect(eventHandler).toHaveBeenNthCalledWith(2, { count: 2, updatedBy: 'Bob' })
290
+ })
291
+ })
292
+
293
+ describe('complex msgpack payloads', () => {
294
+ it('decodes arrays in binary events', () => {
295
+ const handle = joinRoom('chat:lobby')
296
+ const handler = vi.fn()
297
+ handle.on('messages:batch' as any, handler)
298
+
299
+ const messages = [
300
+ { id: 1, text: 'hello', user: 'alice' },
301
+ { id: 2, text: 'world', user: 'bob' },
302
+ ]
303
+ binaryHandler!(buildTestFrame(BINARY_ROOM_EVENT, 'comp-test-1', 'chat:lobby', 'messages:batch', messages))
304
+
305
+ expect(handler).toHaveBeenCalledWith(messages)
306
+ })
307
+
308
+ it('decodes nested objects in binary state updates', () => {
309
+ const handle = joinRoom('game:complex', {
310
+ board: { cells: [] },
311
+ players: {},
312
+ })
313
+
314
+ const stateUpdate = {
315
+ state: {
316
+ board: { cells: [[1, 0], [0, 2]] },
317
+ players: {
318
+ p1: { name: 'Alice', score: 100, items: ['sword'] },
319
+ },
320
+ },
321
+ }
322
+ binaryHandler!(buildTestFrame(BINARY_ROOM_STATE, 'comp-test-1', 'game:complex', '$state:update', stateUpdate))
323
+
324
+ expect(handle.state.board.cells).toEqual([[1, 0], [0, 2]])
325
+ expect(handle.state.players.p1.name).toBe('Alice')
326
+ expect(handle.state.players.p1.items).toEqual(['sword'])
327
+ })
328
+
329
+ it('decodes boolean and null values correctly', () => {
330
+ const handle = joinRoom('room:types')
331
+ const handler = vi.fn()
332
+ handle.on('data:typed' as any, handler)
333
+
334
+ binaryHandler!(buildTestFrame(BINARY_ROOM_EVENT, 'comp-test-1', 'room:types', 'data:typed', {
335
+ active: true,
336
+ deleted: false,
337
+ metadata: null,
338
+ count: 0,
339
+ name: '',
340
+ }))
341
+
342
+ expect(handler).toHaveBeenCalledWith({
343
+ active: true,
344
+ deleted: false,
345
+ metadata: null,
346
+ count: 0,
347
+ name: '',
348
+ })
349
+ })
350
+ })
351
+
352
+ describe('cleanup', () => {
353
+ it('unsubscribes binary handler on destroy', () => {
354
+ expect(binaryHandler).not.toBeNull()
355
+ manager.destroy()
356
+ expect(binaryHandler).toBeNull()
357
+ })
358
+ })
359
+ })