@xiboplayer/xmr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,737 @@
1
+ /**
2
+ * XmrWrapper Tests
3
+ *
4
+ * Comprehensive testing for XMR WebSocket integration
5
+ * Tests connection lifecycle, all CMS commands, reconnection logic, and error handling
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
9
+ import { XmrWrapper } from './xmr-wrapper.js';
10
+ import { createSpy, createMockPlayer, createMockConfig, wait } from './test-utils.js';
11
+
12
+ // Mock the official Xmr class
13
+ vi.mock('@xibosignage/xibo-communication-framework', () => {
14
+ class MockXmr {
15
+ constructor(channel) {
16
+ this.channel = channel;
17
+ this.events = new Map();
18
+ this.connected = false;
19
+ this.init = vi.fn(() => Promise.resolve());
20
+ this.start = vi.fn(() => {
21
+ this.connected = true;
22
+ this.emit('connected');
23
+ return Promise.resolve();
24
+ });
25
+ this.stop = vi.fn(() => {
26
+ this.connected = false;
27
+ this.emit('disconnected');
28
+ return Promise.resolve();
29
+ });
30
+ this.send = vi.fn(() => Promise.resolve());
31
+ }
32
+
33
+ on(event, callback) {
34
+ if (!this.events.has(event)) {
35
+ this.events.set(event, []);
36
+ }
37
+ this.events.get(event).push(callback);
38
+ }
39
+
40
+ emit(event, ...args) {
41
+ const listeners = this.events.get(event);
42
+ if (listeners) {
43
+ listeners.forEach(callback => callback(...args));
44
+ }
45
+ }
46
+
47
+ // Simulate CMS sending a command
48
+ simulateCommand(command, data) {
49
+ this.emit(command, data);
50
+ }
51
+ }
52
+
53
+ return { Xmr: MockXmr };
54
+ });
55
+
56
+ describe('XmrWrapper', () => {
57
+ let wrapper;
58
+ let mockPlayer;
59
+ let mockConfig;
60
+ let xmrInstance;
61
+
62
+ beforeEach(() => {
63
+ vi.clearAllTimers();
64
+ vi.useFakeTimers();
65
+
66
+ mockConfig = createMockConfig();
67
+ mockPlayer = createMockPlayer();
68
+ wrapper = new XmrWrapper(mockConfig, mockPlayer);
69
+ });
70
+
71
+ afterEach(() => {
72
+ vi.clearAllTimers();
73
+ vi.useRealTimers();
74
+ });
75
+
76
+ describe('Constructor', () => {
77
+ it('should create XmrWrapper with config and player', () => {
78
+ expect(wrapper.config).toBe(mockConfig);
79
+ expect(wrapper.player).toBe(mockPlayer);
80
+ expect(wrapper.connected).toBe(false);
81
+ expect(wrapper.xmr).toBeNull();
82
+ });
83
+
84
+ it('should initialize reconnection properties', () => {
85
+ expect(wrapper.reconnectAttempts).toBe(0);
86
+ expect(wrapper.maxReconnectAttempts).toBe(10);
87
+ expect(wrapper.reconnectDelay).toBe(5000);
88
+ expect(wrapper.reconnectTimer).toBeNull();
89
+ });
90
+ });
91
+
92
+ describe('start(xmrUrl, cmsKey)', () => {
93
+ it('should successfully start XMR connection', async () => {
94
+ const result = await wrapper.start('wss://test.xmr.com', 'cms-key-123');
95
+
96
+ expect(result).toBe(true);
97
+ expect(wrapper.connected).toBe(true);
98
+ expect(wrapper.xmr).toBeDefined();
99
+ expect(wrapper.xmr.init).toHaveBeenCalled();
100
+ expect(wrapper.xmr.start).toHaveBeenCalledWith('wss://test.xmr.com', 'cms-key-123');
101
+ expect(wrapper.reconnectAttempts).toBe(0);
102
+ });
103
+
104
+ it('should save connection details for reconnection', async () => {
105
+ await wrapper.start('wss://test.xmr.com', 'cms-key-123');
106
+
107
+ expect(wrapper.lastXmrUrl).toBe('wss://test.xmr.com');
108
+ expect(wrapper.lastCmsKey).toBe('cms-key-123');
109
+ });
110
+
111
+ it('should reuse existing xmr instance on reconnect', async () => {
112
+ await wrapper.start('wss://test.xmr.com', 'cms-key-123');
113
+ const firstInstance = wrapper.xmr;
114
+
115
+ // Simulate disconnect
116
+ wrapper.connected = false;
117
+
118
+ await wrapper.start('wss://test.xmr.com', 'cms-key-123');
119
+ const secondInstance = wrapper.xmr;
120
+
121
+ expect(firstInstance).toBe(secondInstance);
122
+ });
123
+
124
+ it('should use custom xmrChannel if provided', async () => {
125
+ mockConfig.xmrChannel = 'custom-channel';
126
+ const newWrapper = new XmrWrapper(mockConfig, mockPlayer);
127
+
128
+ await newWrapper.start('wss://test.xmr.com', 'cms-key-123');
129
+
130
+ expect(newWrapper.xmr.channel).toBe('custom-channel');
131
+ });
132
+
133
+ it('should use hardware key as channel if xmrChannel not provided', async () => {
134
+ delete mockConfig.xmrChannel;
135
+ const newWrapper = new XmrWrapper(mockConfig, mockPlayer);
136
+
137
+ await newWrapper.start('wss://test.xmr.com', 'cms-key-123');
138
+
139
+ expect(newWrapper.xmr.channel).toBe('player-test-hw-key');
140
+ });
141
+
142
+ it('should handle connection failure gracefully', async () => {
143
+ const newWrapper = new XmrWrapper(mockConfig, mockPlayer);
144
+ await newWrapper.start('wss://test.xmr.com', 'cms-key-123');
145
+
146
+ // Make start fail by replacing the start method
147
+ if (newWrapper.xmr) {
148
+ newWrapper.xmr.start = vi.fn(() => Promise.reject(new Error('Connection failed')));
149
+ newWrapper.connected = false;
150
+
151
+ const result = await newWrapper.start('wss://test.xmr.com', 'cms-key-123');
152
+ expect(result).toBe(false);
153
+ }
154
+ });
155
+
156
+ it('should schedule reconnect on failure', async () => {
157
+ const newWrapper = new XmrWrapper(mockConfig, mockPlayer);
158
+ await newWrapper.start('wss://test.xmr.com', 'cms-key-123');
159
+
160
+ // Simulate failure and check reconnect
161
+ if (newWrapper.xmr) {
162
+ newWrapper.xmr.start = vi.fn(() => Promise.reject(new Error('Connection failed')));
163
+ newWrapper.connected = false;
164
+
165
+ await newWrapper.start('wss://test.xmr.com', 'cms-key-123');
166
+ expect(newWrapper.reconnectTimer).toBeDefined();
167
+ }
168
+ });
169
+
170
+ it('should cancel pending reconnect timer on new start', async () => {
171
+ wrapper.reconnectTimer = setTimeout(() => {}, 5000);
172
+ const timerId = wrapper.reconnectTimer;
173
+
174
+ await wrapper.start('wss://test.xmr.com', 'cms-key-123');
175
+
176
+ expect(wrapper.reconnectTimer).toBeNull();
177
+ });
178
+ });
179
+
180
+ describe('Event Handlers', () => {
181
+ beforeEach(async () => {
182
+ await wrapper.start('wss://test.xmr.com', 'cms-key-123');
183
+ xmrInstance = wrapper.xmr;
184
+ });
185
+
186
+ describe('Connection Events', () => {
187
+ it('should handle connected event', () => {
188
+ wrapper.connected = false;
189
+
190
+ xmrInstance.simulateCommand('connected');
191
+
192
+ expect(wrapper.connected).toBe(true);
193
+ expect(wrapper.reconnectAttempts).toBe(0);
194
+ expect(mockPlayer.updateStatus).toHaveBeenCalledWith('XMR connected');
195
+ });
196
+
197
+ it('should handle disconnected event', () => {
198
+ wrapper.connected = true;
199
+
200
+ xmrInstance.simulateCommand('disconnected');
201
+
202
+ expect(wrapper.connected).toBe(false);
203
+ expect(mockPlayer.updateStatus).toHaveBeenCalledWith('XMR disconnected (polling mode)');
204
+ });
205
+
206
+ it('should schedule reconnect on disconnect', () => {
207
+ wrapper.connected = true;
208
+
209
+ xmrInstance.simulateCommand('disconnected');
210
+
211
+ expect(wrapper.reconnectTimer).toBeDefined();
212
+ });
213
+
214
+ it('should handle error event', () => {
215
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
216
+
217
+ xmrInstance.simulateCommand('error', new Error('Test error'));
218
+
219
+ expect(consoleErrorSpy).toHaveBeenCalled();
220
+ consoleErrorSpy.mockRestore();
221
+ });
222
+ });
223
+
224
+ describe('CMS Commands', () => {
225
+ it('should handle collectNow command', async () => {
226
+ xmrInstance.simulateCommand('collectNow');
227
+
228
+ // Wait for async handler
229
+ await vi.runAllTimersAsync();
230
+
231
+ expect(mockPlayer.collect).toHaveBeenCalled();
232
+ });
233
+
234
+ it('should handle collectNow failure gracefully', async () => {
235
+ mockPlayer.collect.mockRejectedValue(new Error('Collection failed'));
236
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
237
+
238
+ xmrInstance.simulateCommand('collectNow');
239
+ await vi.runAllTimersAsync();
240
+
241
+ // Logger outputs as separate args: '[XMR]', 'collectNow failed:', Error
242
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
243
+ '[XMR]',
244
+ 'collectNow failed:',
245
+ expect.any(Error)
246
+ );
247
+ consoleErrorSpy.mockRestore();
248
+ });
249
+
250
+ it('should handle screenShot command', async () => {
251
+ xmrInstance.simulateCommand('screenShot');
252
+ await vi.runAllTimersAsync();
253
+
254
+ expect(mockPlayer.captureScreenshot).toHaveBeenCalled();
255
+ });
256
+
257
+ it('should handle screenshot command (alternative)', async () => {
258
+ xmrInstance.simulateCommand('screenshot');
259
+ await vi.runAllTimersAsync();
260
+
261
+ expect(mockPlayer.captureScreenshot).toHaveBeenCalled();
262
+ });
263
+
264
+ it('should handle screenShot failure gracefully', async () => {
265
+ mockPlayer.captureScreenshot.mockRejectedValue(new Error('Screenshot failed'));
266
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
267
+
268
+ xmrInstance.simulateCommand('screenShot');
269
+ await vi.runAllTimersAsync();
270
+
271
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
272
+ '[XMR]',
273
+ 'screenShot failed:',
274
+ expect.any(Error)
275
+ );
276
+ consoleErrorSpy.mockRestore();
277
+ });
278
+
279
+ it('should handle licenceCheck command (no-op)', () => {
280
+ // licenceCheck is a debug-level log — just verify it doesn't throw
281
+ expect(() => {
282
+ xmrInstance.simulateCommand('licenceCheck');
283
+ }).not.toThrow();
284
+ });
285
+
286
+ it('should handle changeLayout command', async () => {
287
+ xmrInstance.simulateCommand('changeLayout', 'layout-123');
288
+ await vi.runAllTimersAsync();
289
+
290
+ expect(mockPlayer.changeLayout).toHaveBeenCalledWith('layout-123');
291
+ });
292
+
293
+ it('should handle changeLayout failure gracefully', async () => {
294
+ mockPlayer.changeLayout.mockRejectedValue(new Error('Layout change failed'));
295
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
296
+
297
+ xmrInstance.simulateCommand('changeLayout', 'layout-123');
298
+ await vi.runAllTimersAsync();
299
+
300
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
301
+ '[XMR]',
302
+ 'changeLayout failed:',
303
+ expect.any(Error)
304
+ );
305
+ consoleErrorSpy.mockRestore();
306
+ });
307
+
308
+ it('should handle rekey command', () => {
309
+ // rekey is a debug-level log — just verify it doesn't throw
310
+ expect(() => {
311
+ xmrInstance.simulateCommand('rekey');
312
+ }).not.toThrow();
313
+ });
314
+
315
+ it('should handle criteriaUpdate command', async () => {
316
+ const criteriaData = { displayId: '123', criteria: 'new-criteria' };
317
+
318
+ xmrInstance.simulateCommand('criteriaUpdate', criteriaData);
319
+ await vi.runAllTimersAsync();
320
+
321
+ // Should trigger collect to get updated criteria
322
+ expect(mockPlayer.collect).toHaveBeenCalled();
323
+ });
324
+
325
+ it('should handle criteriaUpdate failure gracefully', async () => {
326
+ mockPlayer.collect.mockRejectedValue(new Error('Collect failed'));
327
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
328
+
329
+ xmrInstance.simulateCommand('criteriaUpdate', {});
330
+ await vi.runAllTimersAsync();
331
+
332
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
333
+ '[XMR]',
334
+ 'criteriaUpdate failed:',
335
+ expect.any(Error)
336
+ );
337
+ consoleErrorSpy.mockRestore();
338
+ });
339
+
340
+ it('should handle currentGeoLocation command', async () => {
341
+ const geoData = { latitude: 40.7128, longitude: -74.0060 };
342
+
343
+ xmrInstance.simulateCommand('currentGeoLocation', geoData);
344
+ await vi.runAllTimersAsync();
345
+
346
+ expect(mockPlayer.reportGeoLocation).toHaveBeenCalledWith(geoData);
347
+ });
348
+
349
+ it('should handle currentGeoLocation when not implemented', async () => {
350
+ delete mockPlayer.reportGeoLocation;
351
+ const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
352
+
353
+ xmrInstance.simulateCommand('currentGeoLocation', {});
354
+ await vi.runAllTimersAsync();
355
+
356
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
357
+ '[XMR]',
358
+ 'Geo location reporting not implemented in player'
359
+ );
360
+ consoleWarnSpy.mockRestore();
361
+ });
362
+
363
+ it('should handle currentGeoLocation failure gracefully', async () => {
364
+ mockPlayer.reportGeoLocation.mockRejectedValue(new Error('Geo location failed'));
365
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
366
+
367
+ xmrInstance.simulateCommand('currentGeoLocation', {});
368
+ await vi.runAllTimersAsync();
369
+
370
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
371
+ '[XMR]',
372
+ 'currentGeoLocation failed:',
373
+ expect.any(Error)
374
+ );
375
+ consoleErrorSpy.mockRestore();
376
+ });
377
+
378
+ it('should handle overlayLayout command', async () => {
379
+ xmrInstance.simulateCommand('overlayLayout', 'overlay-layout-456');
380
+ await vi.runAllTimersAsync();
381
+
382
+ expect(mockPlayer.overlayLayout).toHaveBeenCalledWith('overlay-layout-456');
383
+ });
384
+
385
+ it('should handle overlayLayout failure gracefully', async () => {
386
+ mockPlayer.overlayLayout.mockRejectedValue(new Error('Overlay layout failed'));
387
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
388
+
389
+ xmrInstance.simulateCommand('overlayLayout', 'overlay-layout-456');
390
+ await vi.runAllTimersAsync();
391
+
392
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
393
+ '[XMR]',
394
+ 'overlayLayout failed:',
395
+ expect.any(Error)
396
+ );
397
+ consoleErrorSpy.mockRestore();
398
+ });
399
+
400
+ it('should handle revertToSchedule command', async () => {
401
+ xmrInstance.simulateCommand('revertToSchedule');
402
+ await vi.runAllTimersAsync();
403
+
404
+ expect(mockPlayer.revertToSchedule).toHaveBeenCalled();
405
+ });
406
+
407
+ it('should handle revertToSchedule failure gracefully', async () => {
408
+ mockPlayer.revertToSchedule.mockRejectedValue(new Error('Revert failed'));
409
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
410
+
411
+ xmrInstance.simulateCommand('revertToSchedule');
412
+ await vi.runAllTimersAsync();
413
+
414
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
415
+ '[XMR]',
416
+ 'revertToSchedule failed:',
417
+ expect.any(Error)
418
+ );
419
+ consoleErrorSpy.mockRestore();
420
+ });
421
+
422
+ it('should handle purgeAll command', async () => {
423
+ xmrInstance.simulateCommand('purgeAll');
424
+ await vi.runAllTimersAsync();
425
+
426
+ expect(mockPlayer.purgeAll).toHaveBeenCalled();
427
+ });
428
+
429
+ it('should handle purgeAll failure gracefully', async () => {
430
+ mockPlayer.purgeAll.mockRejectedValue(new Error('Purge failed'));
431
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
432
+
433
+ xmrInstance.simulateCommand('purgeAll');
434
+ await vi.runAllTimersAsync();
435
+
436
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
437
+ '[XMR]',
438
+ 'purgeAll failed:',
439
+ expect.any(Error)
440
+ );
441
+ consoleErrorSpy.mockRestore();
442
+ });
443
+
444
+ it('should handle commandAction with data object', async () => {
445
+ const commandData = { commandCode: 'reboot', commands: '--force' };
446
+
447
+ xmrInstance.simulateCommand('commandAction', commandData);
448
+ await vi.runAllTimersAsync();
449
+
450
+ expect(mockPlayer.executeCommand).toHaveBeenCalledWith('reboot', '--force');
451
+ });
452
+
453
+ it('should handle commandAction with string data (fallback)', async () => {
454
+ xmrInstance.simulateCommand('commandAction', 'reboot');
455
+ await vi.runAllTimersAsync();
456
+
457
+ expect(mockPlayer.executeCommand).toHaveBeenCalledWith('reboot', undefined);
458
+ });
459
+
460
+ it('should handle commandAction failure gracefully', async () => {
461
+ mockPlayer.executeCommand.mockRejectedValue(new Error('Command failed'));
462
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
463
+
464
+ xmrInstance.simulateCommand('commandAction', { commandCode: 'reboot' });
465
+ await vi.runAllTimersAsync();
466
+
467
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
468
+ '[XMR]',
469
+ 'commandAction failed:',
470
+ expect.any(Error)
471
+ );
472
+ consoleErrorSpy.mockRestore();
473
+ });
474
+
475
+ it('should handle triggerWebhook with data object', async () => {
476
+ const webhookData = { triggerCode: 'webhook-abc' };
477
+
478
+ xmrInstance.simulateCommand('triggerWebhook', webhookData);
479
+ await vi.runAllTimersAsync();
480
+
481
+ expect(mockPlayer.triggerWebhook).toHaveBeenCalledWith('webhook-abc');
482
+ });
483
+
484
+ it('should handle triggerWebhook with string data (fallback)', async () => {
485
+ xmrInstance.simulateCommand('triggerWebhook', 'webhook-xyz');
486
+ await vi.runAllTimersAsync();
487
+
488
+ expect(mockPlayer.triggerWebhook).toHaveBeenCalledWith('webhook-xyz');
489
+ });
490
+
491
+ it('should handle triggerWebhook failure gracefully', async () => {
492
+ mockPlayer.triggerWebhook.mockImplementation(() => {
493
+ throw new Error('Webhook failed');
494
+ });
495
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
496
+
497
+ xmrInstance.simulateCommand('triggerWebhook', { triggerCode: 'webhook-abc' });
498
+ await vi.runAllTimersAsync();
499
+
500
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
501
+ '[XMR]',
502
+ 'triggerWebhook failed:',
503
+ expect.any(Error)
504
+ );
505
+ consoleErrorSpy.mockRestore();
506
+ });
507
+
508
+ it('should handle dataUpdate command', async () => {
509
+ xmrInstance.simulateCommand('dataUpdate');
510
+ await vi.runAllTimersAsync();
511
+
512
+ expect(mockPlayer.refreshDataConnectors).toHaveBeenCalled();
513
+ });
514
+
515
+ it('should handle dataUpdate failure gracefully', async () => {
516
+ mockPlayer.refreshDataConnectors.mockImplementation(() => {
517
+ throw new Error('Data refresh failed');
518
+ });
519
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
520
+
521
+ xmrInstance.simulateCommand('dataUpdate');
522
+ await vi.runAllTimersAsync();
523
+
524
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
525
+ '[XMR]',
526
+ 'dataUpdate failed:',
527
+ expect.any(Error)
528
+ );
529
+ consoleErrorSpy.mockRestore();
530
+ });
531
+ });
532
+ });
533
+
534
+ describe('Reconnection Logic', () => {
535
+ it('should schedule reconnect with exponential backoff', async () => {
536
+ const newWrapper = new XmrWrapper(mockConfig, mockPlayer);
537
+ await newWrapper.start('wss://test.xmr.com', 'cms-key-123');
538
+
539
+ // Make subsequent starts fail
540
+ if (newWrapper.xmr) {
541
+ newWrapper.xmr.start = vi.fn(() => Promise.reject(new Error('Connection failed')));
542
+ newWrapper.connected = false;
543
+
544
+ // First reconnect attempt
545
+ await newWrapper.start('wss://test.xmr.com', 'cms-key-123');
546
+ expect(newWrapper.reconnectAttempts).toBe(1);
547
+
548
+ // Second reconnect attempt (should have longer delay)
549
+ await newWrapper.start('wss://test.xmr.com', 'cms-key-123');
550
+ expect(newWrapper.reconnectAttempts).toBe(2);
551
+ }
552
+ });
553
+
554
+ it('should stop reconnecting after max attempts', async () => {
555
+ wrapper.reconnectAttempts = wrapper.maxReconnectAttempts;
556
+
557
+ wrapper.scheduleReconnect('wss://test.xmr.com', 'cms-key-123');
558
+
559
+ expect(wrapper.reconnectTimer).toBeNull();
560
+ });
561
+
562
+ it('should cancel existing timer before scheduling new reconnect', () => {
563
+ wrapper.reconnectTimer = setTimeout(() => {}, 5000);
564
+ const firstTimer = wrapper.reconnectTimer;
565
+
566
+ wrapper.scheduleReconnect('wss://test.xmr.com', 'cms-key-123');
567
+
568
+ expect(wrapper.reconnectTimer).not.toBe(firstTimer);
569
+ });
570
+
571
+ it('should reset reconnect attempts on successful connection', async () => {
572
+ wrapper.reconnectAttempts = 5;
573
+
574
+ await wrapper.start('wss://test.xmr.com', 'cms-key-123');
575
+
576
+ expect(wrapper.reconnectAttempts).toBe(0);
577
+ });
578
+ });
579
+
580
+ describe('stop()', () => {
581
+ it('should stop XMR connection', async () => {
582
+ await wrapper.start('wss://test.xmr.com', 'cms-key-123');
583
+ wrapper.connected = true;
584
+
585
+ await wrapper.stop();
586
+
587
+ expect(wrapper.xmr.stop).toHaveBeenCalled();
588
+ expect(wrapper.connected).toBe(false);
589
+ });
590
+
591
+ it('should cancel pending reconnect timer', async () => {
592
+ wrapper.reconnectTimer = setTimeout(() => {}, 5000);
593
+
594
+ await wrapper.stop();
595
+
596
+ expect(wrapper.reconnectTimer).toBeNull();
597
+ });
598
+
599
+ it('should handle stop when not started', async () => {
600
+ await expect(wrapper.stop()).resolves.not.toThrow();
601
+ });
602
+
603
+ it('should handle stop errors gracefully', async () => {
604
+ await wrapper.start('wss://test.xmr.com', 'cms-key-123');
605
+ wrapper.xmr.stop.mockRejectedValue(new Error('Stop failed'));
606
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
607
+
608
+ await wrapper.stop();
609
+
610
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
611
+ '[XMR]',
612
+ 'Error stopping:',
613
+ expect.any(Error)
614
+ );
615
+ consoleErrorSpy.mockRestore();
616
+ });
617
+ });
618
+
619
+ describe('isConnected()', () => {
620
+ it('should return false when not connected', () => {
621
+ expect(wrapper.isConnected()).toBe(false);
622
+ });
623
+
624
+ it('should return true when connected', async () => {
625
+ await wrapper.start('wss://test.xmr.com', 'cms-key-123');
626
+
627
+ expect(wrapper.isConnected()).toBe(true);
628
+ });
629
+
630
+ it('should return false after disconnect', async () => {
631
+ await wrapper.start('wss://test.xmr.com', 'cms-key-123');
632
+ await wrapper.stop();
633
+
634
+ expect(wrapper.isConnected()).toBe(false);
635
+ });
636
+ });
637
+
638
+ describe('send(action, data)', () => {
639
+ beforeEach(async () => {
640
+ await wrapper.start('wss://test.xmr.com', 'cms-key-123');
641
+ });
642
+
643
+ it('should send message when connected', async () => {
644
+ const result = await wrapper.send('testAction', { test: 'data' });
645
+
646
+ expect(result).toBe(true);
647
+ expect(wrapper.xmr.send).toHaveBeenCalledWith('testAction', { test: 'data' });
648
+ });
649
+
650
+ it('should not send when disconnected', async () => {
651
+ wrapper.connected = false;
652
+
653
+ const result = await wrapper.send('testAction', { test: 'data' });
654
+
655
+ expect(result).toBe(false);
656
+ expect(wrapper.xmr.send).not.toHaveBeenCalled();
657
+ });
658
+
659
+ it('should handle send errors', async () => {
660
+ wrapper.xmr.send.mockRejectedValue(new Error('Send failed'));
661
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
662
+
663
+ const result = await wrapper.send('testAction', { test: 'data' });
664
+
665
+ expect(result).toBe(false);
666
+ expect(consoleErrorSpy).toHaveBeenCalled();
667
+ consoleErrorSpy.mockRestore();
668
+ });
669
+
670
+ it('should not send when xmr not initialized', async () => {
671
+ const newWrapper = new XmrWrapper(mockConfig, mockPlayer);
672
+
673
+ const result = await newWrapper.send('testAction', {});
674
+
675
+ expect(result).toBe(false);
676
+ });
677
+ });
678
+
679
+ describe('Edge Cases', () => {
680
+ it('should handle multiple simultaneous commands', async () => {
681
+ await wrapper.start('wss://test.xmr.com', 'cms-key-123');
682
+ const xmr = wrapper.xmr;
683
+
684
+ xmr.simulateCommand('collectNow');
685
+ xmr.simulateCommand('screenShot');
686
+ xmr.simulateCommand('changeLayout', 'layout-123');
687
+
688
+ await vi.runAllTimersAsync();
689
+
690
+ expect(mockPlayer.collect).toHaveBeenCalled();
691
+ expect(mockPlayer.captureScreenshot).toHaveBeenCalled();
692
+ expect(mockPlayer.changeLayout).toHaveBeenCalledWith('layout-123');
693
+ });
694
+
695
+ it('should handle rapid connect/disconnect cycles', async () => {
696
+ await wrapper.start('wss://test.xmr.com', 'cms-key-123');
697
+ await wrapper.stop();
698
+ await wrapper.start('wss://test.xmr.com', 'cms-key-123');
699
+ await wrapper.stop();
700
+
701
+ expect(wrapper.connected).toBe(false);
702
+ });
703
+
704
+ it('should maintain connection state across errors', async () => {
705
+ await wrapper.start('wss://test.xmr.com', 'cms-key-123');
706
+ mockPlayer.collect.mockRejectedValue(new Error('Collect failed'));
707
+
708
+ wrapper.xmr.simulateCommand('collectNow');
709
+ await vi.runAllTimersAsync();
710
+
711
+ expect(wrapper.connected).toBe(true);
712
+ });
713
+ });
714
+
715
+ describe('Memory Management', () => {
716
+ it('should clean up timers on stop', async () => {
717
+ wrapper.reconnectTimer = setTimeout(() => {}, 5000);
718
+
719
+ await wrapper.stop();
720
+
721
+ expect(wrapper.reconnectTimer).toBeNull();
722
+ });
723
+
724
+ it('should allow garbage collection after stop', async () => {
725
+ await wrapper.start('wss://test.xmr.com', 'cms-key-123');
726
+ await wrapper.stop();
727
+
728
+ // The disconnected event handler may schedule a reconnect,
729
+ // but stop() should cancel it
730
+ // Allow up to a small window for async cleanup
731
+ await vi.runAllTimersAsync();
732
+
733
+ // After all timers run and stop completes, timer should be null
734
+ expect(wrapper.reconnectTimer).toBeNull();
735
+ });
736
+ });
737
+ });