@xiboplayer/stats 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,484 @@
1
+ /**
2
+ * Tests for LogReporter and formatLogs
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
+ import { LogReporter, formatLogs } from './log-reporter.js';
7
+
8
+ describe('LogReporter', () => {
9
+ let reporter;
10
+
11
+ beforeEach(async () => {
12
+ reporter = new LogReporter();
13
+ await reporter.init();
14
+ await reporter.clearAllLogs();
15
+ });
16
+
17
+ afterEach(async () => {
18
+ if (reporter && reporter.db) {
19
+ await reporter.clearAllLogs();
20
+ reporter.db.close();
21
+ }
22
+ });
23
+
24
+ describe('constructor and initialization', () => {
25
+ it('should create a new reporter', () => {
26
+ const r = new LogReporter();
27
+ expect(r).toBeDefined();
28
+ expect(r.db).toBeNull();
29
+ });
30
+
31
+ it('should initialize IndexedDB', async () => {
32
+ const r = new LogReporter();
33
+ await r.init();
34
+ expect(r.db).toBeDefined();
35
+ expect(r.db.name).toBe('xibo-player-logs');
36
+ r.db.close();
37
+ });
38
+
39
+ it('should be idempotent', async () => {
40
+ await reporter.init();
41
+ await reporter.init();
42
+ expect(reporter.db).toBeDefined();
43
+ });
44
+
45
+ it('should handle missing IndexedDB gracefully', async () => {
46
+ const originalIndexedDB = global.indexedDB;
47
+ global.indexedDB = undefined;
48
+
49
+ const r = new LogReporter();
50
+ await expect(r.init()).rejects.toThrow('IndexedDB not available');
51
+
52
+ global.indexedDB = originalIndexedDB;
53
+ });
54
+ });
55
+
56
+ describe('log entry creation', () => {
57
+ it('should log an error message', async () => {
58
+ await reporter.log('error', 'Test error', 'PLAYER');
59
+
60
+ const logs = await reporter.getAllLogs();
61
+ expect(logs.length).toBe(1);
62
+ expect(logs[0].level).toBe('error');
63
+ expect(logs[0].message).toBe('Test error');
64
+ expect(logs[0].category).toBe('PLAYER');
65
+ expect(logs[0].timestamp).toBeInstanceOf(Date);
66
+ expect(logs[0].submitted).toBe(0);
67
+ });
68
+
69
+ it('should log an audit message', async () => {
70
+ await reporter.log('audit', 'User logged in', 'AUTH');
71
+
72
+ const logs = await reporter.getAllLogs();
73
+ expect(logs.length).toBe(1);
74
+ expect(logs[0].level).toBe('audit');
75
+ expect(logs[0].category).toBe('AUTH');
76
+ });
77
+
78
+ it('should log an info message', async () => {
79
+ await reporter.log('info', 'Layout loaded', 'RENDERER');
80
+
81
+ const logs = await reporter.getAllLogs();
82
+ expect(logs.length).toBe(1);
83
+ expect(logs[0].level).toBe('info');
84
+ });
85
+
86
+ it('should log a debug message', async () => {
87
+ await reporter.log('debug', 'Debug info', 'CACHE');
88
+
89
+ const logs = await reporter.getAllLogs();
90
+ expect(logs.length).toBe(1);
91
+ expect(logs[0].level).toBe('debug');
92
+ });
93
+
94
+ it('should use default category', async () => {
95
+ await reporter.log('info', 'Test message');
96
+
97
+ const logs = await reporter.getAllLogs();
98
+ expect(logs[0].category).toBe('PLAYER');
99
+ });
100
+
101
+ it('should handle invalid log level', async () => {
102
+ await reporter.log('invalid', 'Test message', 'PLAYER');
103
+
104
+ const logs = await reporter.getAllLogs();
105
+ expect(logs.length).toBe(1);
106
+ expect(logs[0].level).toBe('info'); // Should default to 'info'
107
+ });
108
+
109
+ it('should log multiple messages', async () => {
110
+ await reporter.log('error', 'Error 1', 'PLAYER');
111
+ await reporter.log('info', 'Info 1', 'PLAYER');
112
+ await reporter.log('debug', 'Debug 1', 'PLAYER');
113
+
114
+ const logs = await reporter.getAllLogs();
115
+ expect(logs.length).toBe(3);
116
+ });
117
+ });
118
+
119
+ describe('shorthand methods', () => {
120
+ it('should use error() shorthand', async () => {
121
+ await reporter.error('Test error', 'PLAYER');
122
+
123
+ const logs = await reporter.getAllLogs();
124
+ expect(logs.length).toBe(1);
125
+ expect(logs[0].level).toBe('error');
126
+ expect(logs[0].message).toBe('Test error');
127
+ });
128
+
129
+ it('should use audit() shorthand', async () => {
130
+ await reporter.audit('User action', 'AUTH');
131
+
132
+ const logs = await reporter.getAllLogs();
133
+ expect(logs.length).toBe(1);
134
+ expect(logs[0].level).toBe('audit');
135
+ });
136
+
137
+ it('should use info() shorthand', async () => {
138
+ await reporter.info('Info message', 'PLAYER');
139
+
140
+ const logs = await reporter.getAllLogs();
141
+ expect(logs.length).toBe(1);
142
+ expect(logs[0].level).toBe('info');
143
+ });
144
+
145
+ it('should use debug() shorthand', async () => {
146
+ await reporter.debug('Debug message', 'CACHE');
147
+
148
+ const logs = await reporter.getAllLogs();
149
+ expect(logs.length).toBe(1);
150
+ expect(logs[0].level).toBe('debug');
151
+ });
152
+
153
+ it('should use default category in shorthand', async () => {
154
+ await reporter.error('Test error');
155
+
156
+ const logs = await reporter.getAllLogs();
157
+ expect(logs[0].category).toBe('PLAYER');
158
+ });
159
+ });
160
+
161
+ describe('log submission flow', () => {
162
+ it('should get unsubmitted logs', async () => {
163
+ await reporter.error('Error 1', 'PLAYER');
164
+ await reporter.info('Info 1', 'PLAYER');
165
+
166
+ const logs = await reporter.getLogsForSubmission();
167
+ expect(logs.length).toBe(2);
168
+ expect(logs.every(l => l.submitted === 0)).toBe(true);
169
+ });
170
+
171
+ it('should respect limit parameter', async () => {
172
+ for (let i = 0; i < 10; i++) {
173
+ await reporter.info(`Message ${i}`, 'PLAYER');
174
+ }
175
+
176
+ const logs = await reporter.getLogsForSubmission(5);
177
+ expect(logs.length).toBe(5);
178
+ });
179
+
180
+ it('should return empty array when no unsubmitted logs', async () => {
181
+ const logs = await reporter.getLogsForSubmission();
182
+ expect(logs).toEqual([]);
183
+ });
184
+
185
+ it('should clear submitted logs', async () => {
186
+ await reporter.error('Error 1', 'PLAYER');
187
+ await reporter.info('Info 1', 'PLAYER');
188
+
189
+ const logs = await reporter.getLogsForSubmission();
190
+ expect(logs.length).toBe(2);
191
+
192
+ await reporter.clearSubmittedLogs(logs);
193
+
194
+ const remaining = await reporter.getAllLogs();
195
+ expect(remaining.length).toBe(0);
196
+ });
197
+
198
+ it('should handle clearing empty array', async () => {
199
+ await reporter.clearSubmittedLogs([]);
200
+ // Should not throw
201
+ });
202
+
203
+ it('should handle clearing null', async () => {
204
+ await reporter.clearSubmittedLogs(null);
205
+ // Should not throw
206
+ });
207
+
208
+ it('should handle invalid log IDs in clearSubmittedLogs', async () => {
209
+ const invalidLogs = [
210
+ { id: null, message: 'Test' },
211
+ { id: undefined, message: 'Test' },
212
+ { message: 'Test' } // No id property
213
+ ];
214
+
215
+ // Should not throw
216
+ await reporter.clearSubmittedLogs(invalidLogs);
217
+ });
218
+ });
219
+
220
+ describe('edge cases', () => {
221
+ it('should handle operations without initialization', async () => {
222
+ const r = new LogReporter();
223
+
224
+ // Should not throw, but should log warnings
225
+ await r.log('error', 'Test', 'PLAYER');
226
+ await r.error('Test');
227
+ await r.info('Test');
228
+
229
+ const logs = await r.getLogsForSubmission();
230
+ expect(logs).toEqual([]);
231
+ });
232
+ });
233
+
234
+ describe('fault reporting', () => {
235
+ it('should report a fault with alertType and eventType', async () => {
236
+ await reporter.reportFault('LAYOUT_LOAD_FAILED', 'Failed to load layout 123');
237
+
238
+ const logs = await reporter.getAllLogs();
239
+ expect(logs.length).toBe(1);
240
+ expect(logs[0].level).toBe('error');
241
+ expect(logs[0].message).toBe('Failed to load layout 123');
242
+ expect(logs[0].alertType).toBe('Player Fault');
243
+ expect(logs[0].eventType).toBe('LAYOUT_LOAD_FAILED');
244
+ });
245
+
246
+ it('should deduplicate same fault code within cooldown', async () => {
247
+ await reporter.reportFault('LAYOUT_LOAD_FAILED', 'First failure', 60000);
248
+ await reporter.reportFault('LAYOUT_LOAD_FAILED', 'Second failure', 60000);
249
+ await reporter.reportFault('LAYOUT_LOAD_FAILED', 'Third failure', 60000);
250
+
251
+ const logs = await reporter.getAllLogs();
252
+ expect(logs.length).toBe(1); // Only first one logged
253
+ expect(logs[0].message).toBe('First failure');
254
+ });
255
+
256
+ it('should allow different fault codes independently', async () => {
257
+ await reporter.reportFault('LAYOUT_LOAD_FAILED', 'Layout error', 60000);
258
+ await reporter.reportFault('MEDIA_DOWNLOAD_FAILED', 'Media error', 60000);
259
+
260
+ const logs = await reporter.getAllLogs();
261
+ expect(logs.length).toBe(2);
262
+ expect(logs[0].eventType).toBe('LAYOUT_LOAD_FAILED');
263
+ expect(logs[1].eventType).toBe('MEDIA_DOWNLOAD_FAILED');
264
+ });
265
+
266
+ it('should allow same fault after cooldown expires', async () => {
267
+ // Report first fault
268
+ await reporter.reportFault('TEST_FAULT', 'First', 1); // 1ms cooldown
269
+
270
+ // Wait for cooldown to expire
271
+ await new Promise(resolve => setTimeout(resolve, 10));
272
+
273
+ await reporter.reportFault('TEST_FAULT', 'Second', 1);
274
+
275
+ const logs = await reporter.getAllLogs();
276
+ expect(logs.length).toBe(2);
277
+ });
278
+
279
+ it('should use default 5-minute cooldown', async () => {
280
+ // Just verify reportFault works with default cooldown (don't wait 5 min)
281
+ await reporter.reportFault('TEST_FAULT', 'Test reason');
282
+
283
+ const logs = await reporter.getAllLogs();
284
+ expect(logs.length).toBe(1);
285
+ expect(logs[0].alertType).toBe('Player Fault');
286
+ });
287
+ });
288
+
289
+ describe('database operations', () => {
290
+ it('should get all logs', async () => {
291
+ await reporter.error('Error 1', 'PLAYER');
292
+ await reporter.info('Info 1', 'CACHE');
293
+ await reporter.debug('Debug 1', 'RENDERER');
294
+
295
+ const logs = await reporter.getAllLogs();
296
+ expect(logs.length).toBe(3);
297
+ expect(logs.some(l => l.level === 'error')).toBe(true);
298
+ expect(logs.some(l => l.level === 'info')).toBe(true);
299
+ expect(logs.some(l => l.level === 'debug')).toBe(true);
300
+ });
301
+
302
+ it('should clear all logs', async () => {
303
+ await reporter.error('Error 1', 'PLAYER');
304
+ await reporter.info('Info 1', 'PLAYER');
305
+
306
+ await reporter.clearAllLogs();
307
+
308
+ const logs = await reporter.getAllLogs();
309
+ expect(logs.length).toBe(0);
310
+ });
311
+ });
312
+ });
313
+
314
+ describe('formatLogs', () => {
315
+ it('should format empty logs', () => {
316
+ const xml = formatLogs([]);
317
+ expect(xml).toBe('<logs></logs>');
318
+ });
319
+
320
+ it('should format null logs', () => {
321
+ const xml = formatLogs(null);
322
+ expect(xml).toBe('<logs></logs>');
323
+ });
324
+
325
+ it('should format a single log entry', () => {
326
+ const logs = [{
327
+ level: 'error',
328
+ message: 'Test error',
329
+ category: 'PLAYER',
330
+ timestamp: new Date('2026-02-10T12:00:00Z')
331
+ }];
332
+
333
+ const xml = formatLogs(logs);
334
+ expect(xml).toContain('<logs>');
335
+ expect(xml).toContain('</logs>');
336
+ expect(xml).toContain('type="error"');
337
+ expect(xml).toContain('message="Test error"');
338
+ expect(xml).toContain('category="PLAYER"');
339
+ expect(xml).toContain('date=');
340
+ });
341
+
342
+ it('should format multiple log entries', () => {
343
+ const logs = [
344
+ {
345
+ level: 'error',
346
+ message: 'Error 1',
347
+ category: 'PLAYER',
348
+ timestamp: new Date('2026-02-10T12:00:00Z')
349
+ },
350
+ {
351
+ level: 'info',
352
+ message: 'Info 1',
353
+ category: 'CACHE',
354
+ timestamp: new Date('2026-02-10T12:01:00Z')
355
+ }
356
+ ];
357
+
358
+ const xml = formatLogs(logs);
359
+ const logCount = (xml.match(/<log /g) || []).length;
360
+ expect(logCount).toBe(2);
361
+ });
362
+
363
+ it('should escape XML special characters in message', () => {
364
+ const logs = [{
365
+ level: 'error',
366
+ message: 'Error: <tag> & "quote" & \'apostrophe\'',
367
+ category: 'PLAYER',
368
+ timestamp: new Date('2026-02-10T12:00:00Z')
369
+ }];
370
+
371
+ const xml = formatLogs(logs);
372
+ expect(xml).toContain('&lt;tag&gt;');
373
+ expect(xml).toContain('&amp;');
374
+ expect(xml).toContain('&quot;');
375
+ expect(xml).toContain('&apos;');
376
+ });
377
+
378
+ it('should escape XML special characters in category', () => {
379
+ const logs = [{
380
+ level: 'error',
381
+ message: 'Test',
382
+ category: 'PLAYER<>',
383
+ timestamp: new Date('2026-02-10T12:00:00Z')
384
+ }];
385
+
386
+ const xml = formatLogs(logs);
387
+ expect(xml).toContain('category="PLAYER&lt;&gt;"');
388
+ });
389
+
390
+ it('should format dates correctly', () => {
391
+ const logs = [{
392
+ level: 'info',
393
+ message: 'Test',
394
+ category: 'PLAYER',
395
+ timestamp: new Date('2026-02-10T12:34:56Z')
396
+ }];
397
+
398
+ const xml = formatLogs(logs);
399
+ // Should contain date in YYYY-MM-DD HH:MM:SS format
400
+ expect(xml).toMatch(/date="\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}"/);
401
+ });
402
+
403
+ it('should handle all log levels', () => {
404
+ const logs = [
405
+ { level: 'error', message: 'Error', category: 'PLAYER', timestamp: new Date() },
406
+ { level: 'audit', message: 'Audit', category: 'PLAYER', timestamp: new Date() },
407
+ { level: 'info', message: 'Info', category: 'PLAYER', timestamp: new Date() },
408
+ { level: 'debug', message: 'Debug', category: 'PLAYER', timestamp: new Date() }
409
+ ];
410
+
411
+ const xml = formatLogs(logs);
412
+ expect(xml).toContain('type="error"');
413
+ expect(xml).toContain('type="audit"');
414
+ expect(xml).toContain('type="info"');
415
+ expect(xml).toContain('type="debug"');
416
+ });
417
+
418
+ it('should include alertType and eventType in XML output for faults', () => {
419
+ const logs = [{
420
+ level: 'error',
421
+ message: 'Layout failed to load',
422
+ category: 'PLAYER',
423
+ timestamp: new Date('2026-02-10T12:00:00Z'),
424
+ alertType: 'Player Fault',
425
+ eventType: 'LAYOUT_LOAD_FAILED'
426
+ }];
427
+
428
+ const xml = formatLogs(logs);
429
+ expect(xml).toContain('alertType="Player Fault"');
430
+ expect(xml).toContain('eventType="LAYOUT_LOAD_FAILED"');
431
+ });
432
+
433
+ it('should not include alertType/eventType when not present', () => {
434
+ const logs = [{
435
+ level: 'info',
436
+ message: 'Normal log',
437
+ category: 'PLAYER',
438
+ timestamp: new Date('2026-02-10T12:00:00Z')
439
+ }];
440
+
441
+ const xml = formatLogs(logs);
442
+ expect(xml).not.toContain('alertType');
443
+ expect(xml).not.toContain('eventType');
444
+ });
445
+
446
+ it('should handle mixed logs with and without fault fields', () => {
447
+ const logs = [
448
+ {
449
+ level: 'error',
450
+ message: 'Fault log',
451
+ category: 'PLAYER',
452
+ timestamp: new Date('2026-02-10T12:00:00Z'),
453
+ alertType: 'Player Fault',
454
+ eventType: 'TEST_FAULT'
455
+ },
456
+ {
457
+ level: 'info',
458
+ message: 'Normal log',
459
+ category: 'PLAYER',
460
+ timestamp: new Date('2026-02-10T12:01:00Z')
461
+ }
462
+ ];
463
+
464
+ const xml = formatLogs(logs);
465
+ const logCount = (xml.match(/<log /g) || []).length;
466
+ expect(logCount).toBe(2);
467
+ // First log has fault fields
468
+ expect(xml).toContain('alertType="Player Fault"');
469
+ expect(xml).toContain('eventType="TEST_FAULT"');
470
+ });
471
+
472
+ it('should handle long messages', () => {
473
+ const longMessage = 'A'.repeat(1000);
474
+ const logs = [{
475
+ level: 'error',
476
+ message: longMessage,
477
+ category: 'PLAYER',
478
+ timestamp: new Date()
479
+ }];
480
+
481
+ const xml = formatLogs(logs);
482
+ expect(xml).toContain(longMessage);
483
+ });
484
+ });