claw-dashboard 2.1.1 → 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 (89) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/docs/API.md +1 -2
  3. package/index.js +31 -41
  4. package/package.json +22 -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/.c8rc.json +0 -38
  14. package/.dockerignore +0 -68
  15. package/.github/dependabot.yml +0 -45
  16. package/.github/pull_request_template.md +0 -39
  17. package/.github/workflows/ci.yml +0 -147
  18. package/.github/workflows/release.yml +0 -40
  19. package/.github/workflows/security.yml +0 -84
  20. package/.husky/pre-commit +0 -1
  21. package/.planning/codebase/ARCHITECTURE.md +0 -206
  22. package/.planning/codebase/INTEGRATIONS.md +0 -150
  23. package/.planning/codebase/STACK.md +0 -122
  24. package/.planning/codebase/STRUCTURE.md +0 -201
  25. package/CONTRIBUTING.md +0 -378
  26. package/Dockerfile +0 -54
  27. package/FEATURES.md +0 -307
  28. package/TODO.md +0 -28
  29. package/ai.openclaw.dashboard.plist +0 -35
  30. package/build-cjs.js +0 -127
  31. package/cjs-shim.js +0 -13
  32. package/dist/clawdash +0 -1729
  33. package/dist/clawdash.meta.json +0 -2236
  34. package/dist/widgets.cjs +0 -6511
  35. package/docker-compose.yml +0 -67
  36. package/esbuild.config.js +0 -158
  37. package/eslint.config.js +0 -56
  38. package/examples/plugins/README.md +0 -122
  39. package/examples/plugins/api-status/index.js +0 -294
  40. package/examples/plugins/api-status/plugin.json +0 -19
  41. package/examples/plugins/hello-world/index.js +0 -104
  42. package/examples/plugins/hello-world/plugin.json +0 -15
  43. package/examples/plugins/system-metrics-chart/index.js +0 -339
  44. package/examples/plugins/system-metrics-chart/plugin.json +0 -18
  45. package/examples/plugins/weather-widget/index.js +0 -136
  46. package/examples/plugins/weather-widget/plugin.json +0 -19
  47. package/index.cjs +0 -23005
  48. package/jest.config.js +0 -11
  49. package/scripts/release.js +0 -595
  50. package/src/database.js +0 -734
  51. package/tests/alerts.test.js +0 -437
  52. package/tests/auto-save.test.js +0 -529
  53. package/tests/cache.test.js +0 -317
  54. package/tests/cli.test.js +0 -351
  55. package/tests/command-palette.test.js +0 -320
  56. package/tests/config-processor.test.js +0 -452
  57. package/tests/config-validator.test.js +0 -710
  58. package/tests/config-watcher.test.js +0 -594
  59. package/tests/config.test.js +0 -755
  60. package/tests/database.test.js +0 -438
  61. package/tests/errors.test.js +0 -189
  62. package/tests/example-plugins.test.js +0 -624
  63. package/tests/integration.test.js +0 -1321
  64. package/tests/loading-states.test.js +0 -300
  65. package/tests/manifest-validation-on-load.test.js +0 -671
  66. package/tests/performance-monitor.test.js +0 -190
  67. package/tests/plugin-api-rate-limit.test.js +0 -302
  68. package/tests/plugin-errors.test.js +0 -311
  69. package/tests/plugin-lifecycle-e2e.test.js +0 -1036
  70. package/tests/plugin-reload.test.js +0 -764
  71. package/tests/rate-limiter.test.js +0 -539
  72. package/tests/removed-dependencies.test.js +0 -74
  73. package/tests/retry.test.js +0 -308
  74. package/tests/security.test.js +0 -411
  75. package/tests/settings-modal.test.js +0 -226
  76. package/tests/theme-selector.test.js +0 -57
  77. package/tests/utils.js +0 -242
  78. package/tests/utils.test.js +0 -317
  79. package/tests/validate-plugin-cli.test.js +0 -359
  80. package/tests/validation.test.js +0 -837
  81. package/tests/web-server.test.js +0 -646
  82. package/tests/widget-arrange-mode.test.js +0 -183
  83. package/tests/widget-config-hot-reload.test.js +0 -465
  84. package/tests/widget-dependency.test.js +0 -591
  85. package/tests/widget-error-boundary.test.js +0 -749
  86. package/tests/widget-error-isolation.test.js +0 -469
  87. package/tests/widget-integration.test.js +0 -1147
  88. package/tests/widget-refresh-intervals.test.js +0 -284
  89. package/tests/worker-pool.test.js +0 -522
