@xiboplayer/xmr 0.6.10 → 0.6.12

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,387 @@
1
+ /**
2
+ * XmrClient Tests
3
+ *
4
+ * Tests the native XMR WebSocket client: connection lifecycle,
5
+ * message parsing, TTL checks, generic action dispatch, and reconnection.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
9
+ import { XmrClient } from './xmr-client.js';
10
+
11
+ // --- Mock WebSocket ---
12
+
13
+ class MockWebSocket {
14
+ static instances = [];
15
+
16
+ constructor(url) {
17
+ this.url = url;
18
+ this.readyState = 0; // CONNECTING
19
+ this._listeners = {};
20
+ this.sentMessages = [];
21
+ MockWebSocket.instances.push(this);
22
+ }
23
+
24
+ addEventListener(event, cb) {
25
+ if (!this._listeners[event]) this._listeners[event] = [];
26
+ this._listeners[event].push(cb);
27
+ }
28
+
29
+ send(data) {
30
+ this.sentMessages.push(data);
31
+ }
32
+
33
+ close() {
34
+ this.readyState = 3; // CLOSED
35
+ this._fire('close', {});
36
+ }
37
+
38
+ // Test helpers
39
+ _fire(event, data) {
40
+ (this._listeners[event] || []).forEach(cb => cb(data));
41
+ }
42
+
43
+ _open() {
44
+ this.readyState = 1; // OPEN
45
+ this._fire('open', {});
46
+ }
47
+
48
+ _message(data) {
49
+ this._fire('message', { data });
50
+ }
51
+
52
+ _error() {
53
+ this._fire('error', {});
54
+ }
55
+ }
56
+
57
+ describe('XmrClient', () => {
58
+ let client;
59
+ let originalWebSocket;
60
+
61
+ beforeEach(() => {
62
+ vi.useFakeTimers();
63
+ MockWebSocket.instances = [];
64
+ originalWebSocket = globalThis.WebSocket;
65
+ globalThis.WebSocket = MockWebSocket;
66
+ client = new XmrClient('test-channel');
67
+ });
68
+
69
+ afterEach(() => {
70
+ vi.useRealTimers();
71
+ globalThis.WebSocket = originalWebSocket;
72
+ });
73
+
74
+ function getSocket() {
75
+ return MockWebSocket.instances[MockWebSocket.instances.length - 1];
76
+ }
77
+
78
+ describe('start()', () => {
79
+ it('should open WebSocket and send init message on connect', async () => {
80
+ await client.start('wss://xmr.example.com', 'cms-key-123');
81
+ const ws = getSocket();
82
+ ws._open();
83
+
84
+ expect(ws.url).toBe('wss://xmr.example.com');
85
+ expect(ws.sentMessages).toHaveLength(1);
86
+
87
+ const initMsg = JSON.parse(ws.sentMessages[0]);
88
+ expect(initMsg).toEqual({
89
+ type: 'init',
90
+ key: 'cms-key-123',
91
+ channel: 'test-channel',
92
+ });
93
+ });
94
+
95
+ it('should emit connected on WebSocket open', async () => {
96
+ const spy = vi.fn();
97
+ client.on('connected', spy);
98
+
99
+ await client.start('wss://xmr.example.com', 'cms-key');
100
+ getSocket()._open();
101
+
102
+ expect(spy).toHaveBeenCalledTimes(1);
103
+ expect(client.isConnected).toBe(true);
104
+ });
105
+
106
+ it('should emit disconnected on WebSocket close', async () => {
107
+ const spy = vi.fn();
108
+ client.on('disconnected', spy);
109
+
110
+ await client.start('wss://xmr.example.com', 'cms-key');
111
+ getSocket()._open();
112
+ getSocket().close();
113
+
114
+ expect(spy).toHaveBeenCalledTimes(1);
115
+ expect(client.isConnected).toBe(false);
116
+ });
117
+
118
+ it('should emit error on WebSocket error', async () => {
119
+ const spy = vi.fn();
120
+ client.on('error', spy);
121
+
122
+ await client.start('wss://xmr.example.com', 'cms-key');
123
+ getSocket()._error();
124
+
125
+ expect(spy).toHaveBeenCalledWith('error');
126
+ });
127
+
128
+ it('should close existing socket when start() called again', async () => {
129
+ await client.start('wss://xmr.example.com', 'cms-key');
130
+ const first = getSocket();
131
+ first._open();
132
+
133
+ await client.start('wss://xmr.example.com', 'cms-key');
134
+
135
+ expect(first.readyState).toBe(3); // CLOSED
136
+ expect(MockWebSocket.instances).toHaveLength(2);
137
+ });
138
+ });
139
+
140
+ describe('Message handling', () => {
141
+ beforeEach(async () => {
142
+ await client.start('wss://xmr.example.com', 'cms-key');
143
+ getSocket()._open();
144
+ });
145
+
146
+ it('should handle heartbeat "H" without emitting action', () => {
147
+ const spy = vi.fn();
148
+ client.on('collectNow', spy);
149
+
150
+ const before = client.lastMessageAt;
151
+ vi.advanceTimersByTime(1000);
152
+ getSocket()._message('H');
153
+
154
+ expect(client.lastMessageAt).toBeGreaterThan(before);
155
+ expect(spy).not.toHaveBeenCalled();
156
+ });
157
+
158
+ it('should emit action name with full message for valid JSON', () => {
159
+ const spy = vi.fn();
160
+ client.on('collectNow', spy);
161
+
162
+ const msg = {
163
+ action: 'collectNow',
164
+ createdDt: new Date().toISOString(),
165
+ ttl: 300,
166
+ };
167
+ getSocket()._message(JSON.stringify(msg));
168
+
169
+ expect(spy).toHaveBeenCalledTimes(1);
170
+ expect(spy).toHaveBeenCalledWith(msg);
171
+ });
172
+
173
+ it('should not emit expired messages (TTL check)', () => {
174
+ const spy = vi.fn();
175
+ client.on('collectNow', spy);
176
+
177
+ const msg = {
178
+ action: 'collectNow',
179
+ createdDt: new Date(Date.now() - 600_000).toISOString(), // 10 min ago
180
+ ttl: 300, // 5 min TTL → expired
181
+ };
182
+ getSocket()._message(JSON.stringify(msg));
183
+
184
+ expect(spy).not.toHaveBeenCalled();
185
+ });
186
+
187
+ it('should emit messages without TTL fields (no expiry check)', () => {
188
+ const spy = vi.fn();
189
+ client.on('collectNow', spy);
190
+
191
+ const msg = { action: 'collectNow' };
192
+ getSocket()._message(JSON.stringify(msg));
193
+
194
+ expect(spy).toHaveBeenCalledTimes(1);
195
+ });
196
+
197
+ it('should dispatch any unknown action generically (no hardcoded list)', () => {
198
+ const spy = vi.fn();
199
+ client.on('someFutureAction', spy);
200
+
201
+ const msg = {
202
+ action: 'someFutureAction',
203
+ createdDt: new Date().toISOString(),
204
+ ttl: 300,
205
+ customField: 'hello',
206
+ };
207
+ getSocket()._message(JSON.stringify(msg));
208
+
209
+ expect(spy).toHaveBeenCalledWith(msg);
210
+ });
211
+
212
+ it('should dispatch commandAction with full message including commandCode', () => {
213
+ const spy = vi.fn();
214
+ client.on('commandAction', spy);
215
+
216
+ const msg = {
217
+ action: 'commandAction',
218
+ commandCode: 'collectNow',
219
+ createdDt: new Date().toISOString(),
220
+ ttl: 300,
221
+ };
222
+ getSocket()._message(JSON.stringify(msg));
223
+
224
+ expect(spy).toHaveBeenCalledWith(msg);
225
+ expect(spy.mock.calls[0][0].commandCode).toBe('collectNow');
226
+ });
227
+
228
+ it('should ignore messages without action field', () => {
229
+ const spy = vi.fn();
230
+ client.on('collectNow', spy);
231
+
232
+ getSocket()._message(JSON.stringify({ data: 'no action' }));
233
+
234
+ expect(spy).not.toHaveBeenCalled();
235
+ });
236
+
237
+ it('should handle malformed JSON gracefully', () => {
238
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
239
+
240
+ getSocket()._message('not-json{{{');
241
+
242
+ expect(errorSpy).toHaveBeenCalledWith(
243
+ 'XmrClient: failed to parse message:',
244
+ expect.any(SyntaxError)
245
+ );
246
+ errorSpy.mockRestore();
247
+ });
248
+ });
249
+
250
+ describe('isActive()', () => {
251
+ it('should return true when connected and recent message', async () => {
252
+ await client.start('wss://xmr.example.com', 'cms-key');
253
+ getSocket()._open();
254
+
255
+ expect(client.isActive()).toBe(true);
256
+ });
257
+
258
+ it('should return false after 15min silence', async () => {
259
+ await client.start('wss://xmr.example.com', 'cms-key');
260
+ getSocket()._open();
261
+
262
+ vi.advanceTimersByTime(15 * 60 * 1000 + 1);
263
+
264
+ expect(client.isActive()).toBe(false);
265
+ });
266
+
267
+ it('should return false when not connected', () => {
268
+ expect(client.isActive()).toBe(false);
269
+ });
270
+ });
271
+
272
+ describe('Reconnect interval', () => {
273
+ it('should call start() every 60s when connection wanted but inactive', async () => {
274
+ await client.init();
275
+ await client.start('wss://xmr.example.com', 'cms-key');
276
+ getSocket()._open();
277
+
278
+ // Advance past 15min to make isActive() false
279
+ vi.advanceTimersByTime(15 * 60 * 1000 + 1);
280
+ // Force disconnect state
281
+ client.isConnected = false;
282
+
283
+ const instancesBefore = MockWebSocket.instances.length;
284
+ vi.advanceTimersByTime(60_000);
285
+
286
+ expect(MockWebSocket.instances.length).toBeGreaterThan(instancesBefore);
287
+ });
288
+
289
+ it('should not reconnect if stop() was called', async () => {
290
+ await client.init();
291
+ await client.start('wss://xmr.example.com', 'cms-key');
292
+ getSocket()._open();
293
+
294
+ await client.stop();
295
+ const instancesAfterStop = MockWebSocket.instances.length;
296
+
297
+ vi.advanceTimersByTime(120_000);
298
+
299
+ expect(MockWebSocket.instances.length).toBe(instancesAfterStop);
300
+ });
301
+ });
302
+
303
+ describe('stop()', () => {
304
+ it('should close socket and clear interval', async () => {
305
+ await client.init();
306
+ await client.start('wss://xmr.example.com', 'cms-key');
307
+ getSocket()._open();
308
+
309
+ await client.stop();
310
+
311
+ expect(client.socket).toBeNull();
312
+ expect(client.isConnected).toBe(false);
313
+ expect(client._interval).toBeNull();
314
+ });
315
+
316
+ it('should be safe to call when not started', async () => {
317
+ await expect(client.stop()).resolves.not.toThrow();
318
+ });
319
+ });
320
+
321
+ describe('on() / emit()', () => {
322
+ it('should support multiple listeners per event', () => {
323
+ const spy1 = vi.fn();
324
+ const spy2 = vi.fn();
325
+ client.on('test', spy1);
326
+ client.on('test', spy2);
327
+
328
+ client.emit('test', 'data');
329
+
330
+ expect(spy1).toHaveBeenCalledWith('data');
331
+ expect(spy2).toHaveBeenCalledWith('data');
332
+ });
333
+
334
+ it('should return unsubscribe function', () => {
335
+ const spy = vi.fn();
336
+ const unsub = client.on('test', spy);
337
+
338
+ client.emit('test');
339
+ expect(spy).toHaveBeenCalledTimes(1);
340
+
341
+ unsub();
342
+ client.emit('test');
343
+ expect(spy).toHaveBeenCalledTimes(1); // not called again
344
+ });
345
+
346
+ it('should not throw when emitting with no listeners', () => {
347
+ expect(() => client.emit('nonexistent', 'data')).not.toThrow();
348
+ });
349
+
350
+ it('should catch and log listener errors without breaking other listeners', () => {
351
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
352
+ const badListener = vi.fn(() => { throw new Error('boom'); });
353
+ const goodListener = vi.fn();
354
+
355
+ client.on('test', badListener);
356
+ client.on('test', goodListener);
357
+
358
+ client.emit('test', 'data');
359
+
360
+ expect(badListener).toHaveBeenCalled();
361
+ expect(goodListener).toHaveBeenCalledWith('data');
362
+ expect(errorSpy).toHaveBeenCalledWith(
363
+ expect.stringContaining("listener error for 'test'"),
364
+ expect.any(Error)
365
+ );
366
+ errorSpy.mockRestore();
367
+ });
368
+ });
369
+
370
+ describe('send()', () => {
371
+ it('should send JSON via WebSocket', async () => {
372
+ await client.start('wss://xmr.example.com', 'cms-key');
373
+ getSocket()._open();
374
+
375
+ await client.send('testAction', { foo: 'bar' });
376
+
377
+ // sentMessages[0] is the init message
378
+ const sent = JSON.parse(getSocket().sentMessages[1]);
379
+ expect(sent.action).toBe('testAction');
380
+ expect(sent.foo).toBe('bar');
381
+ });
382
+
383
+ it('should throw when not connected', async () => {
384
+ await expect(client.send('test', {})).rejects.toThrow('Not connected');
385
+ });
386
+ });
387
+ });
@@ -1,10 +1,12 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2024-2026 Pau Aliagas <linuxnow@gmail.com>
1
3
  /**
2
4
  * XMR (Xibo Message Relay) Wrapper
3
5
  *
4
- * Integrates the official @xibosignage/xibo-communication-framework
5
- * to enable real-time push commands from CMS via WebSocket.
6
+ * Integrates the native XmrClient (xmr-client.js) to enable real-time
7
+ * push commands from CMS via WebSocket.
6
8
  *
7
- * Connection lifecycle is delegated to the framework, which has a
9
+ * Connection lifecycle is delegated to XmrClient, which has a
8
10
  * built-in 60s health-check interval that reconnects automatically.
9
11
  * This wrapper only routes events to player callbacks.
10
12
  *
@@ -24,7 +26,7 @@
24
26
  * - currentGeoLocation: Report current geo location to CMS
25
27
  */
