@xiboplayer/utils 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,473 @@
1
+ /**
2
+ * Config Tests
3
+ *
4
+ * Tests for configuration management with localStorage persistence
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
8
+ import { Config } from './config.js';
9
+
10
+ describe('Config', () => {
11
+ let config;
12
+ let mockLocalStorage;
13
+ let mockRandomUUID;
14
+
15
+ beforeEach(() => {
16
+ // Mock localStorage
17
+ mockLocalStorage = {
18
+ data: {},
19
+ getItem(key) {
20
+ return this.data[key] || null;
21
+ },
22
+ setItem(key, value) {
23
+ this.data[key] = value;
24
+ },
25
+ removeItem(key) {
26
+ delete this.data[key];
27
+ },
28
+ clear() {
29
+ this.data = {};
30
+ }
31
+ };
32
+
33
+ vi.stubGlobal('localStorage', mockLocalStorage);
34
+
35
+ // Mock crypto.randomUUID using vi.stubGlobal (jsdom makes crypto read-only)
36
+ mockRandomUUID = vi.fn(() => '12345678-1234-4567-8901-234567890abc');
37
+ vi.stubGlobal('crypto', {
38
+ randomUUID: mockRandomUUID
39
+ });
40
+
41
+ // Ensure no env vars interfere with localStorage path
42
+ delete process.env.CMS_ADDRESS;
43
+ delete process.env.CMS_URL;
44
+ delete process.env.CMS_KEY;
45
+ delete process.env.DISPLAY_NAME;
46
+ delete process.env.HARDWARE_KEY;
47
+ delete process.env.XMR_CHANNEL;
48
+ });
49
+
50
+ afterEach(() => {
51
+ vi.restoreAllMocks();
52
+ vi.unstubAllGlobals();
53
+ });
54
+
55
+ describe('Initialization', () => {
56
+ it('should create new config when localStorage is empty', () => {
57
+ config = new Config();
58
+
59
+ expect(config.data).toBeDefined();
60
+ expect(config.data.cmsAddress).toBe('');
61
+ expect(config.data.cmsKey).toBe('');
62
+ expect(config.data.displayName).toBe('');
63
+ expect(config.data.hardwareKey).toMatch(/^pwa-/);
64
+ expect(config.data.xmrChannel).toMatch(/^[0-9a-f-]{36}$/);
65
+ });
66
+
67
+ it('should generate stable hardware key on first load', () => {
68
+ config = new Config();
69
+
70
+ const hwKey = config.data.hardwareKey;
71
+ expect(hwKey).toMatch(/^pwa-[0-9a-f]{28}$/);
72
+ expect(hwKey).toBe('pwa-1234567812344567890123456789');
73
+ });
74
+
75
+ it('should save config to localStorage on creation', () => {
76
+ config = new Config();
77
+
78
+ const stored = JSON.parse(mockLocalStorage.getItem('xibo_config'));
79
+ expect(stored).toEqual(config.data);
80
+ });
81
+
82
+ it('should load existing config from localStorage', () => {
83
+ const existingConfig = {
84
+ cmsAddress: 'https://test.cms.com',
85
+ cmsKey: 'test-key',
86
+ displayName: 'Test Display',
87
+ hardwareKey: 'pwa-existinghardwarekey1234567',
88
+ xmrChannel: '12345678-1234-4567-8901-234567890abc'
89
+ };
90
+
91
+ mockLocalStorage.setItem('xibo_config', JSON.stringify(existingConfig));
92
+
93
+ config = new Config();
94
+
95
+ expect(config.data).toEqual(existingConfig);
96
+ });
97
+
98
+ it('should regenerate hardware key if invalid in stored config', () => {
99
+ const invalidConfig = {
100
+ cmsAddress: 'https://test.cms.com',
101
+ cmsKey: 'test-key',
102
+ displayName: 'Test Display',
103
+ hardwareKey: 'short', // Invalid: too short
104
+ xmrChannel: '12345678-1234-4567-8901-234567890abc'
105
+ };
106
+
107
+ mockLocalStorage.setItem('xibo_config', JSON.stringify(invalidConfig));
108
+
109
+ config = new Config();
110
+
111
+ expect(config.data.hardwareKey).not.toBe('short');
112
+ expect(config.data.hardwareKey).toMatch(/^pwa-[0-9a-f]{28}$/);
113
+ });
114
+
115
+ it('should handle corrupted JSON in localStorage', () => {
116
+ mockLocalStorage.setItem('xibo_config', 'invalid-json{');
117
+
118
+ config = new Config();
119
+
120
+ // Should create new config
121
+ expect(config.data.hardwareKey).toMatch(/^pwa-/);
122
+ expect(config.isConfigured()).toBe(false);
123
+ });
124
+ });
125
+
126
+ describe('Hardware Key Generation', () => {
127
+ beforeEach(() => {
128
+ config = new Config();
129
+ });
130
+
131
+ it('should generate UUID-based hardware key when crypto.randomUUID available', () => {
132
+ const hwKey = config.generateStableHardwareKey();
133
+
134
+ expect(hwKey).toBe('pwa-1234567812344567890123456789');
135
+ expect(mockRandomUUID).toHaveBeenCalled();
136
+ });
137
+
138
+ it('should fallback to random hex when crypto.randomUUID unavailable', () => {
139
+ vi.stubGlobal('crypto', {});
140
+ vi.spyOn(Math, 'random').mockReturnValue(0.5);
141
+
142
+ const hwKey = config.generateStableHardwareKey();
143
+
144
+ expect(hwKey).toMatch(/^pwa-[0-9a-f]{28}$/);
145
+ expect(hwKey.length).toBe(32); // 'pwa-' + 28 chars
146
+ });
147
+
148
+ it('should ensure hardware key never becomes undefined', () => {
149
+ config.data.hardwareKey = undefined;
150
+
151
+ const hwKey = config.hardwareKey; // Getter auto-repairs
152
+
153
+ expect(hwKey).toMatch(/^pwa-/);
154
+ expect(config.data.hardwareKey).toBe(hwKey);
155
+ });
156
+ });
157
+
158
+ describe('XMR Channel Generation', () => {
159
+ beforeEach(() => {
160
+ config = new Config();
161
+ });
162
+
163
+ it('should generate valid UUID v4', () => {
164
+ const channel = config.generateXmrChannel();
165
+
166
+ expect(channel).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
167
+ });
168
+
169
+ it('should generate different UUIDs each time', () => {
170
+ const channel1 = config.generateXmrChannel();
171
+ const channel2 = config.generateXmrChannel();
172
+
173
+ expect(channel1).not.toBe(channel2);
174
+ });
175
+ });
176
+
177
+ describe('Hash Function (FNV-1a)', () => {
178
+ beforeEach(() => {
179
+ config = new Config();
180
+ });
181
+
182
+ it('should generate 32-character hex hash', () => {
183
+ const hash = config.hash('test string');
184
+
185
+ expect(hash).toMatch(/^[0-9a-f]{32}$/);
186
+ });
187
+
188
+ it('should be deterministic for same input', () => {
189
+ const hash1 = config.hash('test');
190
+ const hash2 = config.hash('test');
191
+
192
+ expect(hash1).toBe(hash2);
193
+ });
194
+
195
+ it('should produce different hashes for different inputs', () => {
196
+ const hash1 = config.hash('test1');
197
+ const hash2 = config.hash('test2');
198
+
199
+ expect(hash1).not.toBe(hash2);
200
+ });
201
+
202
+ it('should handle empty string', () => {
203
+ const hash = config.hash('');
204
+
205
+ expect(hash).toMatch(/^[0-9a-f]{32}$/);
206
+ });
207
+
208
+ it('should produce good entropy for similar inputs', () => {
209
+ const hash1 = config.hash('a');
210
+ const hash2 = config.hash('b');
211
+
212
+ // Hashes should be completely different (not just 1 character difference)
213
+ let differences = 0;
214
+ for (let i = 0; i < hash1.length; i++) {
215
+ if (hash1[i] !== hash2[i]) differences++;
216
+ }
217
+
218
+ expect(differences).toBeGreaterThan(15); // At least half different
219
+ });
220
+ });
221
+
222
+ describe('Canvas Fingerprint', () => {
223
+ let createElementSpy;
224
+
225
+ beforeEach(() => {
226
+ config = new Config();
227
+
228
+ // Mock canvas via spying on document.createElement
229
+ const mockCanvas = {
230
+ getContext: vi.fn(() => ({
231
+ textBaseline: '',
232
+ font: '',
233
+ fillStyle: '',
234
+ fillRect: vi.fn(),
235
+ fillText: vi.fn()
236
+ })),
237
+ toDataURL: vi.fn(() => 'data:image/png;base64,mockdata')
238
+ };
239
+
240
+ createElementSpy = vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas);
241
+ });
242
+
243
+ afterEach(() => {
244
+ createElementSpy.mockRestore();
245
+ });
246
+
247
+ it('should generate canvas fingerprint', () => {
248
+ const fingerprint = config.getCanvasFingerprint();
249
+
250
+ expect(fingerprint).toBe('data:image/png;base64,mockdata');
251
+ expect(createElementSpy).toHaveBeenCalledWith('canvas');
252
+ });
253
+
254
+ it('should return "no-canvas" when canvas context unavailable', () => {
255
+ const mockCanvas = {
256
+ getContext: vi.fn(() => null)
257
+ };
258
+
259
+ createElementSpy.mockReturnValue(mockCanvas);
260
+
261
+ const fingerprint = config.getCanvasFingerprint();
262
+
263
+ expect(fingerprint).toBe('no-canvas');
264
+ });
265
+
266
+ it('should return "canvas-error" on exception', () => {
267
+ createElementSpy.mockImplementation(() => {
268
+ throw new Error('Canvas not supported');
269
+ });
270
+
271
+ const fingerprint = config.getCanvasFingerprint();
272
+
273
+ expect(fingerprint).toBe('canvas-error');
274
+ });
275
+ });
276
+
277
+ describe('Configuration Getters/Setters', () => {
278
+ beforeEach(() => {
279
+ config = new Config();
280
+ });
281
+
282
+ it('should get/set cmsAddress', () => {
283
+ expect(config.cmsAddress).toBe('');
284
+
285
+ config.cmsAddress = 'https://new.cms.com';
286
+
287
+ expect(config.cmsAddress).toBe('https://new.cms.com');
288
+ expect(config.data.cmsAddress).toBe('https://new.cms.com');
289
+ });
290
+
291
+ it('should save to localStorage when cmsAddress set', () => {
292
+ config.cmsAddress = 'https://test.com';
293
+
294
+ const stored = JSON.parse(mockLocalStorage.getItem('xibo_config'));
295
+ expect(stored.cmsAddress).toBe('https://test.com');
296
+ });
297
+
298
+ it('should get/set cmsKey', () => {
299
+ config.cmsKey = 'new-key';
300
+
301
+ expect(config.cmsKey).toBe('new-key');
302
+ expect(config.data.cmsKey).toBe('new-key');
303
+ });
304
+
305
+ it('should get/set displayName', () => {
306
+ config.displayName = 'New Display';
307
+
308
+ expect(config.displayName).toBe('New Display');
309
+ expect(config.data.displayName).toBe('New Display');
310
+ });
311
+
312
+ it('should get hardwareKey (read-only via data)', () => {
313
+ const originalKey = config.hardwareKey;
314
+
315
+ expect(config.hardwareKey).toBe(originalKey);
316
+ expect(config.hardwareKey).toMatch(/^pwa-/);
317
+ });
318
+
319
+ it('should get xmrChannel (read-only)', () => {
320
+ const channel = config.xmrChannel;
321
+
322
+ expect(channel).toMatch(/^[0-9a-f-]{36}$/);
323
+ });
324
+ });
325
+
326
+ describe('isConfigured()', () => {
327
+ beforeEach(() => {
328
+ config = new Config();
329
+ });
330
+
331
+ it('should return false when config incomplete', () => {
332
+ expect(config.isConfigured()).toBe(false);
333
+ });
334
+
335
+ it('should return false when only cmsAddress set', () => {
336
+ config.cmsAddress = 'https://test.com';
337
+
338
+ expect(config.isConfigured()).toBe(false);
339
+ });
340
+
341
+ it('should return false when only cmsKey set', () => {
342
+ config.cmsKey = 'test-key';
343
+
344
+ expect(config.isConfigured()).toBe(false);
345
+ });
346
+
347
+ it('should return false when only displayName set', () => {
348
+ config.displayName = 'Test Display';
349
+
350
+ expect(config.isConfigured()).toBe(false);
351
+ });
352
+
353
+ it('should return true when all required fields set', () => {
354
+ config.cmsAddress = 'https://test.com';
355
+ config.cmsKey = 'test-key';
356
+ config.displayName = 'Test Display';
357
+
358
+ expect(config.isConfigured()).toBe(true);
359
+ });
360
+ });
361
+
362
+ describe('save()', () => {
363
+ beforeEach(() => {
364
+ config = new Config();
365
+ });
366
+
367
+ it('should save current config to localStorage', () => {
368
+ config.data.cmsAddress = 'https://manual.com';
369
+ config.data.cmsKey = 'manual-key';
370
+
371
+ config.save();
372
+
373
+ const stored = JSON.parse(mockLocalStorage.getItem('xibo_config'));
374
+ expect(stored.cmsAddress).toBe('https://manual.com');
375
+ expect(stored.cmsKey).toBe('manual-key');
376
+ });
377
+
378
+ it('should auto-save when setters used', () => {
379
+ config.cmsAddress = 'https://auto.com';
380
+
381
+ const stored = JSON.parse(mockLocalStorage.getItem('xibo_config'));
382
+ expect(stored.cmsAddress).toBe('https://auto.com');
383
+ });
384
+ });
385
+
386
+ describe('Backwards Compatibility', () => {
387
+ beforeEach(() => {
388
+ config = new Config();
389
+ });
390
+
391
+ it('should support generateHardwareKey() alias', () => {
392
+ const key1 = config.generateHardwareKey();
393
+ const key2 = config.generateStableHardwareKey();
394
+
395
+ // Both should generate valid keys
396
+ expect(key1).toMatch(/^pwa-[0-9a-f]{28}$/);
397
+ expect(key2).toMatch(/^pwa-[0-9a-f]{28}$/);
398
+ });
399
+ });
400
+
401
+ describe('Edge Cases', () => {
402
+ it('should handle missing hardwareKey in loaded config', () => {
403
+ mockLocalStorage.setItem('xibo_config', JSON.stringify({
404
+ cmsAddress: 'https://test.com',
405
+ cmsKey: 'test-key',
406
+ displayName: 'Test'
407
+ // hardwareKey missing
408
+ }));
409
+
410
+ config = new Config();
411
+
412
+ // Should auto-generate
413
+ expect(config.hardwareKey).toMatch(/^pwa-/);
414
+ });
415
+
416
+ it('should handle null values in config', () => {
417
+ mockLocalStorage.setItem('xibo_config', JSON.stringify({
418
+ cmsAddress: null,
419
+ cmsKey: null,
420
+ displayName: null,
421
+ hardwareKey: 'pwa-1234567812344567890123456789',
422
+ xmrChannel: '12345678-1234-4567-8901-234567890abc'
423
+ }));
424
+
425
+ config = new Config();
426
+
427
+ expect(config.isConfigured()).toBe(false);
428
+ expect(config.cmsAddress).toBeNull();
429
+ });
430
+
431
+ it('should handle very long strings', () => {
432
+ config = new Config();
433
+
434
+ const longString = 'a'.repeat(10000);
435
+ const hash = config.hash(longString);
436
+
437
+ expect(hash).toMatch(/^[0-9a-f]{32}$/);
438
+ });
439
+
440
+ it('should handle unicode in hash', () => {
441
+ config = new Config();
442
+
443
+ const hash = config.hash('测试中文🎉');
444
+
445
+ expect(hash).toMatch(/^[0-9a-f]{32}$/);
446
+ });
447
+ });
448
+
449
+ describe('Persistence', () => {
450
+ it('should persist hardware key across multiple instances', () => {
451
+ const config1 = new Config();
452
+ const key1 = config1.hardwareKey;
453
+
454
+ const config2 = new Config();
455
+ const key2 = config2.hardwareKey;
456
+
457
+ expect(key1).toBe(key2);
458
+ });
459
+
460
+ it('should persist configuration changes', () => {
461
+ const config1 = new Config();
462
+ config1.cmsAddress = 'https://persist.com';
463
+ config1.cmsKey = 'persist-key';
464
+ config1.displayName = 'Persist Display';
465
+
466
+ const config2 = new Config();
467
+
468
+ expect(config2.cmsAddress).toBe('https://persist.com');
469
+ expect(config2.cmsKey).toBe('persist-key');
470
+ expect(config2.displayName).toBe('Persist Display');
471
+ });
472
+ });
473
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Simple EventEmitter implementation
3
+ * Compatible with both browser and Node.js
4
+ */
5
+
6
+ export class EventEmitter {
7
+ constructor() {
8
+ this.events = new Map();
9
+ }
10
+
11
+ /**
12
+ * Register event listener
13
+ * @param {string} event - Event name
14
+ * @param {Function} callback - Callback function
15
+ */
16
+ on(event, callback) {
17
+ if (!this.events.has(event)) {
18
+ this.events.set(event, []);
19
+ }
20
+ this.events.get(event).push(callback);
21
+ }
22
+
23
+ /**
24
+ * Register one-time event listener
25
+ * @param {string} event - Event name
26
+ * @param {Function} callback - Callback function
27
+ */
28
+ once(event, callback) {
29
+ const wrapper = (...args) => {
30
+ callback(...args);
31
+ this.off(event, wrapper);
32
+ };
33
+ this.on(event, wrapper);
34
+ }
35
+
36
+ /**
37
+ * Remove event listener
38
+ * @param {string} event - Event name
39
+ * @param {Function} callback - Callback function
40
+ */
41
+ off(event, callback) {
42
+ if (!this.events.has(event)) return;
43
+
44
+ const listeners = this.events.get(event);
45
+ const index = listeners.indexOf(callback);
46
+ if (index !== -1) {
47
+ listeners.splice(index, 1);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Emit event
53
+ * @param {string} event - Event name
54
+ * @param {...any} args - Arguments to pass to listeners
55
+ */
56
+ emit(event, ...args) {
57
+ if (!this.events.has(event)) return;
58
+
59
+ // Make a copy to handle listeners that remove themselves during emission
60
+ const listeners = this.events.get(event).slice();
61
+ for (const listener of listeners) {
62
+ listener(...args);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Remove all listeners for an event
68
+ * @param {string} event - Event name (optional, removes all if not specified)
69
+ */
70
+ removeAllListeners(event) {
71
+ if (event) {
72
+ this.events.delete(event);
73
+ } else {
74
+ this.events.clear();
75
+ }
76
+ }
77
+ }