@@ -1,1036 +0,0 @@
1
- /**
2
- * End-to-End Plugin Lifecycle Integration Tests
3
- * Tests the complete plugin load/validate/render cycle
4
- */
5
-
6
- import { jest } from '@jest/globals';
7
- import { fileURLToPath } from 'url';
8
- import { dirname, join, resolve } from 'path';
9
- import { existsSync, mkdirSync, writeFileSync, rmSync, readdirSync } from 'fs';
10
- import os from 'os';
11
-
12
- const __filename = fileURLToPath(import.meta.url);
13
- const __dirname = dirname(__filename);
14
-
15
- // Mock logger to reduce noise in tests
16
- jest.unstable_mockModule('../src/logger.js', () => ({
17
- default: {
18
- info: jest.fn(),
19
- warn: jest.fn(),
20
- error: jest.fn(),
21
- debug: jest.fn(),
22
- },
23
- }));
24
-
25
- // Import modules after mocking
26
- const { default: logger } = await import('../src/logger.js');
27
- const { WidgetLoader } = await import('../src/widgets/widget-loader.js');
28
- const { validateManifest, validateManifestFile } = await import('../src/plugin-manifest-validator.js');
29
- const { runValidatePluginCli } = await import('../src/cli/validate-plugin.js');
30
- const { PluginError, PLUGIN_ERROR_CODES } = await import('../src/plugin-errors.js');
31
-
32
- describe('Plugin Lifecycle E2E Tests', () => {
33
- let testDir;
34
- let widgetLoader;
35
- let originalCwd;
36
- let testCounter = 0;
37
-
38
- beforeAll(() => {
39
- originalCwd = process.cwd();
40
- });
41
-
42
- beforeEach(() => {
43
- // Create temporary test directory with unique ID
44
- const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${++testCounter}`;
45
- testDir = join(os.tmpdir(), `claw-test-${uniqueId}`);
46
- mkdirSync(testDir, { recursive: true });
47
-
48
- // Create a package.json with type: module so ES Modules work in temp directory
49
- writeFileSync(
50
- join(testDir, 'package.json'),
51
- JSON.stringify({ type: 'module' }, null, 2)
52
- );
53
-
54
- // Create widget loader with test directories
55
- widgetLoader = new WidgetLoader({
56
- widgetsDir: join(testDir, 'widgets'),
57
- pluginsDir: join(testDir, 'plugins'),
58
- });
59
-
60
- // Create plugins directory
61
- mkdirSync(join(testDir, 'plugins'), { recursive: true });
62
-
63
- jest.clearAllMocks();
64
- });
65
-
66
- afterEach(async () => {
67
- // Cleanup test directory
68
- if (existsSync(testDir)) {
69
- rmSync(testDir, { recursive: true, force: true });
70
- }
71
-
72
- // Clear widget loader
73
- if (widgetLoader) {
74
- await widgetLoader.clear();
75
- }
76
- });
77
-
78
- afterAll(() => {
79
- process.chdir(originalCwd);
80
- });
81
-
82
- // ============================================================================
83
- // HELPER FUNCTIONS
84
- // ============================================================================
85
-
86
- /**
87
- * Create a test plugin directory with manifest and code.
88
- * Uses unique directory names to avoid ES Module caching between tests.
89
- */
90
- function createTestPlugin(pluginName, manifest, code) {
91
- // Use unique directory name to avoid ES Module caching
92
- const uniqueDirName = `${pluginName}-${testCounter}-${Date.now()}`;
93
- const pluginDir = join(testDir, 'plugins', uniqueDirName);
94
- mkdirSync(pluginDir, { recursive: true });
95
-
96
- // Write manifest
97
- writeFileSync(join(pluginDir, 'plugin.json'), JSON.stringify(manifest, null, 2));
98
-
99
- // Write code if provided
100
- if (code) {
101
- writeFileSync(join(pluginDir, 'index.js'), code);
102
- }
103
-
104
- return pluginDir;
105
- }
106
-
107
- /**
108
- * Create a minimal valid widget plugin
109
- */
110
- function createMinimalWidget(name, options = {}) {
111
- const manifest = {
112
- id: options.id || name,
113
- name: options.name || name,
114
- version: options.version || '1.0.0',
115
- type: 'widget',
116
- category: options.category || 'custom',
117
- description: options.description || 'Test widget',
118
- author: options.author || 'Test Author',
119
- lazyLoad: options.lazyLoad !== undefined ? options.lazyLoad : false, // Default to eager loading for tests
120
- ...options.manifestExtra,
121
- };
122
-
123
- // Ensure category is a valid enum value
124
- const validCategories = ['system', 'monitoring', 'custom', 'example'];
125
- if (!validCategories.includes(manifest.category)) {
126
- manifest.category = 'custom';
127
- }
128
-
129
- const code = options.code || `
130
- export default class TestWidget {
131
- constructor(config) {
132
- this.config = config || {};
133
- this.data = { value: 42 };
134
- }
135
-
136
- async init() {
137
- return { success: true };
138
- }
139
-
140
- async getData() {
141
- return this.data;
142
- }
143
-
144
- render() {
145
- return 'TestWidget rendered';
146
- }
147
-
148
- async destroy() {
149
- // Cleanup
150
- }
151
-
152
- getWidgetConfig() {
153
- return {
154
- refreshInterval: 5000,
155
- priority: ${options.priority || 100},
156
- };
157
- }
158
- }
159
- `;
160
-
161
- return { manifest, code };
162
- }
163
-
164
- // ============================================================================
165
- // PLUGIN DISCOVERY TESTS
166
- // ============================================================================
167
-
168
- describe('Plugin Discovery', () => {
169
- test('should discover plugins from plugins directory', async () => {
170
- // Create multiple test plugins
171
- const plugin1 = createMinimalWidget('test-widget-1');
172
- createTestPlugin('test-widget-1', plugin1.manifest, plugin1.code);
173
-
174
- const plugin2 = createMinimalWidget('test-widget-2');
175
- createTestPlugin('test-widget-2', plugin2.manifest, plugin2.code);
176
-
177
- // Discover plugins
178
- const discovered = await widgetLoader.discoverPlugins();
179
-
180
- expect(discovered).toHaveLength(2);
181
- expect(discovered.map(p => p.id)).toContain('test-widget-1');
182
- expect(discovered.map(p => p.id)).toContain('test-widget-2');
183
- });
184
-
185
- test('should skip non-widget type plugins', async () => {
186
- // Create a widget plugin
187
- const widgetPlugin = createMinimalWidget('test-widget');
188
- createTestPlugin('test-widget', widgetPlugin.manifest, widgetPlugin.code);
189
-
190
- // Create a non-widget plugin
191
- const nonWidgetManifest = {
192
- id: 'test-theme',
193
- name: 'Test Theme',
194
- version: '1.0.0',
195
- type: 'theme',
196
- description: 'Test theme plugin',
197
- };
198
- createTestPlugin('test-theme', nonWidgetManifest, 'export default {}');
199
-
200
- const discovered = await widgetLoader.discoverPlugins();
201
-
202
- expect(discovered).toHaveLength(1);
203
- expect(discovered[0].id).toBe('test-widget');
204
- });
205
-
206
- test('should skip plugins with invalid directory names', async () => {
207
- // Create a plugin with invalid name
208
- const plugin = createMinimalWidget('test-widget');
209
- createTestPlugin('../malicious', plugin.manifest, plugin.code);
210
-
211
- const discovered = await widgetLoader.discoverPlugins();
212
-
213
- // Should skip the malicious directory
214
- expect(discovered).toHaveLength(0);
215
- });
216
-
217
- test('should skip plugins without required files', async () => {
218
- // Create a plugin with only manifest (no index.js)
219
- const plugin = createMinimalWidget('test-widget');
220
- const pluginDir = join(testDir, 'plugins', 'test-widget');
221
- mkdirSync(pluginDir, { recursive: true });
222
- writeFileSync(join(pluginDir, 'plugin.json'), JSON.stringify(plugin.manifest, null, 2));
223
- // Intentionally NOT creating index.js
224
-
225
- const discovered = await widgetLoader.discoverPlugins();
226
-
227
- expect(discovered).toHaveLength(0);
228
- });
229
-
230
- test('should handle empty plugins directory', async () => {
231
- const discovered = await widgetLoader.discoverPlugins();
232
- expect(discovered).toHaveLength(0);
233
- });
234
-
235
- test('should handle missing plugins directory gracefully', async () => {
236
- // Remove plugins directory
237
- rmSync(join(testDir, 'plugins'), { recursive: true, force: true });
238
-
239
- // Should return empty array, not throw
240
- const discovered = await widgetLoader.discoverPlugins();
241
- expect(discovered).toHaveLength(0);
242
- });
243
- });
244
-
245
- // ============================================================================
246
- // PLUGIN VALIDATION TESTS
247
- // ============================================================================
248
-
249
- describe('Plugin Validation', () => {
250
- test('should validate a valid plugin manifest', () => {
251
- const manifest = {
252
- id: 'test-widget',
253
- name: 'Test Widget',
254
- version: '1.0.0',
255
- type: 'widget',
256
- description: 'A test widget',
257
- author: 'Test Author',
258
- category: 'custom',
259
- };
260
-
261
- const result = validateManifest(manifest);
262
-
263
- expect(result.valid).toBe(true);
264
- expect(result.errors).toHaveLength(0);
265
- });
266
-
267
- test('should reject invalid plugin manifest', () => {
268
- const manifest = {
269
- id: 'test-widget',
270
- name: '', // Invalid: empty name
271
- version: 'invalid', // Invalid: not semver
272
- type: 'unknown', // Invalid: not in enum
273
- };
274
-
275
- const result = validateManifest(manifest);
276
-
277
- expect(result.valid).toBe(false);
278
- expect(result.errors.length).toBeGreaterThan(0);
279
- });
280
-
281
- test('should detect missing required fields', () => {
282
- const manifest = {
283
- id: 'test-widget',
284
- // Missing name, version, type
285
- };
286
-
287
- const result = validateManifest(manifest);
288
-
289
- expect(result.valid).toBe(false);
290
- expect(result.errors.some(e => e.includes('name'))).toBe(true);
291
- expect(result.errors.some(e => e.includes('version'))).toBe(true);
292
- expect(result.errors.some(e => e.includes('type'))).toBe(true);
293
- });
294
-
295
- test('should validate plugin via CLI', async () => {
296
- const plugin = createMinimalWidget('cli-test-widget');
297
- const pluginDir = createTestPlugin('cli-test-widget', plugin.manifest, plugin.code);
298
-
299
- const exitCode = await runValidatePluginCli([pluginDir]);
300
-
301
- expect(exitCode).toBe(0);
302
- });
303
-
304
- test('should reject invalid plugin via CLI', async () => {
305
- const invalidManifest = {
306
- id: 'invalid-widget',
307
- name: '', // Invalid
308
- version: 'bad', // Invalid
309
- type: 'widget',
310
- };
311
- const pluginDir = createTestPlugin('invalid-widget', invalidManifest, 'export default {}');
312
-
313
- const exitCode = await runValidatePluginCli([pluginDir]);
314
-
315
- expect(exitCode).toBe(1);
316
- });
317
-
318
- test('should validate plugin with complex config', () => {
319
- const manifest = {
320
- id: 'config-widget',
321
- name: 'Config Widget',
322
- version: '1.0.0',
323
- type: 'widget',
324
- category: 'custom',
325
- description: 'Widget with config',
326
- author: 'Test Author',
327
- config: {
328
- refreshInterval: {
329
- type: 'number',
330
- default: 5000,
331
- min: 1000,
332
- max: 60000,
333
- },
334
- theme: {
335
- type: 'string',
336
- default: 'default',
337
- options: ['default', 'dark', 'light'],
338
- },
339
- enabled: {
340
- type: 'boolean',
341
- default: true,
342
- },
343
- },
344
- };
345
-
346
- const result = validateManifest(manifest);
347
-
348
- expect(result.valid).toBe(true);
349
- });
350
- });
351
-
352
- // ============================================================================
353
- // PLUGIN LOADING TESTS
354
- // ============================================================================
355
-
356
- describe('Plugin Loading', () => {
357
- test('should load a valid plugin', async () => {
358
- const plugin = createMinimalWidget('loadable-widget');
359
- const pluginDir = createTestPlugin('loadable-widget', plugin.manifest, plugin.code);
360
-
361
- const id = await widgetLoader.loadPlugin(pluginDir);
362
-
363
- expect(id).toBe('loadable-widget');
364
- // With lazy loading, isLoaded() returns true after loadPlugin() because it auto-loads
365
- expect(widgetLoader.isLoaded('loadable-widget')).toBe(true);
366
- });
367
-
368
- test('should fail to load plugin with invalid manifest', async () => {
369
- const invalidManifest = {
370
- id: 'bad-manifest',
371
- name: '', // Invalid
372
- version: '1.0.0',
373
- type: 'widget',
374
- };
375
- const pluginDir = createTestPlugin('bad-manifest', invalidManifest, 'export default {}');
376
-
377
- // Should return null with fallbackOnError (default)
378
- const id = await widgetLoader.loadPlugin(pluginDir);
379
- expect(id).toBeNull();
380
- });
381
-
382
- test('should fail to load plugin with missing entry point', async () => {
383
- const plugin = createMinimalWidget('no-entry');
384
- const pluginDir = join(testDir, 'plugins', 'no-entry');
385
- mkdirSync(pluginDir, { recursive: true });
386
- writeFileSync(join(pluginDir, 'plugin.json'), JSON.stringify(plugin.manifest, null, 2));
387
- // Don't create index.js
388
-
389
- const id = await widgetLoader.loadPlugin(pluginDir);
390
- expect(id).toBeNull();
391
- });
392
-
393
- test('should load multiple plugins', async () => {
394
- const plugin1 = createMinimalWidget('multi-1');
395
- const plugin2 = createMinimalWidget('multi-2');
396
-
397
- createTestPlugin('multi-1', plugin1.manifest, plugin1.code);
398
- createTestPlugin('multi-2', plugin2.manifest, plugin2.code);
399
-
400
- const results = await widgetLoader.loadAllPluginsWithFallback();
401
-
402
- expect(results.successful).toContain('multi-1');
403
- expect(results.successful).toContain('multi-2');
404
- expect(results.failed).toHaveLength(0);
405
- });
406
-
407
- test('should handle partial loading with some failures', async () => {
408
- const goodPlugin = createMinimalWidget('good-widget');
409
- const badManifest = {
410
- id: 'bad-widget',
411
- name: '', // Invalid - will fail validation
412
- version: '1.0.0',
413
- type: 'widget',
414
- author: 'Test Author',
415
- category: 'custom',
416
- };
417
-
418
- createTestPlugin('good-widget', goodPlugin.manifest, goodPlugin.code);
419
- createTestPlugin('bad-widget', badManifest, 'export default {}');
420
-
421
- const results = await widgetLoader.loadAllPluginsWithFallback();
422
-
423
- // Only the good widget should be successfully loaded
424
- expect(results.successful).toContain('good-widget');
425
- // The bad widget fails validation during discovery and is never registered
426
- expect(results.successful).not.toContain('bad-widget');
427
- });
428
-
429
- test('should unload a loaded plugin', async () => {
430
- const plugin = createMinimalWidget('unloadable');
431
- const pluginDir = createTestPlugin('unloadable', plugin.manifest, plugin.code);
432
-
433
- const id = await widgetLoader.loadPlugin(pluginDir);
434
- expect(id).toBe('unloadable');
435
-
436
- // Load the widget instance
437
- const instance = await widgetLoader.load('unloadable');
438
- expect(instance).not.toBeNull();
439
- expect(widgetLoader.isLoaded('unloadable')).toBe(true);
440
-
441
- const unloaded = await widgetLoader.unload('unloadable');
442
- expect(unloaded).toBe(true);
443
- expect(widgetLoader.isLoaded('unloadable')).toBe(false);
444
- });
445
-
446
- test('should support lazy loading', async () => {
447
- const plugin = createMinimalWidget('lazy-widget', {
448
- manifestExtra: { lazyLoad: true },
449
- });
450
- const pluginDir = createTestPlugin('lazy-widget', plugin.manifest, plugin.code);
451
-
452
- // Register only (lazy)
453
- await widgetLoader.loadPlugin(pluginDir, { fallbackOnError: true });
454
-
455
- // Should be registered but not loaded
456
- expect(widgetLoader.widgetRegistry.has('lazy-widget')).toBe(true);
457
- });
458
-
459
- test('should load plugins with dependencies in correct order', async () => {
460
- // Create parent plugin
461
- const parent = createMinimalWidget('parent-widget');
462
- createTestPlugin('parent-widget', parent.manifest, parent.code);
463
-
464
- // Create child plugin with dependency
465
- const child = createMinimalWidget('child-widget', {
466
- manifestExtra: { dependencies: ['parent-widget'] },
467
- });
468
- createTestPlugin('child-widget', child.manifest, child.code);
469
-
470
- // Load plugins with dependency resolution
471
- const results = await widgetLoader.loadAllPluginsWithFallback({
472
- resolveDependencies: true,
473
- });
474
-
475
- expect(results.successful).toContain('parent-widget');
476
- expect(results.successful).toContain('child-widget');
477
- });
478
- });
479
-
480
- // ============================================================================
481
- // WIDGET RENDER LIFECYCLE TESTS
482
- // ============================================================================
483
-
484
- describe('Widget Render Lifecycle', () => {
485
- test('should complete full widget lifecycle: init -> getData -> render -> destroy', async () => {
486
- const plugin = createMinimalWidget('lifecycle-widget', {
487
- code: `
488
- export default class LifecycleWidget {
489
- constructor(config) {
490
- this.config = config;
491
- this.initCalled = false;
492
- this.data = { status: 'ready', counter: 0 };
493
- }
494
-
495
- async init() {
496
- this.initCalled = true;
497
- return { success: true };
498
- }
499
-
500
- async getData() {
501
- this.data.counter++;
502
- return this.data;
503
- }
504
-
505
- render(data) {
506
- if (!this.initCalled) {
507
- throw new Error('init not called before render');
508
- }
509
- return { content: 'Rendered: ' + JSON.stringify(data || this.data) };
510
- }
511
-
512
- async destroy() {
513
- this.data = null;
514
- }
515
- }
516
- `,
517
- });
518
-
519
- const pluginDir = createTestPlugin('lifecycle-widget', plugin.manifest, plugin.code);
520
- await widgetLoader.loadPlugin(pluginDir);
521
-
522
- // Load the widget instance to get the actual widget object
523
- const instance = await widgetLoader.load('lifecycle-widget');
524
- expect(instance).toBeDefined();
525
- expect(instance).not.toBeNull();
526
-
527
- // Test init
528
- const initResult = await instance.init();
529
- expect(initResult.success).toBe(true);
530
- expect(instance.initCalled).toBe(true);
531
-
532
- // Test getData
533
- const data = await instance.getData();
534
- expect(data.status).toBe('ready');
535
- expect(data.counter).toBe(1);
536
-
537
- // Test render
538
- const renderResult = instance.render(data);
539
- expect(renderResult.content).toContain('Rendered:');
540
- expect(renderResult.content).toContain('ready');
541
-
542
- // Test destroy
543
- await instance.destroy();
544
- expect(instance.data).toBeNull();
545
- });
546
-
547
- test('should handle widget that requires render after getData', async () => {
548
- const plugin = createMinimalWidget('render-flow', {
549
- code: `
550
- export default class RenderFlowWidget {
551
- constructor(config) {
552
- this.config = config;
553
- this.cachedData = null;
554
- }
555
-
556
- async getData() {
557
- this.cachedData = { timestamp: Date.now(), value: 'test' };
558
- return this.cachedData;
559
- }
560
-
561
- render() {
562
- if (!this.cachedData) {
563
- throw new Error('getData must be called before render');
564
- }
565
- return { rendered: true, data: this.cachedData };
566
- }
567
- }
568
- `,
569
- });
570
-
571
- const pluginDir = createTestPlugin('render-flow', plugin.manifest, plugin.code);
572
- await widgetLoader.loadPlugin(pluginDir);
573
-
574
- // Load the widget instance
575
- const instance = await widgetLoader.load('render-flow');
576
- expect(instance).toBeDefined();
577
- expect(instance).not.toBeNull();
578
-
579
- // Should throw if render called before getData
580
- expect(() => instance.render()).toThrow('getData must be called');
581
-
582
- // Should work after getData
583
- await instance.getData();
584
- const result = instance.render();
585
- expect(result.rendered).toBe(true);
586
- });
587
-
588
- test('should handle widgets with config', async () => {
589
- const plugin = createMinimalWidget('config-widget', {
590
- manifestExtra: {
591
- config: {
592
- title: { type: 'string', default: 'Default Title' },
593
- refreshInterval: { type: 'number', default: 5000 },
594
- },
595
- },
596
- code: `
597
- export default class ConfigWidget {
598
- constructor(config) {
599
- this.config = config || {};
600
- }
601
-
602
- getWidgetConfig() {
603
- return {
604
- title: this.config.title || 'Default Title',
605
- refreshInterval: this.config.refreshInterval || 5000,
606
- };
607
- }
608
-
609
- async getData() {
610
- return {
611
- config: this.getWidgetConfig(),
612
- };
613
- }
614
-
615
- render() {
616
- const cfg = this.getWidgetConfig();
617
- return { title: cfg.title };
618
- }
619
- }
620
- `,
621
- });
622
-
623
- const pluginDir = createTestPlugin('config-widget', plugin.manifest, plugin.code);
624
- await widgetLoader.loadPlugin(pluginDir);
625
-
626
- // Load the widget instance
627
- const instance = await widgetLoader.load('config-widget');
628
- expect(instance).toBeDefined();
629
- expect(instance).not.toBeNull();
630
-
631
- const config = instance.getWidgetConfig();
632
-
633
- expect(config.title).toBe('Default Title');
634
- expect(config.refreshInterval).toBe(5000);
635
- });
636
-
637
- test('should support widgets with multiple render cycles', async () => {
638
- const plugin = createMinimalWidget('multi-render', {
639
- code: `
640
- export default class MultiRenderWidget {
641
- constructor(config) {
642
- this.config = config;
643
- this.renderCount = 0;
644
- }
645
-
646
- async getData() {
647
- this.renderCount++;
648
- return { count: this.renderCount };
649
- }
650
-
651
- render(data) {
652
- return { cycle: data.count, rendered: true };
653
- }
654
- }
655
- `,
656
- });
657
-
658
- const pluginDir = createTestPlugin('multi-render', plugin.manifest, plugin.code);
659
- await widgetLoader.loadPlugin(pluginDir);
660
-
661
- // Load the widget instance using load() not get()
662
- const instance = await widgetLoader.load('multi-render');
663
- expect(instance).not.toBeNull();
664
-
665
- // Simulate multiple render cycles
666
- for (let i = 1; i <= 3; i++) {
667
- const data = await instance.getData();
668
- const result = instance.render(data);
669
- expect(result.cycle).toBe(i);
670
- }
671
- });
672
- });
673
-
674
- // ============================================================================
675
- // ERROR HANDLING TESTS
676
- // ============================================================================
677
-
678
- describe('Error Handling and Edge Cases', () => {
679
- // SKIPPED: Syntax error handling cannot be tested with Jest's ESM implementation
680
- // VM-level SyntaxErrors during dynamic import cannot be caught by try-catch
681
- // and crash the test process. This is a known limitation.
682
- // In production, syntax errors would be caught by fallbackOnError.
683
- test.skip('should handle plugin with syntax error in entry point', async () => {
684
- const manifest = {
685
- id: 'syntax-error',
686
- name: 'Syntax Error Widget',
687
- version: '1.0.0',
688
- type: 'widget',
689
- author: 'Test Author',
690
- category: 'custom',
691
- description: 'Test widget with syntax error',
692
- };
693
- const badCode = `
694
- export default class BadWidget {
695
- constructor(config) { // Missing closing brace
696
- this.config = config
697
- // Syntax error!
698
- `;
699
-
700
- const pluginDir = createTestPlugin('syntax-error', manifest, badCode);
701
-
702
- // With fallbackOnError, loadPlugin should return the ID even if loading fails
703
- const id = await widgetLoader.loadPlugin(pluginDir);
704
- expect(id).toBe('syntax-error');
705
- });
706
-
707
- test('should handle widget with missing required methods', async () => {
708
- const manifest = {
709
- id: 'incomplete',
710
- name: 'Incomplete Widget',
711
- version: '1.0.0',
712
- type: 'widget',
713
- };
714
- const incompleteCode = `
715
- export default class IncompleteWidget {
716
- constructor(config) {
717
- this.config = config;
718
- }
719
- // Missing getData and render methods
720
- }
721
- `;
722
-
723
- const pluginDir = createTestPlugin('incomplete', manifest, incompleteCode);
724
-
725
- // Should fail validation when trying to load
726
- const id = await widgetLoader.registerPlugin(pluginDir);
727
-
728
- // Try to load - should fail validation
729
- await expect(widgetLoader.load('incomplete')).rejects.toThrow();
730
- });
731
-
732
- test('should handle plugin with circular dependencies', async () => {
733
- const pluginA = createMinimalWidget('circular-a', {
734
- manifestExtra: { dependencies: ['circular-b'] },
735
- });
736
- const pluginB = createMinimalWidget('circular-b', {
737
- manifestExtra: { dependencies: ['circular-a'] },
738
- });
739
-
740
- createTestPlugin('circular-a', pluginA.manifest, pluginA.code);
741
- createTestPlugin('circular-b', pluginB.manifest, pluginB.code);
742
-
743
- const results = await widgetLoader.loadAllPluginsWithFallback({
744
- resolveDependencies: true,
745
- });
746
-
747
- // Should detect circular dependency
748
- expect(results.dependencyErrors.length).toBeGreaterThan(0);
749
- });
750
-
751
- test('should handle plugin with missing dependency', async () => {
752
- const plugin = createMinimalWidget('missing-dep', {
753
- manifestExtra: { dependencies: ['non-existent-widget'] },
754
- });
755
-
756
- createTestPlugin('missing-dep', plugin.manifest, plugin.code);
757
-
758
- const results = await widgetLoader.loadAllPluginsWithFallback({
759
- resolveDependencies: true,
760
- });
761
-
762
- // Missing dependency errors are recorded in dependencyErrors
763
- expect(results.dependencyErrors.length).toBeGreaterThan(0);
764
- });
765
-
766
- test('should handle malformed plugin.json', async () => {
767
- const pluginDir = join(testDir, 'plugins', 'malformed-json');
768
- mkdirSync(pluginDir, { recursive: true });
769
- writeFileSync(join(pluginDir, 'plugin.json'), '{ invalid json }');
770
- writeFileSync(join(pluginDir, 'index.js'), 'export default {}');
771
-
772
- const discovered = await widgetLoader.discoverPlugins();
773
- expect(discovered).toHaveLength(0);
774
- });
775
-
776
- test('should handle plugin with invalid ID format', async () => {
777
- const manifest = {
778
- id: 'Invalid ID With Spaces!', // Invalid ID format
779
- name: 'Bad ID Widget',
780
- version: '1.0.0',
781
- type: 'widget',
782
- };
783
-
784
- const result = validateManifest(manifest);
785
- // ID validation may be strict depending on schema
786
- // The schema should reject invalid ID patterns
787
- });
788
-
789
- test('should handle concurrent loading attempts', async () => {
790
- const plugin = createMinimalWidget('concurrent');
791
- const pluginDir = createTestPlugin('concurrent', plugin.manifest, plugin.code);
792
-
793
- // Start multiple concurrent loads
794
- const loads = [
795
- widgetLoader.loadPlugin(pluginDir),
796
- widgetLoader.loadPlugin(pluginDir),
797
- widgetLoader.loadPlugin(pluginDir),
798
- ];
799
-
800
- // Should handle gracefully (not crash)
801
- const results = await Promise.all(loads);
802
- expect(results.every(r => r === 'concurrent' || r === null)).toBe(true);
803
- });
804
-
805
- test('should handle plugin that throws during init', async () => {
806
- const plugin = createMinimalWidget('failing-init', {
807
- code: `
808
- export default class FailingInitWidget {
809
- constructor(config) {
810
- this.config = config;
811
- }
812
-
813
- async init() {
814
- throw new Error('Init failed');
815
- }
816
-
817
- async getData() {
818
- return {};
819
- }
820
-
821
- render() {
822
- return 'rendered';
823
- }
824
- }
825
- `,
826
- });
827
-
828
- const pluginDir = createTestPlugin('failing-init', plugin.manifest, plugin.code);
829
- await widgetLoader.loadPlugin(pluginDir);
830
-
831
- // Load the widget instance - init() is not called during load, so this succeeds
832
- const instance = await widgetLoader.load('failing-init');
833
- expect(instance).toBeDefined();
834
- expect(instance).not.toBeNull();
835
-
836
- // init should throw
837
- await expect(instance.init()).rejects.toThrow('Init failed');
838
- });
839
-
840
- test('should handle plugin that throws during getData', async () => {
841
- const plugin = createMinimalWidget('failing-getdata', {
842
- code: `
843
- export default class FailingGetDataWidget {
844
- constructor(config) {
845
- this.config = config;
846
- }
847
-
848
- async getData() {
849
- throw new Error('Data fetch failed');
850
- }
851
-
852
- render() {
853
- return 'rendered';
854
- }
855
- }
856
- `,
857
- });
858
-
859
- const pluginDir = createTestPlugin('failing-getdata', plugin.manifest, plugin.code);
860
- await widgetLoader.loadPlugin(pluginDir);
861
-
862
- // Load the widget instance
863
- const instance = await widgetLoader.load('failing-getdata');
864
- expect(instance).toBeDefined();
865
- expect(instance).not.toBeNull();
866
-
867
- await expect(instance.getData()).rejects.toThrow('Data fetch failed');
868
- });
869
-
870
- test('should handle cleanup on unload failure', async () => {
871
- const plugin = createMinimalWidget('failing-destroy', {
872
- code: `
873
- export default class FailingDestroyWidget {
874
- constructor(config) {
875
- this.config = config;
876
- }
877
-
878
- async getData() {
879
- return {};
880
- }
881
-
882
- render() {
883
- return 'rendered';
884
- }
885
-
886
- async destroy() {
887
- throw new Error('Destroy failed');
888
- }
889
- }
890
- `,
891
-
892
- });
893
-
894
- const pluginDir = createTestPlugin('failing-destroy', plugin.manifest, plugin.code);
895
- const id = await widgetLoader.loadPlugin(pluginDir);
896
- expect(id).toBe('failing-destroy');
897
-
898
- // Widget should be registered (even if loading failed)
899
- expect(widgetLoader.widgetRegistry.has('failing-destroy')).toBe(true);
900
-
901
- // Try to unload - returns false if widget not loaded, true if unloaded
902
- const unloaded = await widgetLoader.unload('failing-destroy');
903
- // unload returns false if widget was never successfully loaded
904
- expect(typeof unloaded).toBe('boolean');
905
- });
906
-
907
- test('should provide meaningful error context', async () => {
908
- const manifest = {
909
- id: 'error-context',
910
- name: '', // Invalid - will cause validation failure
911
- version: '1.0.0',
912
- type: 'widget',
913
- };
914
-
915
- const result = validateManifest(manifest);
916
-
917
- expect(result.valid).toBe(false);
918
- expect(result.errors.length).toBeGreaterThan(0);
919
- });
920
- });
921
-
922
- // ============================================================================
923
- // INTEGRATION TESTS
924
- // ============================================================================
925
-
926
- describe('Full Integration Flow', () => {
927
- test('should complete full E2E workflow: discover -> validate -> load -> render', async () => {
928
- // Create multiple widgets with different characteristics
929
- const widgetA = createMinimalWidget('widget-a', {
930
- priority: 10,
931
- description: 'High priority widget',
932
- });
933
- const widgetB = createMinimalWidget('widget-b', {
934
- priority: 50,
935
- description: 'Medium priority widget',
936
- manifestExtra: { lazyLoad: false },
937
- });
938
-
939
- createTestPlugin('widget-a', widgetA.manifest, widgetA.code);
940
- createTestPlugin('widget-b', widgetB.manifest, widgetB.code);
941
-
942
- // Step 1: Discover
943
- const discovered = await widgetLoader.discoverPlugins();
944
- expect(discovered).toHaveLength(2);
945
-
946
- // Step 2: Validate all discovered plugins
947
- for (const plugin of discovered) {
948
- const validation = validateManifest(plugin.manifest);
949
- expect(validation.valid).toBe(true);
950
- }
951
-
952
- // Step 3: Load all plugins
953
- const results = await widgetLoader.loadAllPluginsWithFallback();
954
-
955
- // Check that plugins were discovered and registered
956
- // Note: actual loading may fail due to test environment constraints
957
- expect(results.successful.length + results.failed.length + results.skipped.length)
958
- .toBeGreaterThanOrEqual(0);
959
-
960
- // Step 4: For any successfully loaded widgets, render them
961
- for (const id of results.successful) {
962
- // Load the widget instance explicitly
963
- const instance = await widgetLoader.load(id).catch(() => null);
964
- if (!instance) continue;
965
-
966
- expect(typeof instance.getData).toBe('function');
967
- expect(typeof instance.render).toBe('function');
968
-
969
- const data = await instance.getData();
970
- const rendered = instance.render(data);
971
- expect(rendered).toBeDefined();
972
- }
973
-
974
- // Step 5: Get loader stats
975
- const stats = widgetLoader.getStats();
976
- expect(stats.total).toBeGreaterThan(0);
977
- });
978
-
979
- test('should handle complex dependency chain', async () => {
980
- // Create a chain: A -> B -> C (C depends on B, B depends on A)
981
- const widgetA = createMinimalWidget('dep-a');
982
- const widgetB = createMinimalWidget('dep-b', {
983
- manifestExtra: { dependencies: ['dep-a'] },
984
- });
985
- const widgetC = createMinimalWidget('dep-c', {
986
- manifestExtra: { dependencies: ['dep-b'] },
987
- });
988
-
989
- createTestPlugin('dep-a', widgetA.manifest, widgetA.code);
990
- createTestPlugin('dep-b', widgetB.manifest, widgetB.code);
991
- createTestPlugin('dep-c', widgetC.manifest, widgetC.code);
992
-
993
- // Load with dependency resolution
994
- const results = await widgetLoader.loadAllPluginsWithFallback({
995
- resolveDependencies: true,
996
- });
997
-
998
- expect(results.successful).toContain('dep-a');
999
- expect(results.successful).toContain('dep-b');
1000
- expect(results.successful).toContain('dep-c');
1001
-
1002
- // Get dependency info
1003
- const depInfo = widgetLoader.getDependencyInfo('dep-c');
1004
- expect(depInfo.allDependencies).toContain('dep-a');
1005
- expect(depInfo.allDependencies).toContain('dep-b');
1006
- });
1007
-
1008
- test('should handle plugin hot-reload scenario', async () => {
1009
- // Initial load
1010
- const v1 = createMinimalWidget('hot-reload', { version: '1.0.0' });
1011
- const pluginDir = createTestPlugin('hot-reload', v1.manifest, v1.code);
1012
-
1013
- const id = await widgetLoader.loadPlugin(pluginDir);
1014
- expect(id).toBe('hot-reload');
1015
-
1016
- // Widget should be registered
1017
- expect(widgetLoader.widgetRegistry.has('hot-reload')).toBe(true);
1018
-
1019
- // Try to get the loaded instance (may be null if loading failed)
1020
- const instance = await widgetLoader.load('hot-reload').catch(() => null);
1021
- if (instance) {
1022
- expect(widgetLoader.isLoaded('hot-reload')).toBe(true);
1023
- }
1024
-
1025
- // Simulate update (in real scenario, file watcher would trigger this)
1026
- const v2 = createMinimalWidget('hot-reload', { version: '2.0.0' });
1027
- writeFileSync(join(pluginDir, 'plugin.json'), JSON.stringify(v2.manifest, null, 2));
1028
-
1029
- // Re-discover should see the update
1030
- const discovered = await widgetLoader.discoverPlugins();
1031
- const found = discovered.find(p => p.id === 'hot-reload');
1032
- expect(found).toBeDefined();
1033
- expect(found.manifest.version).toBe('2.0.0');
1034
- });
1035
- });
1036
- });