claw-dashboard 1.8.4 → 2.0.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.
Files changed (135) hide show
  1. package/.c8rc.json +38 -0
  2. package/.dockerignore +68 -0
  3. package/.github/dependabot.yml +45 -0
  4. package/.github/pull_request_template.md +39 -0
  5. package/.github/workflows/ci.yml +147 -0
  6. package/.github/workflows/release.yml +40 -0
  7. package/.github/workflows/security.yml +84 -0
  8. package/.husky/pre-commit +1 -0
  9. package/.planning/codebase/ARCHITECTURE.md +206 -0
  10. package/.planning/codebase/INTEGRATIONS.md +150 -0
  11. package/.planning/codebase/STACK.md +122 -0
  12. package/.planning/codebase/STRUCTURE.md +201 -0
  13. package/CHANGELOG.md +293 -0
  14. package/CONTRIBUTING.md +378 -0
  15. package/Dockerfile +54 -0
  16. package/FEATURES.md +195 -21
  17. package/README.md +83 -6
  18. package/TODO.md +28 -0
  19. package/build-cjs.js +127 -0
  20. package/cjs-shim.js +13 -0
  21. package/dist/clawdash +1729 -0
  22. package/dist/clawdash.meta.json +2236 -0
  23. package/dist/widgets.cjs +6511 -0
  24. package/docker-compose.yml +67 -0
  25. package/docs/API.md +1050 -0
  26. package/docs/PLUGINS.md +1504 -0
  27. package/esbuild.config.js +158 -0
  28. package/eslint.config.js +56 -0
  29. package/examples/plugins/README.md +122 -0
  30. package/examples/plugins/api-status/index.js +294 -0
  31. package/examples/plugins/api-status/plugin.json +19 -0
  32. package/examples/plugins/hello-world/index.js +104 -0
  33. package/examples/plugins/hello-world/plugin.json +15 -0
  34. package/examples/plugins/system-metrics-chart/index.js +339 -0
  35. package/examples/plugins/system-metrics-chart/plugin.json +18 -0
  36. package/examples/plugins/weather-widget/index.js +136 -0
  37. package/examples/plugins/weather-widget/plugin.json +19 -0
  38. package/index.cjs +23005 -0
  39. package/index.js +5331 -512
  40. package/jest.config.js +11 -0
  41. package/man/clawdash.1 +276 -0
  42. package/package.json +54 -5
  43. package/schemas/plugin-manifest.json +128 -0
  44. package/scripts/release.js +595 -0
  45. package/src/alerts.js +693 -0
  46. package/src/auto-save.js +584 -0
  47. package/src/cache.js +390 -0
  48. package/src/checksum.js +146 -0
  49. package/src/cli/args.js +110 -0
  50. package/src/cli/export-schedule.js +423 -0
  51. package/src/cli/export-snapshot.js +138 -0
  52. package/src/cli/help.js +69 -0
  53. package/src/cli/import-snapshot.js +230 -0
  54. package/src/cli/index.js +14 -0
  55. package/src/cli/list-templates.js +69 -0
  56. package/src/cli/validate-config.js +76 -0
  57. package/src/cli/validate-plugin.js +187 -0
  58. package/src/cli/version.js +15 -0
  59. package/src/config-validator.js +586 -0
  60. package/src/config-watcher.js +401 -0
  61. package/src/config.js +684 -0
  62. package/src/container-detector.js +499 -0
  63. package/src/database.js +734 -0
  64. package/src/differential-render.js +327 -0
  65. package/src/errors.js +169 -0
  66. package/src/export-scheduler.js +581 -0
  67. package/src/gateway-manager.js +685 -0
  68. package/src/hints.js +371 -0
  69. package/src/loading-states.js +509 -0
  70. package/src/logger.js +185 -0
  71. package/src/memory-pressure.js +467 -0
  72. package/src/performance-monitor.js +374 -0
  73. package/src/plugin-errors.js +586 -0
  74. package/src/plugin-manifest-validator.js +219 -0
  75. package/src/plugin-reload.js +481 -0
  76. package/src/plugin-scaffold.js +1941 -0
  77. package/src/plugin-validator.js +284 -0
  78. package/src/retry.js +218 -0
  79. package/src/security.js +860 -0
  80. package/src/snapshot.js +372 -0
  81. package/src/splash.js +115 -0
  82. package/src/theme-selector.js +411 -0
  83. package/src/themes.js +874 -0
  84. package/src/transitions.js +534 -0
  85. package/src/types.d.ts +174 -0
  86. package/src/validation.js +926 -0
  87. package/src/web-server.js +885 -0
  88. package/src/widgets/builtin-widgets.js +1057 -0
  89. package/src/widgets/config-processor.js +401 -0
  90. package/src/widgets/dependency-resolver.js +596 -0
  91. package/src/widgets/index.js +79 -0
  92. package/src/widgets/plugin-api.js +845 -0
  93. package/src/widgets/widget-error-boundary.js +773 -0
  94. package/src/widgets/widget-error-isolation.js +551 -0
  95. package/src/widgets/widget-loader.js +1437 -0
  96. package/src/workers/system-worker.js +130 -0
  97. package/src/workers/worker-pool.js +633 -0
  98. package/tests/alerts.test.js +437 -0
  99. package/tests/auto-save.test.js +529 -0
  100. package/tests/cache.test.js +317 -0
  101. package/tests/cli.test.js +351 -0
  102. package/tests/command-palette.test.js +320 -0
  103. package/tests/config-processor.test.js +452 -0
  104. package/tests/config-validator.test.js +710 -0
  105. package/tests/config-watcher.test.js +594 -0
  106. package/tests/config.test.js +755 -0
  107. package/tests/database.test.js +438 -0
  108. package/tests/errors.test.js +189 -0
  109. package/tests/example-plugins.test.js +624 -0
  110. package/tests/integration.test.js +1321 -0
  111. package/tests/loading-states.test.js +300 -0
  112. package/tests/manifest-validation-on-load.test.js +671 -0
  113. package/tests/performance-monitor.test.js +190 -0
  114. package/tests/plugin-api-rate-limit.test.js +302 -0
  115. package/tests/plugin-errors.test.js +311 -0
  116. package/tests/plugin-lifecycle-e2e.test.js +1036 -0
  117. package/tests/plugin-reload.test.js +764 -0
  118. package/tests/rate-limiter.test.js +539 -0
  119. package/tests/retry.test.js +308 -0
  120. package/tests/security.test.js +411 -0
  121. package/tests/settings-modal.test.js +226 -0
  122. package/tests/theme-selector.test.js +57 -0
  123. package/tests/utils.js +242 -0
  124. package/tests/utils.test.js +317 -0
  125. package/tests/validate-plugin-cli.test.js +359 -0
  126. package/tests/validation.test.js +837 -0
  127. package/tests/web-server.test.js +646 -0
  128. package/tests/widget-arrange-mode.test.js +183 -0
  129. package/tests/widget-config-hot-reload.test.js +465 -0
  130. package/tests/widget-dependency.test.js +591 -0
  131. package/tests/widget-error-boundary.test.js +749 -0
  132. package/tests/widget-error-isolation.test.js +469 -0
  133. package/tests/widget-integration.test.js +1147 -0
  134. package/tests/widget-refresh-intervals.test.js +284 -0
  135. package/tests/worker-pool.test.js +522 -0
