claw-dashboard 1.9.0 → 2.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.
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 +5285 -566
  40. package/jest.config.js +11 -0
  41. package/man/clawdash.1 +276 -0
  42. package/package.json +54 -6
  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 +1934 -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 +1056 -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,411 @@
1
+ /**
2
+ * Tests for security module
3
+ * Path validation, sanitization, and widget config security
4
+ */
5
+
6
+ import {
7
+ isValidPath,
8
+ isSafeToChmod,
9
+ isSafeToChmodSync,
10
+ setSecurePermissions,
11
+ setSecurePermissionsSync,
12
+ sanitizeWidgetConfig,
13
+ validateWidgetConfig,
14
+ WidgetConfigValidator,
15
+ validatePluginPath,
16
+ validatePluginName,
17
+ } from '../src/security.js';
18
+
19
+ import { writeFileSync, unlinkSync, mkdirSync, rmdirSync, chmodSync, symlinkSync } from 'fs';
20
+ import { join } from 'path';
21
+ import { mkdtempSync } from 'fs';
22
+ import { tmpdir } from 'os';
23
+
24
+ describe('security', () => {
25
+ describe('isValidPath', () => {
26
+ test('should return false for null/undefined', () => {
27
+ expect(isValidPath(null)).toBe(false);
28
+ expect(isValidPath(undefined)).toBe(false);
29
+ });
30
+
31
+ test('should return false for non-string types', () => {
32
+ expect(isValidPath(123)).toBe(false);
33
+ expect(isValidPath({})).toBe(false);
34
+ expect(isValidPath([])).toBe(false);
35
+ });
36
+
37
+ test('should return false for empty string', () => {
38
+ expect(isValidPath('')).toBe(false);
39
+ });
40
+
41
+ test('should return false for paths with null bytes', () => {
42
+ expect(isValidPath('/path/to/file\0')).toBe(false);
43
+ });
44
+
45
+ test('should return false for paths over 4096 chars', () => {
46
+ const longPath = '/' + 'a'.repeat(4096);
47
+ expect(isValidPath(longPath)).toBe(false);
48
+ });
49
+
50
+ test('should return true for valid paths', () => {
51
+ expect(isValidPath('/path/to/file')).toBe(true);
52
+ expect(isValidPath('./relative/path')).toBe(true);
53
+ expect(isValidPath('simple.txt')).toBe(true);
54
+ });
55
+ });
56
+
57
+ describe('isSafeToChmodSync', () => {
58
+ let testDir;
59
+
60
+ beforeEach(() => {
61
+ testDir = mkdtempSync(join(tmpdir(), 'security-test-'));
62
+ });
63
+
64
+ afterEach(() => {
65
+ try {
66
+ if (existsSync(testDir)) {
67
+ rmdirSync(testDir, { recursive: true });
68
+ }
69
+ } catch (e) {
70
+ // Ignore cleanup errors
71
+ }
72
+ });
73
+
74
+ test('should return false for non-existent path', () => {
75
+ expect(isSafeToChmodSync('/nonexistent/path')).toBe(false);
76
+ });
77
+
78
+ test('should return true for regular files', () => {
79
+ const testFile = join(testDir, 'test.txt');
80
+ writeFileSync(testFile, 'content');
81
+ expect(isSafeToChmodSync(testFile)).toBe(true);
82
+ });
83
+
84
+ test('should return false for directories', () => {
85
+ expect(isSafeToChmodSync(testDir)).toBe(false);
86
+ });
87
+
88
+ test('should return false for symlinks', () => {
89
+ const testFile = join(testDir, 'test.txt');
90
+ const symlinkFile = join(testDir, 'link.txt');
91
+ writeFileSync(testFile, 'content');
92
+ symlinkSync(testFile, symlinkFile);
93
+ expect(isSafeToChmodSync(symlinkFile)).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe('setSecurePermissionsSync', () => {
98
+ let testDir;
99
+
100
+ beforeEach(() => {
101
+ testDir = mkdtempSync(join(tmpdir(), 'security-test-'));
102
+ });
103
+
104
+ afterEach(() => {
105
+ try {
106
+ if (existsSync(testDir)) {
107
+ rmdirSync(testDir, { recursive: true });
108
+ }
109
+ } catch (e) {
110
+ // Ignore cleanup errors
111
+ }
112
+ });
113
+
114
+ test('should return false for invalid path', () => {
115
+ expect(setSecurePermissionsSync(null)).toBe(false);
116
+ expect(setSecurePermissionsSync('')).toBe(false);
117
+ });
118
+
119
+ test('should return false for non-existent file', () => {
120
+ expect(setSecurePermissionsSync('/nonexistent/file')).toBe(false);
121
+ });
122
+
123
+ test('should set permissions on valid file', () => {
124
+ const testFile = join(testDir, 'test.txt');
125
+ writeFileSync(testFile, 'content');
126
+ expect(setSecurePermissionsSync(testFile)).toBe(true);
127
+ });
128
+
129
+ test('should return false for directory', () => {
130
+ expect(setSecurePermissionsSync(testDir)).toBe(false);
131
+ });
132
+ });
133
+
134
+ describe('WidgetConfigValidator', () => {
135
+ test('should create with default options', () => {
136
+ const validator = new WidgetConfigValidator();
137
+ expect(validator.maxStringLength).toBe(1000);
138
+ expect(validator.maxDepth).toBe(10);
139
+ expect(validator.maxArrayLength).toBe(100);
140
+ });
141
+
142
+ test('should create with custom options', () => {
143
+ const validator = new WidgetConfigValidator({
144
+ maxStringLength: 500,
145
+ maxDepth: 5,
146
+ });
147
+ expect(validator.maxStringLength).toBe(500);
148
+ expect(validator.maxDepth).toBe(5);
149
+ });
150
+
151
+ test('should handle null/undefined config', () => {
152
+ const validator = new WidgetConfigValidator();
153
+ expect(validator.validate(null)).toEqual({});
154
+ expect(validator.validate(undefined)).toEqual({});
155
+ });
156
+
157
+ test('should throw for non-object config', () => {
158
+ const validator = new WidgetConfigValidator();
159
+ expect(() => validator.validate('string')).toThrow('must be an object');
160
+ expect(() => validator.validate(123)).toThrow('must be an object');
161
+ });
162
+
163
+ test('should sanitize strings', () => {
164
+ const validator = new WidgetConfigValidator({ maxStringLength: 10 });
165
+ const result = validator.validate({ key: 'longer than 10 chars' });
166
+ expect(result.key).toBe('longer tha');
167
+ });
168
+
169
+ test('should strip null bytes from strings', () => {
170
+ const validator = new WidgetConfigValidator();
171
+ const result = validator.validate({ key: 'hello\0world' });
172
+ expect(result.key).toBe('helloworld');
173
+ });
174
+
175
+ test('should sanitize numbers', () => {
176
+ const validator = new WidgetConfigValidator();
177
+ const result = validator.validate({
178
+ normal: 42,
179
+ nan: NaN,
180
+ infinite: Infinity,
181
+ negInfinite: -Infinity,
182
+ });
183
+ expect(result.normal).toBe(42);
184
+ expect(result.nan).toBe(0);
185
+ expect(result.infinite).toBe(0);
186
+ expect(result.negInfinite).toBe(0);
187
+ });
188
+
189
+ test('should limit depth in nested objects', () => {
190
+ const validator = new WidgetConfigValidator({ maxDepth: 2 });
191
+ const deepObj = { a: { b: { c: { d: 'deep' } } } };
192
+ const result = validator.validate(deepObj);
193
+ // Depth is limited - nested objects beyond maxDepth get null values
194
+ expect(result.a).toBeDefined();
195
+ expect(result.a.b).toBeDefined();
196
+ // Objects beyond depth are null (not undefined)
197
+ expect(result.a.b.c).toBeNull();
198
+ });
199
+
200
+ test('should sanitize arrays', () => {
201
+ const validator = new WidgetConfigValidator({ maxArrayLength: 2 });
202
+ const result = validator.validate({ arr: [1, 2, 3, 4, 5] });
203
+ expect(result.arr).toEqual([1, 2]);
204
+ });
205
+
206
+ test('should validate with schema', () => {
207
+ const validator = new WidgetConfigValidator();
208
+ const config = {
209
+ name: 'test',
210
+ count: 5,
211
+ unknown: 'should be removed',
212
+ };
213
+ const schema = {
214
+ properties: {
215
+ name: { type: 'string' },
216
+ count: { type: 'number', default: 0 },
217
+ },
218
+ };
219
+ const result = validator.validate(config, schema);
220
+ expect(result.name).toBe('test');
221
+ expect(result.count).toBe(5);
222
+ expect(result.unknown).toBeUndefined();
223
+ });
224
+ });
225
+
226
+ describe('sanitizeWidgetConfig', () => {
227
+ test('should sanitize basic config', () => {
228
+ const config = { name: 'test', count: 5 };
229
+ const result = sanitizeWidgetConfig(config);
230
+ expect(result).toEqual(config);
231
+ });
232
+
233
+ test('should handle null config', () => {
234
+ expect(sanitizeWidgetConfig(null)).toEqual({});
235
+ });
236
+
237
+ test('should strip null bytes', () => {
238
+ const config = { key: 'value\0' };
239
+ const result = sanitizeWidgetConfig(config);
240
+ expect(result.key).toBe('value');
241
+ });
242
+ });
243
+
244
+ describe('validateWidgetConfig', () => {
245
+ test('should return valid for correct config', () => {
246
+ const config = { name: 'test' };
247
+ const schema = {
248
+ properties: {
249
+ name: { type: 'string' },
250
+ },
251
+ };
252
+ const result = validateWidgetConfig(config, schema);
253
+ expect(result.valid).toBe(true);
254
+ expect(result.errors).toEqual([]);
255
+ });
256
+
257
+ test('should handle null config gracefully', () => {
258
+ const result = validateWidgetConfig(null, {});
259
+ // Implementation gracefully handles null by returning empty object
260
+ expect(result.valid).toBe(true);
261
+ });
262
+ });
263
+
264
+ describe('validatePluginPath', () => {
265
+ let testDir;
266
+
267
+ beforeEach(() => {
268
+ testDir = mkdtempSync(join(tmpdir(), 'plugin-test-'));
269
+ });
270
+
271
+ afterEach(() => {
272
+ try {
273
+ if (existsSync(testDir)) {
274
+ rmdirSync(testDir, { recursive: true });
275
+ }
276
+ } catch (e) {
277
+ // Ignore cleanup errors
278
+ }
279
+ });
280
+
281
+ test('should reject null/undefined paths', () => {
282
+ expect(validatePluginPath(null).valid).toBe(false);
283
+ expect(validatePluginPath(undefined).valid).toBe(false);
284
+ });
285
+
286
+ test('should reject empty strings', () => {
287
+ expect(validatePluginPath('').valid).toBe(false);
288
+ });
289
+
290
+ test('should reject paths with null bytes', () => {
291
+ expect(validatePluginPath('path\0').valid).toBe(false);
292
+ });
293
+
294
+ test('should reject absolute paths by default', () => {
295
+ expect(validatePluginPath('/absolute/path').valid).toBe(false);
296
+ });
297
+
298
+ test('should allow absolute paths when option set', () => {
299
+ const result = validatePluginPath('/tmp/test', { allowAbsolute: true });
300
+ expect(result.valid).toBe(true);
301
+ });
302
+
303
+ test('should reject path traversal attempts', () => {
304
+ expect(validatePluginPath('../etc/passwd').valid).toBe(false);
305
+ expect(validatePluginPath('foo/../bar').valid).toBe(false);
306
+ expect(validatePluginPath('foo/../../bar').valid).toBe(false);
307
+ });
308
+
309
+ test('should validate path stays within allowed directories', () => {
310
+ const result = validatePluginPath('subdir/file', {
311
+ allowedDirs: [testDir],
312
+ });
313
+ expect(result.valid).toBe(true);
314
+ });
315
+
316
+ test('should reject paths outside allowed directories', () => {
317
+ const result = validatePluginPath('../other', {
318
+ allowedDirs: [testDir],
319
+ });
320
+ expect(result.valid).toBe(false);
321
+ });
322
+
323
+ test('should check path exists when required', () => {
324
+ const testFile = join(testDir, 'exists.txt');
325
+ writeFileSync(testFile, 'content');
326
+
327
+ expect(validatePluginPath('exists.txt', {
328
+ allowedDirs: [testDir],
329
+ mustExist: true,
330
+ expectedType: 'file',
331
+ }).valid).toBe(true);
332
+
333
+ expect(validatePluginPath('nonexistent.txt', {
334
+ allowedDirs: [testDir],
335
+ mustExist: true,
336
+ }).valid).toBe(false);
337
+ });
338
+
339
+ test('should reject invalid characters in path', () => {
340
+ expect(validatePluginPath('path with spaces/file').valid).toBe(false);
341
+ expect(validatePluginPath('path$with$special/file').valid).toBe(false);
342
+ });
343
+
344
+ test('should allow valid plugin names', () => {
345
+ expect(validatePluginPath('my-plugin').valid).toBe(true);
346
+ expect(validatePluginPath('my_plugin').valid).toBe(true);
347
+ expect(validatePluginPath('Plugin123').valid).toBe(true);
348
+ });
349
+
350
+ test('should reject hidden files', () => {
351
+ expect(validatePluginPath('.hidden/file').valid).toBe(false);
352
+ expect(validatePluginPath('dir/.secret').valid).toBe(false);
353
+ });
354
+
355
+ test('should allow specific hidden files', () => {
356
+ expect(validatePluginPath('.gitkeep').valid).toBe(true);
357
+ expect(validatePluginPath('.gitignore').valid).toBe(true);
358
+ });
359
+ });
360
+
361
+ describe('validatePluginName', () => {
362
+ test('should reject null/undefined', () => {
363
+ expect(validatePluginName(null).valid).toBe(false);
364
+ expect(validatePluginName(undefined).valid).toBe(false);
365
+ });
366
+
367
+ test('should reject non-strings', () => {
368
+ expect(validatePluginName(123).valid).toBe(false);
369
+ expect(validatePluginName({}).valid).toBe(false);
370
+ });
371
+
372
+ test('should reject empty strings', () => {
373
+ expect(validatePluginName('').valid).toBe(false);
374
+ expect(validatePluginName(' ').valid).toBe(false);
375
+ });
376
+
377
+ test('should reject names over 100 chars', () => {
378
+ const longName = 'a'.repeat(101);
379
+ expect(validatePluginName(longName).valid).toBe(false);
380
+ });
381
+
382
+ test('should reject reserved names', () => {
383
+ expect(validatePluginName('node_modules').valid).toBe(false);
384
+ expect(validatePluginName('.git').valid).toBe(false);
385
+ expect(validatePluginName('package.json').valid).toBe(false);
386
+ });
387
+
388
+ test('should reject names not starting with alphanumeric', () => {
389
+ expect(validatePluginName('-plugin').valid).toBe(false);
390
+ expect(validatePluginName('_plugin').valid).toBe(false);
391
+ expect(validatePluginName('.plugin').valid).toBe(false);
392
+ });
393
+
394
+ test('should reject names with invalid characters', () => {
395
+ expect(validatePluginName('my plugin').valid).toBe(false);
396
+ expect(validatePluginName('my/plugin').valid).toBe(false);
397
+ expect(validatePluginName('my:plugin').valid).toBe(false);
398
+ });
399
+
400
+ test('should allow valid plugin names', () => {
401
+ expect(validatePluginName('my-plugin').valid).toBe(true);
402
+ expect(validatePluginName('my_plugin').valid).toBe(true);
403
+ expect(validatePluginName('Plugin123').valid).toBe(true);
404
+ expect(validatePluginName('a').valid).toBe(true);
405
+ });
406
+
407
+ test('should trim whitespace', () => {
408
+ expect(validatePluginName(' my-plugin ').valid).toBe(true);
409
+ });
410
+ });
411
+ });
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Tests for Settings Modal Lifecycle
3
+ * Verifies the fix for navigation crash after opening/closing settings menu
4
+ *
5
+ * The crash was caused by a race condition during the async closeSettings transition.
6
+ * When settings was closing (150ms transition), navigation keys could still trigger
7
+ * because the settingsList.focused check wasn't accounting for the closing state.
8
+ */
9
+
10
+ import { jest } from '@jest/globals';
11
+
12
+ describe('Settings Modal Lifecycle', () => {
13
+ describe('_settingsClosing flag behavior', () => {
14
+ test('should block navigation when settings is closing', () => {
15
+ // Simulate the state during settings close transition
16
+ const state = {
17
+ _settingsClosing: false,
18
+ w: {
19
+ settingsList: null,
20
+ settingsBox: null,
21
+ },
22
+ isModalActive: false,
23
+ };
24
+
25
+ // Simulate navigation guard logic
26
+ const shouldBlockNavigation = () => {
27
+ return !!(state._settingsClosing || (state.w.settingsList && state.w.settingsList.focused));
28
+ };
29
+
30
+ // Initially, navigation should NOT be blocked
31
+ expect(shouldBlockNavigation()).toBe(false);
32
+
33
+ // Simulate opening settings
34
+ state.w.settingsBox = { destroy: jest.fn() };
35
+ state.w.settingsList = { focused: true };
36
+ state.isModalActive = true;
37
+ expect(shouldBlockNavigation()).toBe(true); // Blocked while settings is open
38
+
39
+ // Simulate closeSettings() being called - the critical fix
40
+ // _settingsClosing is set synchronously BEFORE the async transition
41
+ state._settingsClosing = true;
42
+ state.isModalActive = false;
43
+ // settingsList still exists during transition
44
+ expect(shouldBlockNavigation()).toBe(true); // Still blocked during transition!
45
+
46
+ // After transition completes
47
+ state._settingsClosing = false;
48
+ state.w.settingsList = null;
49
+ state.w.settingsBox = null;
50
+ expect(shouldBlockNavigation()).toBe(false); // Now navigation is allowed
51
+ });
52
+
53
+ test('should handle rapid open/close cycles', () => {
54
+ const state = {
55
+ _settingsClosing: false,
56
+ w: {
57
+ settingsList: null,
58
+ settingsBox: null,
59
+ },
60
+ isModalActive: false,
61
+ };
62
+
63
+ const shouldBlockNavigation = () => {
64
+ return !!(state._settingsClosing || (state.w.settingsList && state.w.settingsList.focused));
65
+ };
66
+
67
+ // Rapid open/close cycle
68
+ // Open
69
+ state.w.settingsBox = {};
70
+ state.w.settingsList = { focused: true };
71
+ state.isModalActive = true;
72
+ expect(shouldBlockNavigation()).toBe(true);
73
+
74
+ // Close starts
75
+ state._settingsClosing = true;
76
+ state.isModalActive = false;
77
+ expect(shouldBlockNavigation()).toBe(true);
78
+
79
+ // Close completes
80
+ state._settingsClosing = false;
81
+ state.w.settingsList = null;
82
+ state.w.settingsBox = null;
83
+ expect(shouldBlockNavigation()).toBe(false);
84
+
85
+ // Rapidly re-open before any navigation
86
+ state.w.settingsBox = {};
87
+ state.w.settingsList = { focused: true };
88
+ state.isModalActive = true;
89
+ expect(shouldBlockNavigation()).toBe(true);
90
+
91
+ // Close again
92
+ state._settingsClosing = true;
93
+ state.isModalActive = false;
94
+ expect(shouldBlockNavigation()).toBe(true);
95
+
96
+ // Final cleanup
97
+ state._settingsClosing = false;
98
+ state.w.settingsList = null;
99
+ state.w.settingsBox = null;
100
+ expect(shouldBlockNavigation()).toBe(false);
101
+ });
102
+
103
+ test('should use try/finally pattern to ensure flag is always cleared', () => {
104
+ // This test verifies the code pattern used in closeSettings()
105
+ // The flag should be cleared even if an error occurs
106
+
107
+ const state = {
108
+ _settingsClosing: false,
109
+ };
110
+
111
+ // Simulate the closeSettings pattern
112
+ const simulateClose = async (shouldThrow = false) => {
113
+ state._settingsClosing = true;
114
+ try {
115
+ if (shouldThrow) {
116
+ throw new Error('Transition failed');
117
+ }
118
+ // Simulate async transition
119
+ await new Promise(resolve => setTimeout(resolve, 10));
120
+ } finally {
121
+ state._settingsClosing = false;
122
+ }
123
+ };
124
+
125
+ // Normal close
126
+ return simulateClose(false).then(() => {
127
+ expect(state._settingsClosing).toBe(false);
128
+
129
+ // Close with error
130
+ return simulateClose(true).catch(() => {
131
+ // Error is expected
132
+ });
133
+ }).then(() => {
134
+ // Flag should still be cleared even after error
135
+ expect(state._settingsClosing).toBe(false);
136
+ });
137
+ });
138
+ });
139
+
140
+ describe('isModalActive state management', () => {
141
+ test('should be set false synchronously when closeSettings starts', () => {
142
+ // This test verifies the fix for the race condition where
143
+ // isModalActive was only set after the async transition completed
144
+
145
+ const state = {
146
+ isModalActive: false,
147
+ _settingsClosing: false,
148
+ };
149
+
150
+ // Simulate closeSettings() start
151
+ const closeSettingsStart = () => {
152
+ state._settingsClosing = true;
153
+ state.isModalActive = false;
154
+ // At this point, even though transition hasn't completed,
155
+ // isModalActive is already false
156
+ };
157
+
158
+ // Open settings first
159
+ state.isModalActive = true;
160
+ expect(state.isModalActive).toBe(true);
161
+
162
+ // Close starts - isModalActive should be false immediately
163
+ closeSettingsStart();
164
+ expect(state.isModalActive).toBe(false);
165
+ expect(state._settingsClosing).toBe(true);
166
+ });
167
+ });
168
+
169
+ describe('Navigation guard logic', () => {
170
+ test('should check _settingsClosing first for performance', () => {
171
+ // The guard uses || with _settingsClosing first
172
+ // This means if _settingsClosing is true, we don't even check settingsList.focused
173
+ // which could cause issues with destroyed widgets
174
+
175
+ let settingsListFocusedCallCount = 0;
176
+ const state = {
177
+ _settingsClosing: false,
178
+ w: {
179
+ settingsList: {
180
+ get focused() {
181
+ settingsListFocusedCallCount++;
182
+ return true;
183
+ }
184
+ }
185
+ }
186
+ };
187
+
188
+ const shouldBlockNavigation = () => {
189
+ return !!(state._settingsClosing || (state.w.settingsList && state.w.settingsList.focused));
190
+ };
191
+
192
+ // When _settingsClosing is false, settingsList.focused is checked
193
+ settingsListFocusedCallCount = 0;
194
+ state._settingsClosing = false;
195
+ shouldBlockNavigation();
196
+ expect(settingsListFocusedCallCount).toBe(1);
197
+
198
+ // When _settingsClosing is true, settingsList.focused is NOT checked (short-circuit)
199
+ settingsListFocusedCallCount = 0;
200
+ state._settingsClosing = true;
201
+ shouldBlockNavigation();
202
+ expect(settingsListFocusedCallCount).toBe(0);
203
+ });
204
+
205
+ test('should handle null settingsList gracefully', () => {
206
+ const state = {
207
+ _settingsClosing: false,
208
+ w: {
209
+ settingsList: null,
210
+ }
211
+ };
212
+
213
+ const shouldBlockNavigation = () => {
214
+ return !!(state._settingsClosing || (state.w.settingsList && state.w.settingsList.focused));
215
+ };
216
+
217
+ // Should not throw when settingsList is null
218
+ expect(() => shouldBlockNavigation()).not.toThrow();
219
+ expect(shouldBlockNavigation()).toBe(false);
220
+
221
+ // Should still work when _settingsClosing is true
222
+ state._settingsClosing = true;
223
+ expect(shouldBlockNavigation()).toBe(true);
224
+ });
225
+ });
226
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Tests for theme-selector.js
3
+ */
4
+
5
+ import {
6
+ getThemeInfo,
7
+ getAllThemesInfo
8
+ } from '../src/theme-selector.js';
9
+
10
+ describe('Theme Selector', () => {
11
+ describe('getThemeInfo', () => {
12
+ test('should return theme info for a theme', () => {
13
+ const info = getThemeInfo('dark');
14
+
15
+ expect(info).toBeDefined();
16
+ expect(info.name).toBe('dark');
17
+ expect(typeof info.displayName).toBe('string');
18
+ expect(typeof info.isCurrent).toBe('boolean');
19
+ expect(info.isAuto).toBe(false);
20
+ });
21
+
22
+ test('should identify auto theme', () => {
23
+ const info = getThemeInfo('auto');
24
+
25
+ expect(info.isAuto).toBe(true);
26
+ expect(info.displayName).toBe('Auto-detect');
27
+ });
28
+
29
+ test('should include color categories for regular themes', () => {
30
+ const info = getThemeInfo('dark');
31
+
32
+ expect(Array.isArray(info.colors)).toBe(true);
33
+ });
34
+ });
35
+
36
+ describe('getAllThemesInfo', () => {
37
+ test('should return info for all themes', () => {
38
+ const themes = getAllThemesInfo();
39
+
40
+ expect(Array.isArray(themes)).toBe(true);
41
+ expect(themes.length).toBeGreaterThan(0);
42
+
43
+ // Check for expected theme names
44
+ const names = themes.map(t => t.name);
45
+ expect(names).toContain('default');
46
+ expect(names).toContain('dark');
47
+ expect(names).toContain('auto');
48
+ });
49
+
50
+ test('should mark one theme as current', () => {
51
+ const themes = getAllThemesInfo();
52
+ const currentThemes = themes.filter(t => t.isCurrent);
53
+
54
+ expect(currentThemes.length).toBe(1);
55
+ });
56
+ });
57
+ });