claw-dashboard 2.1.0 → 2.2.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 (90) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/docs/API.md +1 -2
  3. package/index.js +32 -42
  4. package/package.json +23 -12
  5. package/src/auto-save.js +11 -5
  6. package/src/cli/export-snapshot.js +3 -1
  7. package/src/cli/import-snapshot.js +3 -1
  8. package/src/config.js +9 -15
  9. package/src/errors.js +0 -9
  10. package/src/security.js +6 -2
  11. package/src/snapshot.js +73 -26
  12. package/src/web-server.js +7 -10
  13. package/src/widgets/builtin-widgets.js +0 -94
  14. package/src/widgets/index.js +0 -1
  15. package/.c8rc.json +0 -38
  16. package/.dockerignore +0 -68
  17. package/.github/dependabot.yml +0 -45
  18. package/.github/pull_request_template.md +0 -39
  19. package/.github/workflows/ci.yml +0 -147
  20. package/.github/workflows/release.yml +0 -40
  21. package/.github/workflows/security.yml +0 -84
  22. package/.husky/pre-commit +0 -1
  23. package/.planning/codebase/ARCHITECTURE.md +0 -206
  24. package/.planning/codebase/INTEGRATIONS.md +0 -150
  25. package/.planning/codebase/STACK.md +0 -122
  26. package/.planning/codebase/STRUCTURE.md +0 -201
  27. package/CONTRIBUTING.md +0 -378
  28. package/Dockerfile +0 -54
  29. package/FEATURES.md +0 -307
  30. package/TODO.md +0 -28
  31. package/ai.openclaw.dashboard.plist +0 -35
  32. package/build-cjs.js +0 -127
  33. package/cjs-shim.js +0 -13
  34. package/dist/clawdash +0 -1729
  35. package/dist/clawdash.meta.json +0 -2236
  36. package/dist/widgets.cjs +0 -6511
  37. package/docker-compose.yml +0 -67
  38. package/esbuild.config.js +0 -158
  39. package/eslint.config.js +0 -56
  40. package/examples/plugins/README.md +0 -122
  41. package/examples/plugins/api-status/index.js +0 -294
  42. package/examples/plugins/api-status/plugin.json +0 -19
  43. package/examples/plugins/hello-world/index.js +0 -104
  44. package/examples/plugins/hello-world/plugin.json +0 -15
  45. package/examples/plugins/system-metrics-chart/index.js +0 -339
  46. package/examples/plugins/system-metrics-chart/plugin.json +0 -18
  47. package/examples/plugins/weather-widget/index.js +0 -136
  48. package/examples/plugins/weather-widget/plugin.json +0 -19
  49. package/index.cjs +0 -23005
  50. package/jest.config.js +0 -11
  51. package/scripts/release.js +0 -595
  52. package/src/database.js +0 -734
  53. package/tests/alerts.test.js +0 -437
  54. package/tests/auto-save.test.js +0 -529
  55. package/tests/cache.test.js +0 -317
  56. package/tests/cli.test.js +0 -351
  57. package/tests/command-palette.test.js +0 -320
  58. package/tests/config-processor.test.js +0 -452
  59. package/tests/config-validator.test.js +0 -710
  60. package/tests/config-watcher.test.js +0 -594
  61. package/tests/config.test.js +0 -755
  62. package/tests/database.test.js +0 -438
  63. package/tests/errors.test.js +0 -189
  64. package/tests/example-plugins.test.js +0 -624
  65. package/tests/integration.test.js +0 -1321
  66. package/tests/loading-states.test.js +0 -300
  67. package/tests/manifest-validation-on-load.test.js +0 -671
  68. package/tests/performance-monitor.test.js +0 -190
  69. package/tests/plugin-api-rate-limit.test.js +0 -302
  70. package/tests/plugin-errors.test.js +0 -311
  71. package/tests/plugin-lifecycle-e2e.test.js +0 -1036
  72. package/tests/plugin-reload.test.js +0 -764
  73. package/tests/rate-limiter.test.js +0 -539
  74. package/tests/retry.test.js +0 -308
  75. package/tests/security.test.js +0 -411
  76. package/tests/settings-modal.test.js +0 -226
  77. package/tests/theme-selector.test.js +0 -57
  78. package/tests/utils.js +0 -242
  79. package/tests/utils.test.js +0 -317
  80. package/tests/validate-plugin-cli.test.js +0 -359
  81. package/tests/validation.test.js +0 -837
  82. package/tests/web-server.test.js +0 -646
  83. package/tests/widget-arrange-mode.test.js +0 -183
  84. package/tests/widget-config-hot-reload.test.js +0 -465
  85. package/tests/widget-dependency.test.js +0 -591
  86. package/tests/widget-error-boundary.test.js +0 -749
  87. package/tests/widget-error-isolation.test.js +0 -469
  88. package/tests/widget-integration.test.js +0 -1147
  89. package/tests/widget-refresh-intervals.test.js +0 -284
  90. package/tests/worker-pool.test.js +0 -522