@@ -0,0 +1,837 @@
1
+ import {
2
+ validateSettings,
3
+ validateRefreshInterval,
4
+ validateLogLevelFilter,
5
+ validateSessionSortMode,
6
+ validateTheme,
7
+ validateExportFormat,
8
+ validateExportDirectory,
9
+ validateWidgetVisibility,
10
+ validateAlertThresholds,
11
+ validatePath,
12
+ validateType,
13
+ validateGatewayEndpoint,
14
+ getDefaultSettings,
15
+ VALID_THEMES,
16
+ VALID_SORT_MODES,
17
+ VALID_LOG_LEVELS,
18
+ VALID_EXPORT_FORMATS
19
+ } from '../src/validation.js';
20
+ import config from '../src/config.js';
21
+ import os from 'os';
22
+ import { mkdtempSync, writeFileSync, mkdirSync, rmdirSync, rmSync } from 'fs';
23
+ import { join } from 'path';
24
+
25
+ describe('Validation Module', () => {
26
+ describe('validatePath', () => {
27
+ let tempDir;
28
+
29
+ beforeEach(() => {
30
+ tempDir = mkdtempSync(join(os.tmpdir(), 'validation-test-'));
31
+ });
32
+
33
+ afterEach(() => {
34
+ try {
35
+ rmSync(tempDir, { recursive: true, force: true });
36
+ } catch {}
37
+ });
38
+
39
+ test('returns error for non-string path', () => {
40
+ const result = validatePath(123);
41
+ expect(result.valid).toBe(false);
42
+ expect(result.error).toBe('Path must be a non-empty string');
43
+ });
44
+
45
+ test('returns error for empty path', () => {
46
+ const result = validatePath('');
47
+ expect(result.valid).toBe(false);
48
+ });
49
+
50
+ test('returns error for null path', () => {
51
+ const result = validatePath(null);
52
+ expect(result.valid).toBe(false);
53
+ });
54
+
55
+ test('rejects paths with traversal', () => {
56
+ const result = validatePath('/etc/../passwd');
57
+ expect(result.valid).toBe(false);
58
+ expect(result.error).toBe('Path traversal not allowed');
59
+ });
60
+
61
+ test('expands tilde to home directory', () => {
62
+ const result = validatePath('~/test');
63
+ expect(result.valid).toBe(true);
64
+ // The implementation uses resolve() which may produce different path formats
65
+ expect(result.resolvedPath).toContain('test');
66
+ expect(result.resolvedPath).not.toContain('~');
67
+ });
68
+
69
+ test('validates existing path when mustExist is true', () => {
70
+ const result = validatePath(tempDir, true);
71
+ expect(result.valid).toBe(true);
72
+ expect(result.resolvedPath).toBe(tempDir);
73
+ });
74
+
75
+ test('returns error for non-existent path when mustExist is true', () => {
76
+ const result = validatePath('/nonexistent/path/12345', true);
77
+ expect(result.valid).toBe(false);
78
+ expect(result.error).toContain('does not exist');
79
+ });
80
+
81
+ test('returns warning for non-existent parent directory', () => {
82
+ const result = validatePath('/tmp/nonexistent/deep/path');
83
+ expect(result.valid).toBe(true);
84
+ expect(result.warning).toContain('Parent directory will be created');
85
+ });
86
+ });
87
+
88
+ describe('validateRefreshInterval', () => {
89
+ test('returns default for undefined', () => {
90
+ const result = validateRefreshInterval(undefined);
91
+ expect(result.valid).toBe(true);
92
+ expect(result.value).toBe(config.REFRESH_INTERVALS.DEFAULT);
93
+ });
94
+
95
+ test('returns default for null', () => {
96
+ const result = validateRefreshInterval(null);
97
+ expect(result.valid).toBe(true);
98
+ expect(result.value).toBe(config.REFRESH_INTERVALS.DEFAULT);
99
+ });
100
+
101
+ test('accepts valid number within range', () => {
102
+ const result = validateRefreshInterval(2000);
103
+ expect(result.valid).toBe(true);
104
+ expect(result.value).toBe(2000);
105
+ });
106
+
107
+ test('converts string numbers', () => {
108
+ const result = validateRefreshInterval('3000');
109
+ expect(result.valid).toBe(true);
110
+ expect(result.value).toBe(3000);
111
+ });
112
+
113
+ test('rejects NaN values', () => {
114
+ const result = validateRefreshInterval('invalid');
115
+ expect(result.valid).toBe(false);
116
+ expect(result.error).toContain('must be a number');
117
+ });
118
+
119
+ test('rejects values below minimum', () => {
120
+ const result = validateRefreshInterval(100);
121
+ expect(result.valid).toBe(false);
122
+ expect(result.error).toContain('between');
123
+ expect(result.error).toContain(String(config.VALIDATION.REFRESH_INTERVAL.MIN));
124
+ });
125
+
126
+ test('rejects values above maximum', () => {
127
+ const result = validateRefreshInterval(100000);
128
+ expect(result.valid).toBe(false);
129
+ expect(result.error).toContain('between');
130
+ expect(result.error).toContain(String(config.VALIDATION.REFRESH_INTERVAL.MAX));
131
+ });
132
+
133
+ test('accepts minimum boundary value', () => {
134
+ const result = validateRefreshInterval(config.VALIDATION.REFRESH_INTERVAL.MIN);
135
+ expect(result.valid).toBe(true);
136
+ });
137
+
138
+ test('accepts maximum boundary value', () => {
139
+ const result = validateRefreshInterval(config.VALIDATION.REFRESH_INTERVAL.MAX);
140
+ expect(result.valid).toBe(true);
141
+ });
142
+ });
143
+
144
+ describe('validateLogLevelFilter', () => {
145
+ test('returns default for undefined', () => {
146
+ const result = validateLogLevelFilter(undefined);
147
+ expect(result.valid).toBe(true);
148
+ expect(result.value).toBe('all');
149
+ });
150
+
151
+ test('returns default for empty string', () => {
152
+ const result = validateLogLevelFilter('');
153
+ expect(result.valid).toBe(true);
154
+ expect(result.value).toBe('all');
155
+ });
156
+
157
+ test('returns default for null', () => {
158
+ const result = validateLogLevelFilter(null);
159
+ expect(result.valid).toBe(true);
160
+ expect(result.value).toBe('all');
161
+ });
162
+
163
+ test('accepts valid log levels', () => {
164
+ for (const level of VALID_LOG_LEVELS) {
165
+ const result = validateLogLevelFilter(level);
166
+ expect(result.valid).toBe(true);
167
+ expect(result.value).toBe(level);
168
+ }
169
+ });
170
+
171
+ test('normalizes to lowercase', () => {
172
+ const result = validateLogLevelFilter('ERROR');
173
+ expect(result.valid).toBe(true);
174
+ expect(result.value).toBe('error');
175
+ });
176
+
177
+ test('rejects non-string values', () => {
178
+ const result = validateLogLevelFilter(123);
179
+ expect(result.valid).toBe(false);
180
+ expect(result.error).toContain('must be a string');
181
+ });
182
+
183
+ test('rejects invalid log levels', () => {
184
+ const result = validateLogLevelFilter('invalid');
185
+ expect(result.valid).toBe(false);
186
+ expect(result.error).toContain('Invalid log level');
187
+ });
188
+ });
189
+
190
+ describe('validateSessionSortMode', () => {
191
+ test('returns default for undefined', () => {
192
+ const result = validateSessionSortMode(undefined);
193
+ expect(result.valid).toBe(true);
194
+ expect(result.value).toBe('time');
195
+ });
196
+
197
+ test('returns default for null', () => {
198
+ const result = validateSessionSortMode(null);
199
+ expect(result.valid).toBe(true);
200
+ expect(result.value).toBe('time');
201
+ });
202
+
203
+ test('accepts valid sort modes', () => {
204
+ for (const mode of VALID_SORT_MODES) {
205
+ const result = validateSessionSortMode(mode);
206
+ expect(result.valid).toBe(true);
207
+ expect(result.value).toBe(mode);
208
+ }
209
+ });
210
+
211
+ test('normalizes to lowercase', () => {
212
+ const result = validateSessionSortMode('TOKENS');
213
+ expect(result.valid).toBe(true);
214
+ expect(result.value).toBe('tokens');
215
+ });
216
+
217
+ test('rejects non-string values', () => {
218
+ const result = validateSessionSortMode(true);
219
+ expect(result.valid).toBe(false);
220
+ });
221
+
222
+ test('rejects invalid sort modes', () => {
223
+ const result = validateSessionSortMode('invalid');
224
+ expect(result.valid).toBe(false);
225
+ expect(result.error).toContain('Invalid sort mode');
226
+ });
227
+ });
228
+
229
+ describe('validateTheme', () => {
230
+ test('returns default for undefined', () => {
231
+ const result = validateTheme(undefined);
232
+ expect(result.valid).toBe(true);
233
+ expect(result.value).toBe('default');
234
+ });
235
+
236
+ test('returns default for null', () => {
237
+ const result = validateTheme(null);
238
+ expect(result.valid).toBe(true);
239
+ expect(result.value).toBe('default');
240
+ });
241
+
242
+ test('accepts valid themes', () => {
243
+ for (const theme of VALID_THEMES) {
244
+ const result = validateTheme(theme);
245
+ expect(result.valid).toBe(true);
246
+ expect(result.value).toBe(theme);
247
+ }
248
+ });
249
+
250
+ test('normalizes to lowercase', () => {
251
+ const result = validateTheme('DARK');
252
+ expect(result.valid).toBe(true);
253
+ expect(result.value).toBe('dark');
254
+ });
255
+
256
+ test('rejects non-string values', () => {
257
+ const result = validateTheme({});
258
+ expect(result.valid).toBe(false);
259
+ });
260
+
261
+ test('rejects invalid themes', () => {
262
+ const result = validateTheme('invalid');
263
+ expect(result.valid).toBe(false);
264
+ expect(result.error).toContain('Invalid theme');
265
+ });
266
+ });
267
+
268
+ describe('validateExportFormat', () => {
269
+ test('returns default for undefined', () => {
270
+ const result = validateExportFormat(undefined);
271
+ expect(result.valid).toBe(true);
272
+ expect(result.value).toBe('json');
273
+ });
274
+
275
+ test('returns default for null', () => {
276
+ const result = validateExportFormat(null);
277
+ expect(result.valid).toBe(true);
278
+ expect(result.value).toBe('json');
279
+ });
280
+
281
+ test('accepts valid export formats', () => {
282
+ for (const format of VALID_EXPORT_FORMATS) {
283
+ const result = validateExportFormat(format);
284
+ expect(result.valid).toBe(true);
285
+ expect(result.value).toBe(format);
286
+ }
287
+ });
288
+
289
+ test('normalizes to lowercase', () => {
290
+ const result = validateExportFormat('CSV');
291
+ expect(result.valid).toBe(true);
292
+ expect(result.value).toBe('csv');
293
+ });
294
+
295
+ test('rejects non-string values', () => {
296
+ const result = validateExportFormat([]);
297
+ expect(result.valid).toBe(false);
298
+ });
299
+
300
+ test('rejects invalid export formats', () => {
301
+ const result = validateExportFormat('xml');
302
+ expect(result.valid).toBe(false);
303
+ expect(result.error).toContain('Invalid export format');
304
+ });
305
+ });
306
+
307
+ describe('validateExportDirectory', () => {
308
+ test('returns default for undefined', () => {
309
+ const result = validateExportDirectory(undefined);
310
+ expect(result.valid).toBe(true);
311
+ expect(result.value).toBe(config.PATHS.EXPORTS);
312
+ });
313
+
314
+ test('returns default for null', () => {
315
+ const result = validateExportDirectory(null);
316
+ expect(result.valid).toBe(true);
317
+ expect(result.value).toBe(config.PATHS.EXPORTS);
318
+ });
319
+
320
+ test('rejects non-string values', () => {
321
+ const result = validateExportDirectory(123);
322
+ expect(result.valid).toBe(false);
323
+ expect(result.error).toContain('must be a string');
324
+ });
325
+
326
+ test('expands tilde in path', () => {
327
+ const result = validateExportDirectory('~/exports');
328
+ expect(result.valid).toBe(true);
329
+ // The implementation uses resolve() which may produce different path formats
330
+ expect(result.resolvedPath).toContain('exports');
331
+ expect(result.resolvedPath).not.toContain('~');
332
+ });
333
+ });
334
+
335
+ describe('validateWidgetVisibility', () => {
336
+ test('returns default true for undefined', () => {
337
+ const result = validateWidgetVisibility(undefined);
338
+ expect(result.valid).toBe(true);
339
+ expect(result.value).toBe(true);
340
+ });
341
+
342
+ test('returns default true for null', () => {
343
+ const result = validateWidgetVisibility(null);
344
+ expect(result.valid).toBe(true);
345
+ expect(result.value).toBe(true);
346
+ });
347
+
348
+ test('accepts boolean true', () => {
349
+ const result = validateWidgetVisibility(true);
350
+ expect(result.valid).toBe(true);
351
+ expect(result.value).toBe(true);
352
+ });
353
+
354
+ test('accepts boolean false', () => {
355
+ const result = validateWidgetVisibility(false);
356
+ expect(result.valid).toBe(true);
357
+ expect(result.value).toBe(false);
358
+ });
359
+
360
+ test('parses string "true"', () => {
361
+ const result = validateWidgetVisibility('true');
362
+ expect(result.valid).toBe(true);
363
+ expect(result.value).toBe(true);
364
+ });
365
+
366
+ test('parses string "1"', () => {
367
+ const result = validateWidgetVisibility('1');
368
+ expect(result.valid).toBe(true);
369
+ expect(result.value).toBe(true);
370
+ });
371
+
372
+ test('parses string "yes"', () => {
373
+ const result = validateWidgetVisibility('yes');
374
+ expect(result.valid).toBe(true);
375
+ expect(result.value).toBe(true);
376
+ });
377
+
378
+ test('parses string "false"', () => {
379
+ const result = validateWidgetVisibility('false');
380
+ expect(result.valid).toBe(true);
381
+ expect(result.value).toBe(false);
382
+ });
383
+
384
+ test('parses string "0"', () => {
385
+ const result = validateWidgetVisibility('0');
386
+ expect(result.valid).toBe(true);
387
+ expect(result.value).toBe(false);
388
+ });
389
+
390
+ test('parses string "no"', () => {
391
+ const result = validateWidgetVisibility('no');
392
+ expect(result.valid).toBe(true);
393
+ expect(result.value).toBe(false);
394
+ });
395
+
396
+ test('rejects invalid string values', () => {
397
+ const result = validateWidgetVisibility('maybe');
398
+ expect(result.valid).toBe(false);
399
+ });
400
+
401
+ test('coerces truthy values to boolean', () => {
402
+ const result = validateWidgetVisibility(1);
403
+ expect(result.valid).toBe(false);
404
+ });
405
+ });
406
+
407
+ describe('validateAlertThresholds', () => {
408
+ test('rejects non-object values', () => {
409
+ const result = validateAlertThresholds('invalid');
410
+ expect(result.valid).toBe(false);
411
+ expect(result.error).toContain('must be an object');
412
+ });
413
+
414
+ test('rejects null', () => {
415
+ const result = validateAlertThresholds(null);
416
+ expect(result.valid).toBe(false);
417
+ });
418
+
419
+ test('accepts empty object', () => {
420
+ const result = validateAlertThresholds({});
421
+ expect(result.valid).toBe(true);
422
+ expect(result.value).toEqual({});
423
+ });
424
+
425
+ test('validates CPU thresholds', () => {
426
+ const result = validateAlertThresholds({
427
+ cpu: { warning: 70, critical: 90 }
428
+ });
429
+ expect(result.valid).toBe(true);
430
+ expect(result.value.cpu).toEqual({ warning: 70, critical: 90 });
431
+ });
432
+
433
+ test('validates memory thresholds', () => {
434
+ const result = validateAlertThresholds({
435
+ memory: { warning: 75, critical: 90 }
436
+ });
437
+ expect(result.valid).toBe(true);
438
+ expect(result.value.memory).toEqual({ warning: 75, critical: 90 });
439
+ });
440
+
441
+ test('validates disk thresholds with different defaults', () => {
442
+ const result = validateAlertThresholds({
443
+ disk: { warning: 80, critical: 95 }
444
+ });
445
+ expect(result.valid).toBe(true);
446
+ expect(result.value.disk).toEqual({ warning: 80, critical: 95 });
447
+ });
448
+
449
+ test('applies default thresholds when not specified', () => {
450
+ const result = validateAlertThresholds({ cpu: {} });
451
+ expect(result.valid).toBe(true);
452
+ expect(result.value.cpu.warning).toBe(70);
453
+ expect(result.value.cpu.critical).toBe(90);
454
+ });
455
+
456
+ test('rejects non-numeric warning values', () => {
457
+ const result = validateAlertThresholds({
458
+ cpu: { warning: 'high' }
459
+ });
460
+ expect(result.valid).toBe(false);
461
+ });
462
+
463
+ test('rejects warning values below 0', () => {
464
+ const result = validateAlertThresholds({
465
+ cpu: { warning: -1 }
466
+ });
467
+ expect(result.valid).toBe(false);
468
+ });
469
+
470
+ test('rejects warning values above 100', () => {
471
+ const result = validateAlertThresholds({
472
+ cpu: { warning: 101 }
473
+ });
474
+ expect(result.valid).toBe(false);
475
+ });
476
+
477
+ test('rejects critical values below warning', () => {
478
+ const result = validateAlertThresholds({
479
+ cpu: { warning: 90, critical: 80 }
480
+ });
481
+ expect(result.valid).toBe(false);
482
+ expect(result.error).toContain('critical threshold must be >= warning');
483
+ });
484
+
485
+ test('accepts critical equal to warning', () => {
486
+ const result = validateAlertThresholds({
487
+ cpu: { warning: 80, critical: 80 }
488
+ });
489
+ expect(result.valid).toBe(true);
490
+ });
491
+
492
+ test('rejects non-object threshold type', () => {
493
+ const result = validateAlertThresholds({
494
+ cpu: 'invalid'
495
+ });
496
+ expect(result.valid).toBe(false);
497
+ });
498
+ });
499
+
500
+ describe('validateType', () => {
501
+ test('validates number type', () => {
502
+ expect(validateType(42, 'number')).toBe(true);
503
+ expect(validateType(NaN, 'number')).toBe(false);
504
+ expect(validateType('42', 'number')).toBe(false);
505
+ });
506
+
507
+ test('validates string type', () => {
508
+ expect(validateType('hello', 'string')).toBe(true);
509
+ expect(validateType(123, 'string')).toBe(false);
510
+ });
511
+
512
+ test('validates boolean type', () => {
513
+ expect(validateType(true, 'boolean')).toBe(true);
514
+ expect(validateType(false, 'boolean')).toBe(true);
515
+ expect(validateType('true', 'boolean')).toBe(false);
516
+ });
517
+
518
+ test('validates object type', () => {
519
+ expect(validateType({}, 'object')).toBe(true);
520
+ expect(validateType({ key: 'value' }, 'object')).toBe(true);
521
+ expect(validateType(null, 'object')).toBe(false);
522
+ expect(validateType([], 'object')).toBe(true);
523
+ });
524
+
525
+ test('returns false for unknown type', () => {
526
+ expect(validateType('test', 'unknown')).toBe(false);
527
+ });
528
+ });
529
+
530
+ describe('validateGatewayEndpoint', () => {
531
+ test('rejects non-object values', () => {
532
+ const result = validateGatewayEndpoint('invalid');
533
+ expect(result.valid).toBe(false);
534
+ expect(result.error).toContain('must be an object');
535
+ });
536
+
537
+ test('rejects null', () => {
538
+ const result = validateGatewayEndpoint(null);
539
+ expect(result.valid).toBe(false);
540
+ });
541
+
542
+ test('rejects missing name', () => {
543
+ const result = validateGatewayEndpoint({
544
+ host: 'localhost',
545
+ port: 18789
546
+ });
547
+ expect(result.valid).toBe(false);
548
+ expect(result.error).toContain('name is required');
549
+ });
550
+
551
+ test('rejects empty name', () => {
552
+ const result = validateGatewayEndpoint({
553
+ name: '',
554
+ host: 'localhost',
555
+ port: 18789
556
+ });
557
+ expect(result.valid).toBe(false);
558
+ });
559
+
560
+ test('rejects name exceeding max length', () => {
561
+ const result = validateGatewayEndpoint({
562
+ name: 'a'.repeat(50),
563
+ host: 'localhost',
564
+ port: 18789
565
+ });
566
+ expect(result.valid).toBe(false);
567
+ expect(result.error).toContain('at most');
568
+ });
569
+
570
+ test('rejects name with invalid characters', () => {
571
+ const result = validateGatewayEndpoint({
572
+ name: 'my endpoint!',
573
+ host: 'localhost',
574
+ port: 18789
575
+ });
576
+ expect(result.valid).toBe(false);
577
+ expect(result.error).toContain('alphanumeric');
578
+ });
579
+
580
+ test('rejects missing host', () => {
581
+ const result = validateGatewayEndpoint({
582
+ name: 'local',
583
+ port: 18789
584
+ });
585
+ expect(result.valid).toBe(false);
586
+ expect(result.error).toContain('host is required');
587
+ });
588
+
589
+ test('rejects empty host', () => {
590
+ const result = validateGatewayEndpoint({
591
+ name: 'local',
592
+ host: '',
593
+ port: 18789
594
+ });
595
+ expect(result.valid).toBe(false);
596
+ });
597
+
598
+ test('rejects invalid port (0)', () => {
599
+ const result = validateGatewayEndpoint({
600
+ name: 'local',
601
+ host: 'localhost',
602
+ port: 0
603
+ });
604
+ expect(result.valid).toBe(false);
605
+ expect(result.error).toContain('valid port number');
606
+ });
607
+
608
+ test('rejects invalid port (negative)', () => {
609
+ const result = validateGatewayEndpoint({
610
+ name: 'local',
611
+ host: 'localhost',
612
+ port: -1
613
+ });
614
+ expect(result.valid).toBe(false);
615
+ });
616
+
617
+ test('rejects invalid port (above 65535)', () => {
618
+ const result = validateGatewayEndpoint({
619
+ name: 'local',
620
+ host: 'localhost',
621
+ port: 70000
622
+ });
623
+ expect(result.valid).toBe(false);
624
+ });
625
+
626
+ test('rejects invalid port type', () => {
627
+ const result = validateGatewayEndpoint({
628
+ name: 'local',
629
+ host: 'localhost',
630
+ port: 'invalid'
631
+ });
632
+ expect(result.valid).toBe(false);
633
+ });
634
+
635
+ test('accepts valid endpoint configuration', () => {
636
+ const result = validateGatewayEndpoint({
637
+ name: 'local',
638
+ host: 'localhost',
639
+ port: 18789
640
+ });
641
+ expect(result.valid).toBe(true);
642
+ expect(result.value).toEqual({
643
+ name: 'local',
644
+ host: 'localhost',
645
+ port: 18789,
646
+ token: null,
647
+ enabled: true,
648
+ type: 'local'
649
+ });
650
+ });
651
+
652
+ test('accepts endpoint with valid type', () => {
653
+ const result = validateGatewayEndpoint({
654
+ name: 'remote',
655
+ host: '192.168.1.1',
656
+ port: 18789,
657
+ type: 'remote'
658
+ });
659
+ expect(result.valid).toBe(true);
660
+ expect(result.value.type).toBe('remote');
661
+ });
662
+
663
+ test('rejects invalid endpoint type', () => {
664
+ const result = validateGatewayEndpoint({
665
+ name: 'local',
666
+ host: 'localhost',
667
+ port: 18789,
668
+ type: 'invalid'
669
+ });
670
+ expect(result.valid).toBe(false);
671
+ expect(result.error).toContain('type must be one of');
672
+ });
673
+
674
+ test('rejects non-boolean enabled', () => {
675
+ const result = validateGatewayEndpoint({
676
+ name: 'local',
677
+ host: 'localhost',
678
+ port: 18789,
679
+ enabled: 'yes'
680
+ });
681
+ expect(result.valid).toBe(false);
682
+ expect(result.error).toContain('enabled must be a boolean');
683
+ });
684
+
685
+ test('rejects non-string/null token', () => {
686
+ const result = validateGatewayEndpoint({
687
+ name: 'local',
688
+ host: 'localhost',
689
+ port: 18789,
690
+ token: 123
691
+ });
692
+ expect(result.valid).toBe(false);
693
+ expect(result.error).toContain('token must be a string or null');
694
+ });
695
+
696
+ test('accepts endpoint with token', () => {
697
+ const result = validateGatewayEndpoint({
698
+ name: 'remote',
699
+ host: 'remote.example.com',
700
+ port: 18789,
701
+ token: 'secret-token-123'
702
+ });
703
+ expect(result.valid).toBe(true);
704
+ expect(result.value.token).toBe('secret-token-123');
705
+ });
706
+
707
+ test('preserves explicit enabled=false', () => {
708
+ const result = validateGatewayEndpoint({
709
+ name: 'local',
710
+ host: 'localhost',
711
+ port: 18789,
712
+ enabled: false
713
+ });
714
+ expect(result.valid).toBe(true);
715
+ expect(result.value.enabled).toBe(false);
716
+ });
717
+ });
718
+
719
+ describe('validateSettings', () => {
720
+ test('returns defaults for null', () => {
721
+ const result = validateSettings(null);
722
+ // When input is null, getDefaultSettings() is returned directly
723
+ expect(result.refreshInterval).toBe(config.REFRESH_INTERVALS.DEFAULT);
724
+ });
725
+
726
+ test('returns defaults for non-object', () => {
727
+ const result = validateSettings('invalid');
728
+ // When input is not an object, getDefaultSettings() is returned directly
729
+ expect(result).toBeDefined();
730
+ expect(result.refreshInterval).toBe(config.REFRESH_INTERVALS.DEFAULT);
731
+ });
732
+
733
+ test('validates complete settings object', () => {
734
+ const settings = {
735
+ refreshInterval: 5000,
736
+ logLevelFilter: 'error',
737
+ sessionSortMode: 'tokens',
738
+ theme: 'dark',
739
+ exportFormat: 'csv',
740
+ showWidget1: false,
741
+ showWidget2: true
742
+ };
743
+ const result = validateSettings(settings);
744
+ expect(result.valid).toBe(true);
745
+ expect(result.value.refreshInterval).toBe(5000);
746
+ expect(result.value.logLevelFilter).toBe('error');
747
+ expect(result.value.theme).toBe('dark');
748
+ });
749
+
750
+ test('uses default for invalid refreshInterval', () => {
751
+ const settings = { refreshInterval: 999999 };
752
+ const result = validateSettings(settings);
753
+ expect(result.valid).toBe(true);
754
+ expect(result.value.refreshInterval).toBe(config.REFRESH_INTERVALS.DEFAULT);
755
+ });
756
+
757
+ test('uses default for invalid theme', () => {
758
+ const settings = { theme: 'neon' };
759
+ const result = validateSettings(settings);
760
+ expect(result.valid).toBe(true);
761
+ expect(result.value.theme).toBe('default');
762
+ });
763
+ });
764
+
765
+ describe('getDefaultSettings', () => {
766
+ test('returns complete default settings object', () => {
767
+ const defaults = getDefaultSettings();
768
+ expect(defaults.refreshInterval).toBe(config.REFRESH_INTERVALS.DEFAULT);
769
+ expect(defaults.logLevelFilter).toBe('all');
770
+ expect(defaults.sessionSortMode).toBe('time');
771
+ expect(defaults.theme).toBe('default');
772
+ expect(defaults.exportFormat).toBe('json');
773
+ expect(defaults.exportDirectory).toBe(config.PATHS.EXPORTS);
774
+ });
775
+
776
+ test('includes all widget visibility settings', () => {
777
+ const defaults = getDefaultSettings();
778
+ for (let i = 1; i <= 8; i++) {
779
+ expect(defaults[`showWidget${i}`]).toBe(true);
780
+ }
781
+ });
782
+
783
+ test('includes performance metrics flag', () => {
784
+ const defaults = getDefaultSettings();
785
+ expect(defaults.showPerformanceMetrics).toBe(false);
786
+ });
787
+
788
+ test('includes favorites settings', () => {
789
+ const defaults = getDefaultSettings();
790
+ expect(defaults.favorites).toEqual({});
791
+ expect(defaults.showFavoritesOnly).toBe(false);
792
+ });
793
+
794
+ test('includes firstRun flag', () => {
795
+ const defaults = getDefaultSettings();
796
+ expect(defaults.firstRun).toBe(true);
797
+ });
798
+
799
+ test('includes gateway endpoints array', () => {
800
+ const defaults = getDefaultSettings();
801
+ expect(Array.isArray(defaults.gatewayEndpoints)).toBe(true);
802
+ expect(defaults.gatewayEndpoints[0]).toMatchObject({
803
+ name: 'local',
804
+ host: 'localhost',
805
+ enabled: true
806
+ });
807
+ });
808
+
809
+ test('includes web interface config', () => {
810
+ const defaults = getDefaultSettings();
811
+ expect(defaults.webInterface).toMatchObject({
812
+ enabled: false,
813
+ port: config.WEB.DEFAULT_PORT,
814
+ host: config.WEB.HOST,
815
+ cors: true
816
+ });
817
+ });
818
+ });
819
+
820
+ describe('exported constants', () => {
821
+ test('VALID_THEMES matches config', () => {
822
+ expect(VALID_THEMES).toEqual(config.VALIDATION.VALID_THEMES);
823
+ });
824
+
825
+ test('VALID_SORT_MODES matches config', () => {
826
+ expect(VALID_SORT_MODES).toEqual(config.VALIDATION.VALID_SORT_MODES);
827
+ });
828
+
829
+ test('VALID_LOG_LEVELS matches config', () => {
830
+ expect(VALID_LOG_LEVELS).toEqual(config.VALIDATION.VALID_LOG_LEVELS);
831
+ });
832
+
833
+ test('VALID_EXPORT_FORMATS matches config', () => {
834
+ expect(VALID_EXPORT_FORMATS).toEqual(config.VALIDATION.VALID_EXPORT_FORMATS);
835
+ });
836
+ });
837
+ });