26
28
 
27
- import { Xmr } from '@xibosignage/xibo-communication-framework';
29
+ import { XmrClient } from './xmr-client.js';
28
30
  import { createLogger } from '@xiboplayer/utils';
29
31
 
30
32
  const log = createLogger('XMR');
@@ -60,7 +62,7 @@ export class XmrWrapper {
60
62
  if (!this.xmr) {
61
63
  log.info('Initializing connection to:', xmrUrl);
62
64
  const channel = this.config.xmrChannel || `player-${this.config.hardwareKey}`;
63
- this.xmr = new Xmr(channel);
65
+ this.xmr = new XmrClient(channel);
64
66
  this.setupEventHandlers();
65
67
  await this.xmr.init();
66
68
  }
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2024-2026 Pau Aliagas <linuxnow@gmail.com>
1
3
  /**
2
4
  * XmrWrapper Tests
3
5
  *
@@ -13,9 +15,9 @@ import { createSpy, createMockPlayer, createMockConfig, wait } from './test-util
13
15
  const ts = (name, suffix = '') =>
14
16
  expect.stringMatching(new RegExp(`^\\d{2}:\\d{2}:\\d{2}\\.\\d{3} \\[${name}\\]${suffix}$`));
15
17
 
16
- // Mock the official Xmr class
17
- vi.mock('@xibosignage/xibo-communication-framework', () => {
18
- class MockXmr {
18
+ // Mock the native XmrClient
19
+ vi.mock('./xmr-client.js', () => {
20
+ class MockXmrClient {
19
21
  constructor(channel) {
20
22
  this.channel = channel;
21
23
  this.events = new Map();
@@ -54,7 +56,7 @@ vi.mock('@xibosignage/xibo-communication-framework', () => {
54
56
  }
55
57
  }
56
58
 
57
- return { Xmr: MockXmr };
59
+ return { XmrClient: MockXmrClient };
58
60
  });
59
61
 
60
62
  describe('XmrWrapper', () => {