@@ -1,1147 +0,0 @@
1
- /**
2
- * Widget Integration Tests
3
- * Tests for plugin loading, manifest validation, and config sanitization
4
- */
5
-
6
- import { jest } from '@jest/globals';
7
- import { join } from 'path';
8
- import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises';
9
- import { existsSync } from 'fs';
10
- import { tmpdir } from 'os';
11
-
12
- // Import modules under test
13
- import { WidgetLoader } from '../src/widgets/widget-loader.js';
14
- import { validateManifest, BaseWidget, PluginAPI } from '../src/widgets/plugin-api.js';
15
- import { sanitizeWidgetConfig, validateWidgetConfig, WidgetConfigValidator, validatePluginPath, validatePluginName } from '../src/security.js';
16
-
17
- describe('WidgetLoader', () => {
18
- let loader;
19
- let tempDir;
20
-
21
- beforeEach(async () => {
22
- // Create temp directory for test plugins
23
- tempDir = await mkdtemp(join(tmpdir(), 'widget-test-'));
24
- loader = new WidgetLoader({ pluginsDir: tempDir });
25
- });
26
-
27
- afterEach(async () => {
28
- // Cleanup
29
- if (loader) {
30
- await loader.clear();
31
- }
32
- if (tempDir) {
33
- await rm(tempDir, { recursive: true, force: true });
34
- }
35
- });
36
-
37
- describe('register() and load()', () => {
38
- test('should register a widget without loading it', () => {
39
- const loader = new WidgetLoader();
40
- const metadata = { name: 'Test Widget', version: '1.0.0' };
41
- const loaderFn = jest.fn().mockResolvedValue({ render: jest.fn(), getData: jest.fn() });
42
-
43
- loader.register('test-widget', metadata, loaderFn);
44
-
45
- const meta = loader.getMetadata('test-widget');
46
- expect(meta).toBeDefined();
47
- expect(meta.name).toBe('Test Widget');
48
- expect(loaderFn).not.toHaveBeenCalled();
49
- });
50
-
51
- test('should load a registered widget', async () => {
52
- const loader = new WidgetLoader();
53
- const instance = {
54
- render: jest.fn(),
55
- getData: jest.fn(),
56
- destroy: jest.fn(),
57
- };
58
- const loaderFn = jest.fn().mockResolvedValue(instance);
59
-
60
- loader.register('test-widget', { name: 'Test' }, loaderFn);
61
- const loaded = await loader.load('test-widget');
62
-
63
- expect(loaderFn).toHaveBeenCalled();
64
- expect(loaded).toBe(instance);
65
- });
66
-
67
- test('should cache loaded widgets', async () => {
68
- const loader = new WidgetLoader();
69
- const loaderFn = jest.fn().mockResolvedValue({
70
- render: jest.fn(),
71
- getData: jest.fn(),
72
- });
73
-
74
- loader.register('test-widget', { name: 'Test' }, loaderFn);
75
-
76
- await loader.load('test-widget');
77
- await loader.load('test-widget');
78
-
79
- expect(loaderFn).toHaveBeenCalledTimes(1);
80
- });
81
-
82
- test('should throw for unregistered widget', async () => {
83
- const loader = new WidgetLoader();
84
-
85
- await expect(loader.load('nonexistent')).rejects.toThrow("Widget 'nonexistent' not registered");
86
- });
87
-
88
- test('should loadAndRegister in one call', async () => {
89
- const loader = new WidgetLoader();
90
- const instance = {
91
- render: jest.fn(),
92
- getData: jest.fn(),
93
- destroy: jest.fn(),
94
- };
95
- const loaderFn = jest.fn().mockResolvedValue(instance);
96
-
97
- const loaded = await loader.loadAndRegister('test-widget', { name: 'Test' }, loaderFn);
98
-
99
- expect(loaderFn).toHaveBeenCalled();
100
- expect(loaded).toBe(instance);
101
-
102
- // Verify it's registered
103
- const meta = loader.getMetadata('test-widget');
104
- expect(meta).toBeDefined();
105
- expect(meta.name).toBe('Test');
106
-
107
- // Verify it's loaded
108
- expect(loader.isLoaded('test-widget')).toBe(true);
109
- });
110
-
111
- test('should loadAndRegister with lazyLoad option', async () => {
112
- const loader = new WidgetLoader();
113
- const instance = {
114
- render: jest.fn(),
115
- getData: jest.fn(),
116
- };
117
- const loaderFn = jest.fn().mockResolvedValue(instance);
118
-
119
- // Register with lazyLoad: true (default)
120
- await loader.loadAndRegister('lazy-widget', { name: 'Lazy', lazyLoad: true }, loaderFn);
121
-
122
- // Should be registered
123
- expect(loader.getMetadata('lazy-widget')).toBeDefined();
124
-
125
- // With lazyLoad, the loader shouldn't be called immediately
126
- // (Note: loadAndRegister always loads, regardless of lazyLoad setting)
127
- expect(loader.isLoaded('lazy-widget')).toBe(true);
128
- });
129
-
130
- test('should handle loadAndRegister failure', async () => {
131
- const loader = new WidgetLoader();
132
- const loaderFn = jest.fn().mockRejectedValue(new Error('Load failed'));
133
-
134
- await expect(loader.loadAndRegister('failing-widget', { name: 'Failing' }, loaderFn)).rejects.toThrow('Load failed');
135
-
136
- // Widget should be registered even if load failed
137
- expect(loader.getMetadata('failing-widget')).toBeDefined();
138
- expect(loader.isLoaded('failing-widget')).toBe(false);
139
- });
140
-
141
- test('should validate widget has required methods', async () => {
142
- const loader = new WidgetLoader();
143
- const invalidWidget = { someMethod: jest.fn() };
144
-
145
- loader.register('invalid-widget', {}, () => Promise.resolve(invalidWidget));
146
-
147
- await expect(loader.load('invalid-widget')).rejects.toThrow(/missing required methods/);
148
- });
149
- });
150
-
151
- describe('loadMany()', () => {
152
- test('should load multiple widgets in parallel', async () => {
153
- const loader = new WidgetLoader();
154
- const instances = {};
155
-
156
- for (let i = 0; i < 3; i++) {
157
- instances[`widget-${i}`] = {
158
- render: jest.fn(),
159
- getData: jest.fn(),
160
- };
161
- loader.register(`widget-${i}`, { name: `Widget ${i}` }, () => Promise.resolve(instances[`widget-${i}`]));
162
- }
163
-
164
- const loaded = await loader.loadMany(['widget-0', 'widget-1', 'widget-2']);
165
-
166
- expect(loaded.size).toBe(3);
167
- expect(loaded.get('widget-0')).toBe(instances['widget-0']);
168
- });
169
-
170
- test('should handle partial failures', async () => {
171
- const loader = new WidgetLoader();
172
-
173
- loader.register('good-widget', {}, () => Promise.resolve({
174
- render: jest.fn(),
175
- getData: jest.fn(),
176
- }));
177
-
178
- loader.register('bad-widget', {}, () => Promise.resolve({ invalid: true }));
179
-
180
- const loaded = await loader.loadMany(['good-widget', 'bad-widget']);
181
-
182
- // Good widget should load, bad widget should fail validation
183
- expect(loaded.size).toBe(1);
184
- expect(loaded.has('good-widget')).toBe(true);
185
- });
186
- });
187
-
188
- describe('hooks', () => {
189
- test('should call beforeLoad and afterLoad hooks', async () => {
190
- const loader = new WidgetLoader();
191
- const beforeLoad = jest.fn();
192
- const afterLoad = jest.fn();
193
-
194
- loader.addHook('beforeLoad', beforeLoad);
195
- loader.addHook('afterLoad', afterLoad);
196
-
197
- loader.register('test-widget', {}, () => Promise.resolve({
198
- render: jest.fn(),
199
- getData: jest.fn(),
200
- }));
201
-
202
- await loader.load('test-widget');
203
-
204
- expect(beforeLoad).toHaveBeenCalled();
205
- expect(afterLoad).toHaveBeenCalled();
206
- });
207
-
208
- test('should call beforeUnload hook', async () => {
209
- const loader = new WidgetLoader();
210
- const beforeUnload = jest.fn();
211
-
212
- loader.addHook('beforeUnload', beforeUnload);
213
-
214
- loader.register('test-widget', {}, () => Promise.resolve({
215
- render: jest.fn(),
216
- getData: jest.fn(),
217
- destroy: jest.fn(),
218
- }));
219
-
220
- await loader.load('test-widget');
221
- await loader.unload('test-widget');
222
-
223
- expect(beforeUnload).toHaveBeenCalled();
224
- });
225
- });
226
- });
227
-
228
- describe('loadAllPluginsWithFallback()', () => {
229
- let loader;
230
- let tempDir;
231
-
232
- beforeEach(async () => {
233
- tempDir = await mkdtemp(join(tmpdir(), 'plugin-test-'));
234
- loader = new WidgetLoader({ pluginsDir: tempDir });
235
- });
236
-
237
- afterEach(async () => {
238
- if (loader) {
239
- await loader.clear();
240
- }
241
- if (tempDir) {
242
- await rm(tempDir, { recursive: true, force: true });
243
- }
244
- });
245
-
246
- test('should load valid plugins successfully', async () => {
247
- // Create valid plugin
248
- const pluginDir = join(tempDir, 'valid-plugin');
249
- await mkdir(pluginDir);
250
-
251
- // Add package.json to mark as ESM module
252
- await writeFile(join(pluginDir, 'package.json'), JSON.stringify({
253
- type: 'module'
254
- }));
255
-
256
- await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({
257
- id: 'valid-plugin',
258
- name: 'Valid Plugin',
259
- version: '1.0.0',
260
- type: 'widget',
261
- config: { message: 'test' },
262
- }));
263
-
264
- await writeFile(join(pluginDir, 'index.js'), `
265
- export default {
266
- render: () => {},
267
- getData: async () => ({ data: 'test' })
268
- };
269
- `);
270
-
271
- const results = await loader.loadAllPluginsWithFallback();
272
-
273
- expect(results.successful).toContain('valid-plugin');
274
- expect(results.failed).toHaveLength(0);
275
- });
276
-
277
- test('should handle malformed plugin.json gracefully', async () => {
278
- // Create plugin with malformed JSON
279
- const pluginDir = join(tempDir, 'malformed-plugin');
280
- await mkdir(pluginDir);
281
-
282
- await writeFile(join(pluginDir, 'plugin.json'), '{ invalid json }');
283
- await writeFile(join(pluginDir, 'index.js'), 'export default { render: () => {}, getData: async () => ({}) };');
284
-
285
- const results = await loader.loadAllPluginsWithFallback();
286
-
287
- // Should skip or fail gracefully - malformed JSON should not crash
288
- expect(results.successful).not.toContain('malformed-plugin');
289
- // The plugin should either fail or be skipped, not succeed
290
- const wasHandled = results.failed.some(f => f.id === 'malformed-plugin') ||
291
- results.skipped.includes('malformed-plugin') ||
292
- results.successful.length === 0; // If nothing loaded, that's also acceptable
293
- expect(wasHandled).toBe(true);
294
- });
295
-
296
- test('should handle missing plugin.json', async () => {
297
- // Create plugin directory without manifest
298
- const pluginDir = join(tempDir, 'no-manifest');
299
- await mkdir(pluginDir);
300
- await writeFile(join(pluginDir, 'index.js'), 'export default {}');
301
-
302
- const results = await loader.loadAllPluginsWithFallback();
303
-
304
- // Should skip directories without plugin.json
305
- expect(results.successful).not.toContain('no-manifest');
306
- });
307
-
308
- test('should handle missing index.js', async () => {
309
- // Create plugin with manifest but no entry point
310
- const pluginDir = join(tempDir, 'no-entry');
311
- await mkdir(pluginDir);
312
-
313
- await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({
314
- id: 'no-entry',
315
- name: 'No Entry',
316
- version: '1.0.0',
317
- type: 'widget',
318
- }));
319
-
320
- const results = await loader.loadAllPluginsWithFallback();
321
-
322
- // Should skip directories without index.js
323
- expect(results.successful).not.toContain('no-entry');
324
- });
325
-
326
- test('should continue loading after plugin error with continueOnError', async () => {
327
- // Create one valid and one invalid plugin
328
- const validDir = join(tempDir, 'valid-plugin');
329
- await mkdir(validDir);
330
- await writeFile(join(validDir, 'plugin.json'), JSON.stringify({
331
- id: 'valid-plugin',
332
- name: 'Valid',
333
- version: '1.0.0',
334
- type: 'widget',
335
- }));
336
- await writeFile(join(validDir, 'index.js'), `
337
- export default { render: () => {}, getData: async () => ({}) };
338
- `);
339
-
340
- const invalidDir = join(tempDir, 'invalid-plugin');
341
- await mkdir(invalidDir);
342
- await writeFile(join(invalidDir, 'plugin.json'), JSON.stringify({
343
- id: 'invalid-plugin',
344
- name: 'Invalid',
345
- version: '1.0.0',
346
- type: 'widget',
347
- }));
348
- await writeFile(join(invalidDir, 'index.js'), 'export default { invalid: true };'); // Missing required methods
349
-
350
- const results = await loader.loadAllPluginsWithFallback({ continueOnError: true });
351
-
352
- // Valid plugin should load despite invalid one failing
353
- expect(results.successful).toContain('valid-plugin');
354
- });
355
-
356
- test('should handle plugin that throws during load', async () => {
357
- const pluginDir = join(tempDir, 'throwing-plugin');
358
- await mkdir(pluginDir);
359
-
360
- // Set lazyLoad: false to force immediate loading which will trigger the error
361
- await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({
362
- id: 'throwing-plugin',
363
- name: 'Throwing',
364
- version: '1.0.0',
365
- type: 'widget',
366
- lazyLoad: false, // Force immediate load
367
- }));
368
-
369
- await writeFile(join(pluginDir, 'index.js'), `
370
- throw new Error('Plugin failed to initialize');
371
- export default {};
372
- `);
373
-
374
- // With fallbackOnError: false, errors propagate and are caught
375
- const results = await loader.loadAllPluginsWithFallback({
376
- continueOnError: true,
377
- fallbackOnError: false,
378
- });
379
-
380
- // The plugin should fail during auto-load
381
- expect(results.failed.some(f => f.id === 'throwing-plugin')).toBe(true);
382
- });
383
- });
384
-
385
- describe('validateManifest()', () => {
386
- test('should validate required fields', () => {
387
- const validManifest = {
388
- name: 'Test Plugin',
389
- version: '1.0.0',
390
- entryPoint: 'index.js',
391
- };
392
-
393
- expect(() => validateManifest(validManifest)).not.toThrow();
394
- });
395
-
396
- test('should reject missing name', () => {
397
- const manifest = {
398
- version: '1.0.0',
399
- entryPoint: 'index.js',
400
- };
401
-
402
- expect(() => validateManifest(manifest)).toThrow(/Missing required fields/);
403
- });
404
-
405
- test('should reject missing version', () => {
406
- const manifest = {
407
- name: 'Test',
408
- entryPoint: 'index.js',
409
- };
410
-
411
- expect(() => validateManifest(manifest)).toThrow(/Missing required fields/);
412
- });
413
-
414
- test('should reject missing entryPoint', () => {
415
- const manifest = {
416
- name: 'Test',
417
- version: '1.0.0',
418
- };
419
-
420
- expect(() => validateManifest(manifest)).toThrow(/Missing required fields/);
421
- });
422
-
423
- test('should reject invalid semver format', () => {
424
- const manifest = {
425
- name: 'Test',
426
- version: 'v1', // Invalid semver
427
- entryPoint: 'index.js',
428
- };
429
-
430
- expect(() => validateManifest(manifest)).toThrow(/semver/);
431
- });
432
-
433
- test('should accept valid semver formats', () => {
434
- const versions = ['1.0.0', '0.0.1', '10.20.30', '1.0.0-beta', '1.0.0-beta.1'];
435
-
436
- for (const version of versions) {
437
- const manifest = {
438
- name: 'Test',
439
- version,
440
- entryPoint: 'index.js',
441
- };
442
-
443
- expect(() => validateManifest(manifest)).not.toThrow();
444
- }
445
- });
446
- });
447
-
448
- describe('WidgetConfigValidator', () => {
449
- let validator;
450
-
451
- beforeEach(() => {
452
- validator = new WidgetConfigValidator();
453
- });
454
-
455
- describe('basic sanitization', () => {
456
- test('should return empty object for null/undefined', () => {
457
- expect(validator.validate(null)).toEqual({});
458
- expect(validator.validate(undefined)).toEqual({});
459
- });
460
-
461
- test('should reject non-object config', () => {
462
- expect(() => validator.validate('string')).toThrow('must be an object');
463
- expect(() => validator.validate(123)).toThrow('must be an object');
464
- });
465
-
466
- test('should pass through valid objects', () => {
467
- const config = { name: 'test', count: 5, enabled: true };
468
- expect(validator.validate(config)).toEqual(config);
469
- });
470
- });
471
-
472
- describe('string handling', () => {
473
- test('should strip null bytes from strings', () => {
474
- const config = { message: 'hello\0world' };
475
- const sanitized = validator.validate(config);
476
-
477
- expect(sanitized.message).toBe('helloworld');
478
- });
479
-
480
- test('should truncate long strings', () => {
481
- const longString = 'a'.repeat(2000);
482
- const config = { message: longString };
483
- const sanitized = validator.validate(config);
484
-
485
- expect(sanitized.message.length).toBe(1000);
486
- });
487
- });
488
-
489
- describe('number handling', () => {
490
- test('should reject NaN', () => {
491
- const config = { value: NaN };
492
- const sanitized = validator.validate(config);
493
-
494
- expect(sanitized.value).toBe(0);
495
- });
496
-
497
- test('should reject Infinity', () => {
498
- const config = { value: Infinity };
499
- const sanitized = validator.validate(config);
500
-
501
- expect(sanitized.value).toBe(0);
502
- });
503
-
504
- test('should accept valid numbers', () => {
505
- const config = { int: 42, float: 3.14, negative: -10 };
506
- const sanitized = validator.validate(config);
507
-
508
- expect(sanitized).toEqual(config);
509
- });
510
- });
511
-
512
- describe('array handling', () => {
513
- test('should limit array length', () => {
514
- const validator = new WidgetConfigValidator({ maxArrayLength: 5 });
515
- const config = { items: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] };
516
- const sanitized = validator.validate(config);
517
-
518
- expect(sanitized.items.length).toBe(5);
519
- });
520
-
521
- test('should sanitize array items', () => {
522
- const config = { items: ['normal', 'with\0null', 123] };
523
- const sanitized = validator.validate(config);
524
-
525
- expect(sanitized.items).toEqual(['normal', 'withnull', 123]);
526
- });
527
- });
528
-
529
- describe('depth limiting', () => {
530
- test('should handle deeply nested objects gracefully', () => {
531
- // The validator catches depth errors and returns null for values that exceed depth
532
- const validator = new WidgetConfigValidator({ maxDepth: 3 });
533
- const config = {
534
- level1: {
535
- level2: {
536
- level3: {
537
- level4: {
538
- level5: 'too deep',
539
- },
540
- },
541
- },
542
- },
543
- };
544
-
545
- // Should not throw; instead deep values become null
546
- const sanitized = validator.validate(config);
547
- expect(sanitized).toBeDefined();
548
- expect(sanitized.level1).toBeDefined();
549
- });
550
- });
551
-
552
- describe('schema validation', () => {
553
- test('should only allow whitelisted keys with schema', () => {
554
- const schema = {
555
- properties: {
556
- name: { type: 'string' },
557
- count: { type: 'number' },
558
- },
559
- };
560
-
561
- const config = { name: 'test', count: 5, malicious: 'hack' };
562
- const sanitized = validator.validate(config, schema);
563
-
564
- expect(sanitized).toEqual({ name: 'test', count: 5 });
565
- expect(sanitized.malicious).toBeUndefined();
566
- });
567
-
568
- test('should handle schema-based validation', () => {
569
- const schema = {
570
- properties: {
571
- timeout: { type: 'number', default: 5000 },
572
- name: { type: 'string' },
573
- },
574
- };
575
-
576
- const config = { timeout: 'invalid', name: 'test' };
577
- const sanitized = validator.validate(config, schema);
578
-
579
- // Strings are valid, so 'invalid' stays as-is (string type is in allowedTypes)
580
- expect(sanitized.timeout).toBe('invalid');
581
- expect(sanitized.name).toBe('test');
582
- });
583
- });
584
- });
585
-
586
- describe('sanitizeWidgetConfig()', () => {
587
- test('should sanitize malicious inputs', () => {
588
- const malicious = {
589
- __proto__: { polluted: true },
590
- constructor: { prototype: { polluted: true } },
591
- message: 'normal string',
592
- };
593
-
594
- const sanitized = sanitizeWidgetConfig(malicious);
595
-
596
- // Should not have polluted prototype
597
- expect(sanitized.message).toBe('normal string');
598
- expect({}.polluted).toBeUndefined();
599
- });
600
-
601
- test('should handle special string values', () => {
602
- const config = {
603
- script: '<script>alert("xss")</script>',
604
- sql: "'; DROP TABLE users; --",
605
- path: '../../../etc/passwd',
606
- unicode: '\u0000\u0001\u0002', // null byte + two control chars
607
- };
608
-
609
- const sanitized = sanitizeWidgetConfig(config);
610
-
611
- // Should sanitize null bytes but keep other content
612
- expect(sanitized.script).toBe('<script>alert("xss")</script>');
613
- expect(sanitized.sql).toBe("'; DROP TABLE users; --");
614
- expect(sanitized.path).toBe('../../../etc/passwd');
615
- // Only null byte (\u0000) is stripped; \u0001 and \u0002 remain
616
- expect(sanitized.unicode).toBe('\u0001\u0002');
617
- });
618
-
619
- test('should handle edge cases', () => {
620
- expect(sanitizeWidgetConfig(null)).toEqual({});
621
- expect(sanitizeWidgetConfig(undefined)).toEqual({});
622
- expect(sanitizeWidgetConfig({})).toEqual({});
623
- });
624
- });
625
-
626
- describe('validateWidgetConfig()', () => {
627
- test('should return valid result for valid config', () => {
628
- const schema = {
629
- properties: {
630
- name: { type: 'string' },
631
- },
632
- };
633
-
634
- const result = validateWidgetConfig({ name: 'test' }, schema);
635
-
636
- expect(result.valid).toBe(true);
637
- expect(result.errors).toEqual([]);
638
- });
639
-
640
- test('should handle deeply nested config gracefully', () => {
641
- // Default maxDepth is 10, so 11 levels should be handled gracefully
642
- const config = {
643
- level1: {
644
- level2: {
645
- level3: {
646
- level4: {
647
- level5: {
648
- level6: {
649
- level7: {
650
- level8: {
651
- level9: {
652
- level10: {
653
- level11: 'too deep',
654
- },
655
- },
656
- },
657
- },
658
- },
659
- },
660
- },
661
- },
662
- },
663
- },
664
- };
665
-
666
- // The validator handles depth errors gracefully by returning null for deep values
667
- const result = validateWidgetConfig(config);
668
-
669
- // Should either pass (with deep values nulled) or return errors
670
- // depending on implementation details
671
- expect(result).toBeDefined();
672
- expect(result).toHaveProperty('valid');
673
- expect(result).toHaveProperty('errors');
674
- });
675
- });
676
-
677
- describe('BaseWidget', () => {
678
- test('should initialize with default values', () => {
679
- const widget = new BaseWidget();
680
-
681
- expect(widget.id).toBeDefined();
682
- expect(widget.name).toBe('Unnamed Widget');
683
- expect(widget.loaded).toBe(false);
684
- });
685
-
686
- test('should accept options', () => {
687
- const widget = new BaseWidget({
688
- id: 'test-widget',
689
- name: 'Test Widget',
690
- config: { setting: 'value' },
691
- });
692
-
693
- expect(widget.id).toBe('test-widget');
694
- expect(widget.name).toBe('Test Widget');
695
- expect(widget.config).toEqual({ setting: 'value' });
696
- });
697
-
698
- test('should throw on create() by default', async () => {
699
- const widget = new BaseWidget();
700
-
701
- await expect(widget.create()).rejects.toThrow('create() must be implemented');
702
- });
703
-
704
- test('should provide lifecycle methods', async () => {
705
- const widget = new BaseWidget();
706
-
707
- await widget.init();
708
- expect(widget.loaded).toBe(true);
709
-
710
- await widget.destroy();
711
- expect(widget.loaded).toBe(false);
712
- });
713
-
714
- test('should provide getMetadata()', () => {
715
- const widget = new BaseWidget({
716
- id: 'test',
717
- name: 'Test',
718
- description: 'A test widget',
719
- });
720
-
721
- const metadata = widget.getMetadata();
722
-
723
- expect(metadata.id).toBe('test');
724
- expect(metadata.name).toBe('Test');
725
- expect(metadata.description).toBe('A test widget');
726
- expect(metadata.loaded).toBe(false);
727
- });
728
- });
729
-
730
- describe('PluginAPI', () => {
731
- let api;
732
-
733
- beforeEach(() => {
734
- api = new PluginAPI();
735
- });
736
-
737
- describe('extension points', () => {
738
- test('should register extension points', () => {
739
- api.registerExtensionPoint('myExtension', { description: 'Test extension' });
740
-
741
- expect(api.extensions.has('myExtension')).toBe(true);
742
- });
743
-
744
- test('should add handlers to extension points', () => {
745
- api.registerExtensionPoint('myExtension');
746
- const handler = jest.fn();
747
-
748
- api.extend('myExtension', handler);
749
- api.executeExtension('myExtension', 'arg1', 'arg2');
750
-
751
- expect(handler).toHaveBeenCalledWith('arg1', 'arg2');
752
- });
753
-
754
- test('should throw for non-existent extension point', () => {
755
- expect(() => api.extend('nonexistent', jest.fn())).toThrow(/not found/);
756
- });
757
-
758
- test('should enforce single handler when multiple is false', () => {
759
- api.registerExtensionPoint('single', { multiple: false });
760
- api.extend('single', jest.fn());
761
-
762
- expect(() => api.extend('single', jest.fn())).toThrow(/only allows one handler/);
763
- });
764
-
765
- test('should sort handlers by priority', async () => {
766
- api.registerExtensionPoint('ordered');
767
- const order = [];
768
-
769
- api.extend('ordered', () => order.push(2), { priority: 100 });
770
- api.extend('ordered', () => order.push(1), { priority: 1 });
771
- api.extend('ordered', () => order.push(3), { priority: 200 });
772
-
773
- await api.executeExtension('ordered');
774
-
775
- expect(order).toEqual([1, 2, 3]);
776
- });
777
- });
778
-
779
- describe('data providers', () => {
780
- test('should register and call data providers', async () => {
781
- const provider = jest.fn().mockResolvedValue({ data: 'test' });
782
- api.registerDataProvider('myData', provider);
783
-
784
- const result = await api.getData('myData');
785
-
786
- expect(provider).toHaveBeenCalled();
787
- expect(result).toEqual({ data: 'test' });
788
- });
789
-
790
- test('should check if provider exists', () => {
791
- api.registerDataProvider('exists', jest.fn());
792
-
793
- expect(api.hasDataProvider('exists')).toBe(true);
794
- expect(api.hasDataProvider('nonexistent')).toBe(false);
795
- });
796
-
797
- test('should throw for non-existent provider', async () => {
798
- await expect(api.getData('nonexistent')).rejects.toThrow(/not found/);
799
- });
800
-
801
- test('should require provider to be a function', () => {
802
- expect(() => api.registerDataProvider('invalid', 'not a function')).toThrow(/must be a function/);
803
- });
804
- });
805
-
806
- describe('hooks', () => {
807
- test('should add and execute hooks', async () => {
808
- const hook = jest.fn();
809
- api.addHook('init', hook);
810
-
811
- await api.executeHooks('init', { value: 1 });
812
-
813
- expect(hook).toHaveBeenCalledWith({ value: 1 });
814
- });
815
-
816
- test('should remove one-time hooks after execution', async () => {
817
- const hook = jest.fn();
818
- api.addHook('once', hook, { once: true });
819
-
820
- await api.executeHooks('once', {});
821
- await api.executeHooks('once', {});
822
-
823
- expect(hook).toHaveBeenCalledTimes(1);
824
- });
825
-
826
- test('should sort hooks by priority', async () => {
827
- const order = [];
828
-
829
- api.addHook('ordered', () => order.push(2), { priority: 100 });
830
- api.addHook('ordered', () => order.push(1), { priority: 1 });
831
- api.addHook('ordered', () => order.push(3), { priority: 200 });
832
-
833
- await api.executeHooks('ordered', {});
834
-
835
- expect(order).toEqual([1, 2, 3]);
836
- });
837
-
838
- test('should return modified context', async () => {
839
- api.addHook('transform', (ctx) => { ctx.value *= 2; });
840
- api.addHook('transform', (ctx) => { ctx.value += 10; });
841
-
842
- const result = await api.executeHooks('transform', { value: 5 });
843
-
844
- expect(result.value).toBe(20);
845
- });
846
- });
847
-
848
- describe('getInfo()', () => {
849
- test('should return API information', () => {
850
- api.registerExtensionPoint('ext1');
851
- api.registerDataProvider('data1', jest.fn());
852
- api.addHook('hook1', jest.fn());
853
-
854
- const info = api.getInfo();
855
-
856
- expect(info.version).toBe('1.0.0');
857
- expect(info.extensionPoints).toContain('ext1');
858
- expect(info.dataProviders).toContain('data1');
859
- expect(info.hooks).toContain('hook1');
860
- });
861
- });
862
- });
863
-
864
- describe('Path Validation', () => {
865
- let tempDir;
866
-
867
- beforeEach(async () => {
868
- tempDir = await mkdtemp(join(tmpdir(), 'path-validation-test-'));
869
- });
870
-
871
- afterEach(async () => {
872
- if (tempDir) {
873
- await rm(tempDir, { recursive: true, force: true });
874
- }
875
- });
876
-
877
- describe('validatePluginName()', () => {
878
- test('should accept valid plugin names', () => {
879
- const validNames = ['my-widget', 'test_plugin', 'widget123', 'MyWidget', 'my-widget-v2'];
880
-
881
- for (const name of validNames) {
882
- const result = validatePluginName(name);
883
- expect(result.valid).toBe(true);
884
- expect(result.error).toBeNull();
885
- }
886
- });
887
-
888
- test('should reject empty names', () => {
889
- const result = validatePluginName('');
890
- expect(result.valid).toBe(false);
891
- expect(result.error).toContain('empty');
892
- });
893
-
894
- test('should reject null/undefined names', () => {
895
- expect(validatePluginName(null).valid).toBe(false);
896
- expect(validatePluginName(undefined).valid).toBe(false);
897
- });
898
-
899
- test('should reject names starting with non-alphanumeric', () => {
900
- const result = validatePluginName('-widget');
901
- expect(result.valid).toBe(false);
902
- expect(result.error).toContain('must start with alphanumeric');
903
- });
904
-
905
- test('should reject names with invalid characters', () => {
906
- const result = validatePluginName('my@widget');
907
- expect(result.valid).toBe(false);
908
- expect(result.error).toContain('alphanumeric');
909
- });
910
-
911
- test('should reject reserved names', () => {
912
- const reserved = ['node_modules', '.git', 'package.json'];
913
-
914
- for (const name of reserved) {
915
- const result = validatePluginName(name);
916
- expect(result.valid).toBe(false);
917
- expect(result.error).toContain('reserved');
918
- }
919
- });
920
-
921
- test('should reject names over 100 characters', () => {
922
- const longName = 'a'.repeat(101);
923
- const result = validatePluginName(longName);
924
- expect(result.valid).toBe(false);
925
- expect(result.error).toContain('too long');
926
- });
927
-
928
- test('should trim whitespace from names', () => {
929
- const result = validatePluginName(' my-widget ');
930
- expect(result.valid).toBe(true);
931
- });
932
- });
933
-
934
- describe('validatePluginPath()', () => {
935
- test('should validate simple paths within allowed directory', () => {
936
- const result = validatePluginPath('my-widget', {
937
- allowedDirs: ['/plugins'],
938
- allowAbsolute: false,
939
- });
940
-
941
- expect(result.valid).toBe(true);
942
- expect(result.path).toBe('/plugins/my-widget');
943
- expect(result.error).toBeNull();
944
- });
945
-
946
- test('should reject paths with traversal attempts', () => {
947
- const traversalPaths = ['../etc/passwd', 'widget/../../etc', '..', 'widget/../..'];
948
-
949
- for (const testPath of traversalPaths) {
950
- const result = validatePluginPath(testPath, {
951
- allowedDirs: ['/plugins'],
952
- allowAbsolute: false,
953
- });
954
- expect(result.valid).toBe(false);
955
- expect(result.error).toContain('traversal');
956
- }
957
- });
958
-
959
- test('should reject absolute paths when not allowed', () => {
960
- const result = validatePluginPath('/etc/passwd', {
961
- allowedDirs: ['/plugins'],
962
- allowAbsolute: false,
963
- });
964
-
965
- expect(result.valid).toBe(false);
966
- expect(result.error).toContain('Absolute paths');
967
- });
968
-
969
- test('should accept absolute paths when allowed', () => {
970
- const result = validatePluginPath('/plugins/my-widget', {
971
- allowedDirs: ['/plugins'],
972
- allowAbsolute: true,
973
- mustExist: false,
974
- });
975
-
976
- expect(result.valid).toBe(true);
977
- expect(result.path).toBe('/plugins/my-widget');
978
- });
979
-
980
- test('should reject paths outside allowed directories', () => {
981
- const result = validatePluginPath('/etc/passwd', {
982
- allowedDirs: ['/plugins'],
983
- allowAbsolute: true,
984
- mustExist: false,
985
- });
986
-
987
- expect(result.valid).toBe(false);
988
- expect(result.error).toContain('outside allowed');
989
- });
990
-
991
- test('should reject paths with null bytes', () => {
992
- const result = validatePluginPath('widget\0/exploit', {
993
- allowedDirs: ['/plugins'],
994
- });
995
-
996
- expect(result.valid).toBe(false);
997
- expect(result.error).toContain('null bytes');
998
- });
999
-
1000
- test('should reject paths with invalid characters', () => {
1001
- const result = validatePluginPath('my@widget', {
1002
- allowedDirs: ['/plugins'],
1003
- });
1004
-
1005
- expect(result.valid).toBe(false);
1006
- expect(result.error).toContain('Invalid characters');
1007
- });
1008
-
1009
- test('should reject hidden files/directories', () => {
1010
- const result = validatePluginPath('.hidden-widget', {
1011
- allowedDirs: ['/plugins'],
1012
- });
1013
-
1014
- expect(result.valid).toBe(false);
1015
- expect(result.error).toContain('Hidden files');
1016
- });
1017
-
1018
- test('should handle missing allowedDirs', () => {
1019
- const result = validatePluginPath('my-widget', {});
1020
-
1021
- expect(result.valid).toBe(true);
1022
- expect(result.path).toBeDefined();
1023
- });
1024
-
1025
- test('should validate existing directories', () => {
1026
- // This test uses the actual temp directory
1027
- const result = validatePluginPath('.', {
1028
- allowedDirs: [tempDir],
1029
- allowAbsolute: false,
1030
- mustExist: true,
1031
- expectedType: 'directory',
1032
- });
1033
-
1034
- // The test should pass since tempDir exists
1035
- if (result.valid) {
1036
- expect(result.path).toBeDefined();
1037
- }
1038
- });
1039
-
1040
- test('should return error for non-existent paths when mustExist is true', () => {
1041
- const result = validatePluginPath('non-existent-path-xyz', {
1042
- allowedDirs: ['/tmp'],
1043
- mustExist: true,
1044
- });
1045
-
1046
- expect(result.valid).toBe(false);
1047
- expect(result.error).toContain('does not exist');
1048
- });
1049
-
1050
- test('should check file type when expectedType is set', () => {
1051
- // Test with a file that exists (tempDir itself)
1052
- const fileResult = validatePluginPath('.', {
1053
- allowedDirs: [tempDir],
1054
- mustExist: true,
1055
- expectedType: 'file',
1056
- });
1057
-
1058
- // Should fail because . is a directory, not a file
1059
- expect(fileResult.valid).toBe(false);
1060
- expect(fileResult.error).toContain('not a file');
1061
- });
1062
- });
1063
- });
1064
-
1065
- describe('WidgetLoader with path validation', () => {
1066
- let loader;
1067
- let tempDir;
1068
-
1069
- beforeEach(async () => {
1070
- tempDir = await mkdtemp(join(tmpdir(), 'path-validation-test-'));
1071
- loader = new WidgetLoader({ pluginsDir: tempDir });
1072
- });
1073
-
1074
- afterEach(async () => {
1075
- if (loader) {
1076
- await loader.clear();
1077
- }
1078
- if (tempDir) {
1079
- await rm(tempDir, { recursive: true, force: true });
1080
- }
1081
- });
1082
-
1083
- test('should skip plugins with invalid directory names during discovery', async () => {
1084
- // Create a plugin with an invalid name (contains path traversal attempt)
1085
- const invalidDir = join(tempDir, '..invalid');
1086
- // Can't actually create a directory starting with .. on most systems
1087
- // So we'll test with a different invalid character
1088
- const badDir = join(tempDir, 'bad@name');
1089
- await mkdir(badDir).catch(() => {});
1090
-
1091
- if (existsSync(badDir)) {
1092
- await writeFile(join(badDir, 'plugin.json'), JSON.stringify({
1093
- id: 'bad-name',
1094
- name: 'Bad Name',
1095
- version: '1.0.0',
1096
- type: 'widget',
1097
- }));
1098
- await writeFile(join(badDir, 'index.js'), 'export default { render: () => {}, getData: async () => ({}) };');
1099
-
1100
- const discovered = await loader.discoverPlugins();
1101
- expect(discovered.some(p => p.id === 'bad-name')).toBe(false);
1102
- }
1103
- });
1104
-
1105
- test('should load valid plugins with sanitized names', async () => {
1106
- const validDir = join(tempDir, 'valid-widget');
1107
- await mkdir(validDir);
1108
-
1109
- await writeFile(join(validDir, 'plugin.json'), JSON.stringify({
1110
- id: 'valid-widget',
1111
- name: 'Valid Widget',
1112
- version: '1.0.0',
1113
- type: 'widget',
1114
- }));
1115
-
1116
- await writeFile(join(validDir, 'index.js'), `
1117
- export default {
1118
- render: () => {},
1119
- getData: async () => ({ data: 'test' })
1120
- };
1121
- `);
1122
-
1123
- const discovered = await loader.discoverPlugins();
1124
- expect(discovered.some(p => p.id === 'valid-widget')).toBe(true);
1125
- });
1126
-
1127
- test('should reject loading plugin from outside plugins directory', async () => {
1128
- // Attempt to load a plugin from /tmp or similar
1129
- const outsideDir = join(tmpdir(), 'outside-plugin');
1130
- await mkdir(outsideDir, { recursive: true });
1131
-
1132
- await writeFile(join(outsideDir, 'plugin.json'), JSON.stringify({
1133
- id: 'outside-plugin',
1134
- name: 'Outside Plugin',
1135
- version: '1.0.0',
1136
- type: 'widget',
1137
- }));
1138
-
1139
- await writeFile(join(outsideDir, 'index.js'), 'export default { render: () => {}, getData: async () => ({}) };');
1140
-
1141
- // Try to load from outside the plugins directory
1142
- await expect(loader.loadPlugin(outsideDir)).rejects.toThrow(/outside allowed directories/);
1143
-
1144
- // Cleanup
1145
- await rm(outsideDir, { recursive: true, force: true });
1146
- });
1147
- });