claw-dashboard 1.8.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/.c8rc.json +38 -0
  2. package/.dockerignore +68 -0
  3. package/.github/dependabot.yml +45 -0
  4. package/.github/pull_request_template.md +39 -0
  5. package/.github/workflows/ci.yml +147 -0
  6. package/.github/workflows/release.yml +40 -0
  7. package/.github/workflows/security.yml +84 -0
  8. package/.husky/pre-commit +1 -0
  9. package/.planning/codebase/ARCHITECTURE.md +206 -0
  10. package/.planning/codebase/INTEGRATIONS.md +150 -0
  11. package/.planning/codebase/STACK.md +122 -0
  12. package/.planning/codebase/STRUCTURE.md +201 -0
  13. package/CHANGELOG.md +293 -0
  14. package/CONTRIBUTING.md +378 -0
  15. package/Dockerfile +54 -0
  16. package/FEATURES.md +195 -21
  17. package/README.md +83 -6
  18. package/TODO.md +28 -0
  19. package/build-cjs.js +127 -0
  20. package/cjs-shim.js +13 -0
  21. package/dist/clawdash +1729 -0
  22. package/dist/clawdash.meta.json +2236 -0
  23. package/dist/widgets.cjs +6511 -0
  24. package/docker-compose.yml +67 -0
  25. package/docs/API.md +1050 -0
  26. package/docs/PLUGINS.md +1504 -0
  27. package/esbuild.config.js +158 -0
  28. package/eslint.config.js +56 -0
  29. package/examples/plugins/README.md +122 -0
  30. package/examples/plugins/api-status/index.js +294 -0
  31. package/examples/plugins/api-status/plugin.json +19 -0
  32. package/examples/plugins/hello-world/index.js +104 -0
  33. package/examples/plugins/hello-world/plugin.json +15 -0
  34. package/examples/plugins/system-metrics-chart/index.js +339 -0
  35. package/examples/plugins/system-metrics-chart/plugin.json +18 -0
  36. package/examples/plugins/weather-widget/index.js +136 -0
  37. package/examples/plugins/weather-widget/plugin.json +19 -0
  38. package/index.cjs +23005 -0
  39. package/index.js +5331 -512
  40. package/jest.config.js +11 -0
  41. package/man/clawdash.1 +276 -0
  42. package/package.json +54 -5
  43. package/schemas/plugin-manifest.json +128 -0
  44. package/scripts/release.js +595 -0
  45. package/src/alerts.js +693 -0
  46. package/src/auto-save.js +584 -0
  47. package/src/cache.js +390 -0
  48. package/src/checksum.js +146 -0
  49. package/src/cli/args.js +110 -0
  50. package/src/cli/export-schedule.js +423 -0
  51. package/src/cli/export-snapshot.js +138 -0
  52. package/src/cli/help.js +69 -0
  53. package/src/cli/import-snapshot.js +230 -0
  54. package/src/cli/index.js +14 -0
  55. package/src/cli/list-templates.js +69 -0
  56. package/src/cli/validate-config.js +76 -0
  57. package/src/cli/validate-plugin.js +187 -0
  58. package/src/cli/version.js +15 -0
  59. package/src/config-validator.js +586 -0
  60. package/src/config-watcher.js +401 -0
  61. package/src/config.js +684 -0
  62. package/src/container-detector.js +499 -0
  63. package/src/database.js +734 -0
  64. package/src/differential-render.js +327 -0
  65. package/src/errors.js +169 -0
  66. package/src/export-scheduler.js +581 -0
  67. package/src/gateway-manager.js +685 -0
  68. package/src/hints.js +371 -0
  69. package/src/loading-states.js +509 -0
  70. package/src/logger.js +185 -0
  71. package/src/memory-pressure.js +467 -0
  72. package/src/performance-monitor.js +374 -0
  73. package/src/plugin-errors.js +586 -0
  74. package/src/plugin-manifest-validator.js +219 -0
  75. package/src/plugin-reload.js +481 -0
  76. package/src/plugin-scaffold.js +1941 -0
  77. package/src/plugin-validator.js +284 -0
  78. package/src/retry.js +218 -0
  79. package/src/security.js +860 -0
  80. package/src/snapshot.js +372 -0
  81. package/src/splash.js +115 -0
  82. package/src/theme-selector.js +411 -0
  83. package/src/themes.js +874 -0
  84. package/src/transitions.js +534 -0
  85. package/src/types.d.ts +174 -0
  86. package/src/validation.js +926 -0
  87. package/src/web-server.js +885 -0
  88. package/src/widgets/builtin-widgets.js +1057 -0
  89. package/src/widgets/config-processor.js +401 -0
  90. package/src/widgets/dependency-resolver.js +596 -0
  91. package/src/widgets/index.js +79 -0
  92. package/src/widgets/plugin-api.js +845 -0
  93. package/src/widgets/widget-error-boundary.js +773 -0
  94. package/src/widgets/widget-error-isolation.js +551 -0
  95. package/src/widgets/widget-loader.js +1437 -0
  96. package/src/workers/system-worker.js +130 -0
  97. package/src/workers/worker-pool.js +633 -0
  98. package/tests/alerts.test.js +437 -0
  99. package/tests/auto-save.test.js +529 -0
  100. package/tests/cache.test.js +317 -0
  101. package/tests/cli.test.js +351 -0
  102. package/tests/command-palette.test.js +320 -0
  103. package/tests/config-processor.test.js +452 -0
  104. package/tests/config-validator.test.js +710 -0
  105. package/tests/config-watcher.test.js +594 -0
  106. package/tests/config.test.js +755 -0
  107. package/tests/database.test.js +438 -0
  108. package/tests/errors.test.js +189 -0
  109. package/tests/example-plugins.test.js +624 -0
  110. package/tests/integration.test.js +1321 -0
  111. package/tests/loading-states.test.js +300 -0
  112. package/tests/manifest-validation-on-load.test.js +671 -0
  113. package/tests/performance-monitor.test.js +190 -0
  114. package/tests/plugin-api-rate-limit.test.js +302 -0
  115. package/tests/plugin-errors.test.js +311 -0
  116. package/tests/plugin-lifecycle-e2e.test.js +1036 -0
  117. package/tests/plugin-reload.test.js +764 -0
  118. package/tests/rate-limiter.test.js +539 -0
  119. package/tests/retry.test.js +308 -0
  120. package/tests/security.test.js +411 -0
  121. package/tests/settings-modal.test.js +226 -0
  122. package/tests/theme-selector.test.js +57 -0
  123. package/tests/utils.js +242 -0
  124. package/tests/utils.test.js +317 -0
  125. package/tests/validate-plugin-cli.test.js +359 -0
  126. package/tests/validation.test.js +837 -0
  127. package/tests/web-server.test.js +646 -0
  128. package/tests/widget-arrange-mode.test.js +183 -0
  129. package/tests/widget-config-hot-reload.test.js +465 -0
  130. package/tests/widget-dependency.test.js +591 -0
  131. package/tests/widget-error-boundary.test.js +749 -0
  132. package/tests/widget-error-isolation.test.js +469 -0
  133. package/tests/widget-integration.test.js +1147 -0
  134. package/tests/widget-refresh-intervals.test.js +284 -0
  135. package/tests/worker-pool.test.js +522 -0
@@ -0,0 +1,764 @@
1
+ /**
2
+ * Tests for plugin-reload module
3
+ * Hot-reload functionality for plugin development
4
+ */
5
+
6
+ import { jest } from '@jest/globals';
7
+ import {
8
+ PluginReloadManager,
9
+ createPluginReloadManager,
10
+ getPluginReloadManager,
11
+ } from '../src/plugin-reload.js';
12
+
13
+ import { WidgetLoader } from '../src/widgets/widget-loader.js';
14
+ import { ConfigWatcher } from '../src/config-watcher.js';
15
+
16
+ import { writeFileSync, mkdirSync, unlinkSync, rmdirSync, existsSync, readdirSync, readFileSync } from 'fs';
17
+ import { join } from 'path';
18
+ import { mkdtempSync } from 'fs';
19
+ import { tmpdir } from 'os';
20
+
21
+ // Mock logger to avoid noise in tests
22
+ jest.mock('../src/logger.js', () => ({
23
+ __esModule: true,
24
+ default: {
25
+ info: jest.fn(),
26
+ warn: jest.fn(),
27
+ error: jest.fn(),
28
+ debug: jest.fn(),
29
+ },
30
+ }));
31
+
32
+ // Helper to create a test plugin
33
+ function createTestPlugin(pluginsDir, id, version = '1.0.0') {
34
+ const pluginDir = join(pluginsDir, id);
35
+ mkdirSync(pluginDir, { recursive: true });
36
+
37
+ const manifest = {
38
+ id,
39
+ name: `Test Plugin ${id}`,
40
+ version,
41
+ type: 'widget',
42
+ description: 'Test plugin for hot-reload',
43
+ author: 'Test',
44
+ };
45
+
46
+ writeFileSync(join(pluginDir, 'plugin.json'), JSON.stringify(manifest, null, 2));
47
+
48
+ const code = `
49
+ export default class TestWidget {
50
+ constructor(config) {
51
+ this.config = config;
52
+ this.version = '${version}';
53
+ }
54
+ render() { return 'render-${version}'; }
55
+ getData() { return { version: '${version}' }; }
56
+ destroy() {}
57
+ }
58
+ `;
59
+
60
+ writeFileSync(join(pluginDir, 'index.js'), code);
61
+
62
+ return { pluginDir, manifest };
63
+ }
64
+
65
+ describe('plugin-reload', () => {
66
+ let testDir;
67
+ let pluginsDir;
68
+ let widgetLoader;
69
+
70
+ beforeEach(() => {
71
+ // Create temporary directories
72
+ testDir = mkdtempSync(join(tmpdir(), 'plugin-reload-test-'));
73
+ pluginsDir = join(testDir, 'plugins');
74
+ mkdirSync(pluginsDir, { recursive: true });
75
+
76
+ // Create widget loader
77
+ widgetLoader = new WidgetLoader({
78
+ pluginsDir,
79
+ });
80
+ });
81
+
82
+ afterEach(async () => {
83
+ // Clean up
84
+ try {
85
+ if (existsSync(testDir)) {
86
+ const rmDir = (dir) => {
87
+ const files = readdirSync(dir, { withFileTypes: true });
88
+ for (const file of files) {
89
+ const fullPath = join(dir, file.name);
90
+ if (file.isDirectory()) {
91
+ rmDir(fullPath);
92
+ } else {
93
+ try { unlinkSync(fullPath); } catch (e) {}
94
+ }
95
+ }
96
+ try { rmdirSync(dir); } catch (e) {}
97
+ };
98
+ rmDir(testDir);
99
+ }
100
+ } catch (e) {
101
+ // Ignore cleanup errors
102
+ }
103
+ });
104
+
105
+ describe('PluginReloadManager constructor', () => {
106
+ test('should create instance with default options', () => {
107
+ const manager = new PluginReloadManager();
108
+
109
+ expect(manager.options.debounceMs).toBe(300);
110
+ expect(manager.options.autoReload).toBe(true);
111
+ expect(manager.options.showNotifications).toBe(true);
112
+ expect(manager.isRunning).toBe(false);
113
+ expect(manager.watchedPlugins.size).toBe(0);
114
+ expect(manager.widgetLoader).toBeNull();
115
+ });
116
+
117
+ test('should merge custom options with defaults', () => {
118
+ const manager = new PluginReloadManager({
119
+ debounceMs: 500,
120
+ autoReload: false,
121
+ customOption: 'test',
122
+ });
123
+
124
+ expect(manager.options.debounceMs).toBe(500);
125
+ expect(manager.options.autoReload).toBe(false);
126
+ expect(manager.options.showNotifications).toBe(true); // default preserved
127
+ expect(manager.options.customOption).toBe('test');
128
+ });
129
+
130
+ test('should accept widget loader in constructor', () => {
131
+ const manager = new PluginReloadManager({ widgetLoader });
132
+ expect(manager.widgetLoader).toBe(widgetLoader);
133
+ });
134
+ });
135
+
136
+ describe('setWidgetLoader', () => {
137
+ test('should set widget loader', () => {
138
+ const manager = new PluginReloadManager();
139
+ expect(manager.widgetLoader).toBeNull();
140
+
141
+ manager.setWidgetLoader(widgetLoader);
142
+ expect(manager.widgetLoader).toBe(widgetLoader);
143
+ });
144
+ });
145
+
146
+ describe('start', () => {
147
+ test('should fail if no widget loader is set', () => {
148
+ const manager = new PluginReloadManager();
149
+ const result = manager.start();
150
+
151
+ expect(result).toBe(false);
152
+ expect(manager.isRunning).toBe(false);
153
+ });
154
+
155
+ test('should start watching plugins directory', () => {
156
+ // Create a test plugin
157
+ createTestPlugin(pluginsDir, 'test-plugin');
158
+
159
+ const manager = new PluginReloadManager({
160
+ widgetLoader,
161
+ pluginsDir,
162
+ });
163
+
164
+ const result = manager.start();
165
+
166
+ expect(result).toBe(true);
167
+ expect(manager.isRunning).toBe(true);
168
+ expect(manager.watcher).toBeInstanceOf(ConfigWatcher);
169
+ expect(manager.watchedPlugins.has('test-plugin')).toBe(true);
170
+
171
+ manager.stop();
172
+ });
173
+
174
+ test('should return true if already running', () => {
175
+ createTestPlugin(pluginsDir, 'test-plugin');
176
+
177
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
178
+ manager.start();
179
+
180
+ const result = manager.start();
181
+
182
+ expect(result).toBe(true);
183
+ expect(manager.isRunning).toBe(true);
184
+
185
+ manager.stop();
186
+ });
187
+
188
+ test('should handle missing plugins directory gracefully', () => {
189
+ const nonExistentDir = join(testDir, 'non-existent', 'plugins');
190
+
191
+ const manager = new PluginReloadManager({
192
+ widgetLoader,
193
+ pluginsDir: nonExistentDir,
194
+ });
195
+
196
+ const result = manager.start();
197
+
198
+ expect(result).toBe(true); // Still starts, just logs warning
199
+ expect(manager.isRunning).toBe(true);
200
+
201
+ manager.stop();
202
+ });
203
+ });
204
+
205
+ describe('stop', () => {
206
+ test('should stop watching and clean up', () => {
207
+ createTestPlugin(pluginsDir, 'test-plugin');
208
+
209
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
210
+ manager.start();
211
+
212
+ expect(manager.isRunning).toBe(true);
213
+ expect(manager.watchedPlugins.size).toBeGreaterThan(0);
214
+
215
+ manager.stop();
216
+
217
+ expect(manager.isRunning).toBe(false);
218
+ expect(manager.watchedPlugins.size).toBe(0);
219
+ expect(manager.watcher).toBeNull();
220
+ });
221
+
222
+ test('should handle stop when not running', () => {
223
+ const manager = new PluginReloadManager();
224
+
225
+ expect(() => manager.stop()).not.toThrow();
226
+ expect(manager.isRunning).toBe(false);
227
+ });
228
+ });
229
+
230
+ describe('addHook', () => {
231
+ test('should add hooks for valid types', () => {
232
+ const manager = new PluginReloadManager();
233
+ const handler = jest.fn();
234
+
235
+ manager.addHook('beforeReload', handler);
236
+ manager.addHook('afterReload', handler);
237
+ manager.addHook('onError', handler);
238
+
239
+ expect(manager.hooks.beforeReload).toContain(handler);
240
+ expect(manager.hooks.afterReload).toContain(handler);
241
+ expect(manager.hooks.onError).toContain(handler);
242
+ });
243
+
244
+ test('should throw for unknown hook types', () => {
245
+ const manager = new PluginReloadManager();
246
+
247
+ expect(() => manager.addHook('unknownHook', jest.fn())).toThrow('Unknown hook type');
248
+ });
249
+ });
250
+
251
+ describe('reloadPlugin', () => {
252
+ test('should call widgetLoader.unregister for existing plugin', async () => {
253
+ createTestPlugin(pluginsDir, 'test-plugin', '1.0.0');
254
+
255
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
256
+ manager.start();
257
+
258
+ // Mock the widgetLoader methods
259
+ const unregisterSpy = jest.spyOn(widgetLoader, 'unregister').mockResolvedValue(true);
260
+ const loadPluginSpy = jest.spyOn(widgetLoader, 'loadPlugin').mockResolvedValue('test-plugin');
261
+ jest.spyOn(widgetLoader, 'isLoaded').mockReturnValue(true);
262
+ jest.spyOn(widgetLoader.widgetRegistry, 'has').mockReturnValue(true);
263
+
264
+ const { pluginDir } = createTestPlugin(pluginsDir, 'test-plugin');
265
+
266
+ const result = await manager.reloadPlugin(
267
+ 'test-plugin',
268
+ pluginDir,
269
+ join(pluginDir, 'plugin.json'),
270
+ join(pluginDir, 'index.js')
271
+ );
272
+
273
+ expect(result.success).toBe(true);
274
+ expect(unregisterSpy).toHaveBeenCalledWith('test-plugin');
275
+ expect(loadPluginSpy).toHaveBeenCalled();
276
+
277
+ manager.stop();
278
+ });
279
+
280
+ test('should load new plugin when not previously registered', async () => {
281
+ createTestPlugin(pluginsDir, 'new-plugin');
282
+
283
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
284
+ manager.start();
285
+
286
+ // Mock widgetLoader to simulate new plugin
287
+ jest.spyOn(widgetLoader, 'isLoaded').mockReturnValue(false);
288
+ jest.spyOn(widgetLoader.widgetRegistry, 'has').mockReturnValue(false);
289
+ const loadPluginSpy = jest.spyOn(widgetLoader, 'loadPlugin').mockResolvedValue('new-plugin');
290
+
291
+ const pluginDir = join(pluginsDir, 'new-plugin');
292
+
293
+ const result = await manager.reloadPlugin(
294
+ 'new-plugin',
295
+ pluginDir,
296
+ join(pluginDir, 'plugin.json'),
297
+ join(pluginDir, 'index.js')
298
+ );
299
+
300
+ expect(result.success).toBe(true);
301
+ expect(result.isNew).toBe(true);
302
+ expect(loadPluginSpy).toHaveBeenCalled();
303
+
304
+ manager.stop();
305
+ });
306
+
307
+ test('should handle errors from widgetLoader.loadPlugin', async () => {
308
+ createTestPlugin(pluginsDir, 'error-plugin');
309
+
310
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
311
+ manager.start();
312
+
313
+ // Mock widgetLoader to throw error
314
+ jest.spyOn(widgetLoader.widgetRegistry, 'has').mockReturnValue(false);
315
+ jest.spyOn(widgetLoader, 'loadPlugin').mockRejectedValue(new Error('Failed to load plugin'));
316
+
317
+ const pluginDir = join(pluginsDir, 'error-plugin');
318
+
319
+ const result = await manager.reloadPlugin(
320
+ 'error-plugin',
321
+ pluginDir,
322
+ join(pluginDir, 'plugin.json'),
323
+ join(pluginDir, 'index.js')
324
+ );
325
+
326
+ expect(result.success).toBe(false);
327
+ expect(result.error).toBe('Failed to load plugin');
328
+
329
+ manager.stop();
330
+ });
331
+
332
+ test('should call beforeReload and afterReload hooks', async () => {
333
+ createTestPlugin(pluginsDir, 'test-plugin');
334
+
335
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
336
+ const beforeHook = jest.fn();
337
+ const afterHook = jest.fn();
338
+
339
+ manager.addHook('beforeReload', beforeHook);
340
+ manager.addHook('afterReload', afterHook);
341
+ manager.start();
342
+
343
+ // Mock widgetLoader
344
+ jest.spyOn(widgetLoader.widgetRegistry, 'has').mockReturnValue(false);
345
+ jest.spyOn(widgetLoader, 'loadPlugin').mockResolvedValue('test-plugin');
346
+
347
+ const pluginDir = join(pluginsDir, 'test-plugin');
348
+
349
+ await manager.reloadPlugin(
350
+ 'test-plugin',
351
+ pluginDir,
352
+ join(pluginDir, 'plugin.json'),
353
+ join(pluginDir, 'index.js')
354
+ );
355
+
356
+ expect(beforeHook).toHaveBeenCalled();
357
+ expect(afterHook).toHaveBeenCalled();
358
+ expect(beforeHook.mock.calls[0][0].id).toBe('test-plugin');
359
+ expect(afterHook.mock.calls[0][0].id).toBe('test-plugin');
360
+ expect(afterHook.mock.calls[0][0].loadTime).toBeDefined();
361
+
362
+ manager.stop();
363
+ });
364
+
365
+ test('should call onError hook when reload fails', async () => {
366
+ createTestPlugin(pluginsDir, 'bad-plugin');
367
+
368
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
369
+ const errorHook = jest.fn();
370
+
371
+ manager.addHook('onError', errorHook);
372
+ manager.start();
373
+
374
+ // Mock widgetLoader to throw
375
+ jest.spyOn(widgetLoader.widgetRegistry, 'has').mockReturnValue(false);
376
+ jest.spyOn(widgetLoader, 'loadPlugin').mockRejectedValue(new Error('Syntax error'));
377
+
378
+ const pluginDir = join(pluginsDir, 'bad-plugin');
379
+
380
+ await manager.reloadPlugin(
381
+ 'bad-plugin',
382
+ pluginDir,
383
+ join(pluginDir, 'plugin.json'),
384
+ join(pluginDir, 'index.js')
385
+ );
386
+
387
+ expect(errorHook).toHaveBeenCalled();
388
+ expect(errorHook.mock.calls[0][0].error).toBeDefined();
389
+ expect(errorHook.mock.calls[0][0].type).toBe('reload');
390
+
391
+ manager.stop();
392
+ });
393
+ });
394
+
395
+ describe('reload', () => {
396
+ test('should reload a watched plugin by id', async () => {
397
+ createTestPlugin(pluginsDir, 'test-plugin');
398
+
399
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
400
+ manager.start();
401
+
402
+ // Mock the reloadPlugin method
403
+ const reloadSpy = jest.spyOn(manager, 'reloadPlugin').mockResolvedValue({
404
+ success: true,
405
+ id: 'test-plugin',
406
+ });
407
+
408
+ const result = await manager.reload('test-plugin');
409
+
410
+ expect(result.success).toBe(true);
411
+ expect(result.id).toBe('test-plugin');
412
+ expect(reloadSpy).toHaveBeenCalled();
413
+
414
+ manager.stop();
415
+ });
416
+
417
+ test('should throw if plugin is not being watched', async () => {
418
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
419
+
420
+ await expect(manager.reload('non-existent')).rejects.toThrow('not being watched');
421
+ });
422
+ });
423
+
424
+ describe('addPlugin', () => {
425
+ test('should add a new plugin to watch list', async () => {
426
+ // Create manager first
427
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
428
+ manager.start();
429
+
430
+ // Create plugin after manager started
431
+ const { pluginDir } = createTestPlugin(pluginsDir, 'added-plugin');
432
+
433
+ expect(manager.isWatching('added-plugin')).toBe(false);
434
+
435
+ await manager.addPlugin(pluginDir);
436
+
437
+ expect(manager.isWatching('added-plugin')).toBe(true);
438
+
439
+ manager.stop();
440
+ });
441
+
442
+ test('should throw if plugin manifest not found', async () => {
443
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
444
+ const fakeDir = join(pluginsDir, 'fake-plugin');
445
+ mkdirSync(fakeDir);
446
+
447
+ await expect(manager.addPlugin(fakeDir)).rejects.toThrow('manifest not found');
448
+ });
449
+ });
450
+
451
+ describe('removePlugin', () => {
452
+ test('should remove a plugin from watch list', () => {
453
+ createTestPlugin(pluginsDir, 'test-plugin');
454
+
455
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
456
+ manager.start();
457
+
458
+ expect(manager.isWatching('test-plugin')).toBe(true);
459
+
460
+ const result = manager.removePlugin('test-plugin');
461
+
462
+ expect(result).toBe(true);
463
+ expect(manager.isWatching('test-plugin')).toBe(false);
464
+
465
+ manager.stop();
466
+ });
467
+
468
+ test('should return false if plugin not being watched', () => {
469
+ const manager = new PluginReloadManager();
470
+
471
+ const result = manager.removePlugin('non-existent');
472
+
473
+ expect(result).toBe(false);
474
+ });
475
+ });
476
+
477
+ describe('getWatchedPlugins', () => {
478
+ test('should return list of watched plugin IDs', () => {
479
+ createTestPlugin(pluginsDir, 'plugin-a');
480
+ createTestPlugin(pluginsDir, 'plugin-b');
481
+
482
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
483
+ manager.start();
484
+
485
+ const plugins = manager.getWatchedPlugins();
486
+
487
+ expect(plugins).toContain('plugin-a');
488
+ expect(plugins).toContain('plugin-b');
489
+ expect(plugins).toHaveLength(2);
490
+
491
+ manager.stop();
492
+ });
493
+
494
+ test('should return empty array when no plugins watched', () => {
495
+ const manager = new PluginReloadManager();
496
+
497
+ const plugins = manager.getWatchedPlugins();
498
+
499
+ expect(plugins).toEqual([]);
500
+ });
501
+ });
502
+
503
+ describe('isWatching', () => {
504
+ test('should return true for watched plugin', () => {
505
+ createTestPlugin(pluginsDir, 'test-plugin');
506
+
507
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
508
+ manager.start();
509
+
510
+ expect(manager.isWatching('test-plugin')).toBe(true);
511
+
512
+ manager.stop();
513
+ });
514
+
515
+ test('should return false for unwatched plugin', () => {
516
+ createTestPlugin(pluginsDir, 'test-plugin');
517
+
518
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
519
+ manager.start();
520
+
521
+ expect(manager.isWatching('non-existent')).toBe(false);
522
+
523
+ manager.stop();
524
+ });
525
+ });
526
+
527
+ describe('getStats', () => {
528
+ test('should return stats object', () => {
529
+ createTestPlugin(pluginsDir, 'test-plugin');
530
+
531
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
532
+
533
+ const stats = manager.getStats();
534
+
535
+ expect(stats).toEqual({
536
+ isRunning: false,
537
+ watchedPlugins: 0,
538
+ watchedFiles: 0,
539
+ autoReload: true,
540
+ });
541
+ });
542
+
543
+ test('should reflect running state', () => {
544
+ createTestPlugin(pluginsDir, 'test-plugin');
545
+
546
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
547
+ manager.start();
548
+
549
+ const stats = manager.getStats();
550
+
551
+ expect(stats.isRunning).toBe(true);
552
+ expect(stats.watchedPlugins).toBe(1);
553
+ expect(stats.watchedFiles).toBeGreaterThan(0);
554
+
555
+ manager.stop();
556
+ });
557
+ });
558
+
559
+ describe('file change detection', () => {
560
+ test('should detect plugin.json changes and reload', async () => {
561
+ const { pluginDir } = createTestPlugin(pluginsDir, 'test-plugin', '1.0.0');
562
+
563
+ const manager = new PluginReloadManager({
564
+ widgetLoader,
565
+ pluginsDir,
566
+ debounceMs: 50,
567
+ });
568
+
569
+ const reloadSpy = jest.spyOn(manager, 'reloadPlugin').mockResolvedValue({
570
+ success: true,
571
+ id: 'test-plugin',
572
+ });
573
+
574
+ manager.start();
575
+
576
+ // Simulate file change by triggering reload handler
577
+ const manifestPath = join(pluginDir, 'plugin.json');
578
+ await manager._handleFileChange(manifestPath);
579
+
580
+ expect(reloadSpy).toHaveBeenCalled();
581
+
582
+ manager.stop();
583
+ });
584
+
585
+ test('should detect index.js changes and reload', async () => {
586
+ const { pluginDir } = createTestPlugin(pluginsDir, 'test-plugin', '1.0.0');
587
+
588
+ const manager = new PluginReloadManager({
589
+ widgetLoader,
590
+ pluginsDir,
591
+ debounceMs: 50,
592
+ });
593
+
594
+ const reloadSpy = jest.spyOn(manager, 'reloadPlugin').mockResolvedValue({
595
+ success: true,
596
+ id: 'test-plugin',
597
+ });
598
+
599
+ manager.start();
600
+
601
+ const indexPath = join(pluginDir, 'index.js');
602
+ await manager._handleFileChange(indexPath);
603
+
604
+ expect(reloadSpy).toHaveBeenCalled();
605
+
606
+ manager.stop();
607
+ });
608
+
609
+ test('should ignore changes to non-plugin files', async () => {
610
+ createTestPlugin(pluginsDir, 'test-plugin');
611
+
612
+ const manager = new PluginReloadManager({
613
+ widgetLoader,
614
+ pluginsDir,
615
+ debounceMs: 50,
616
+ });
617
+
618
+ const reloadSpy = jest.spyOn(manager, 'reloadPlugin').mockResolvedValue({
619
+ success: true,
620
+ id: 'test-plugin',
621
+ });
622
+
623
+ manager.start();
624
+
625
+ // Try to handle change for unknown file
626
+ await manager._handleFileChange('/random/path/file.js');
627
+
628
+ expect(reloadSpy).not.toHaveBeenCalled();
629
+
630
+ manager.stop();
631
+ });
632
+
633
+ test('should skip reload when autoReload is disabled', async () => {
634
+ const { pluginDir } = createTestPlugin(pluginsDir, 'test-plugin', '1.0.0');
635
+
636
+ const manager = new PluginReloadManager({
637
+ widgetLoader,
638
+ pluginsDir,
639
+ debounceMs: 50,
640
+ autoReload: false,
641
+ });
642
+
643
+ const reloadSpy = jest.spyOn(manager, 'reloadPlugin').mockResolvedValue({
644
+ success: true,
645
+ id: 'test-plugin',
646
+ });
647
+
648
+ manager.start();
649
+
650
+ const indexPath = join(pluginDir, 'index.js');
651
+ await manager._handleFileChange(indexPath);
652
+
653
+ expect(reloadSpy).not.toHaveBeenCalled();
654
+
655
+ manager.stop();
656
+ });
657
+ });
658
+
659
+ describe('createPluginReloadManager', () => {
660
+ test('should create a new manager instance', () => {
661
+ const manager = createPluginReloadManager({ debounceMs: 500 });
662
+
663
+ expect(manager).toBeInstanceOf(PluginReloadManager);
664
+ expect(manager.options.debounceMs).toBe(500);
665
+ });
666
+ });
667
+
668
+ describe('getPluginReloadManager', () => {
669
+ test('should return singleton instance', () => {
670
+ const manager1 = getPluginReloadManager();
671
+ const manager2 = getPluginReloadManager();
672
+
673
+ expect(manager1).toBe(manager2);
674
+ });
675
+
676
+ test('should create instance with options if none exists', () => {
677
+ // Note: This may interact with other tests
678
+ const manager = getPluginReloadManager({ debounceMs: 400 });
679
+
680
+ expect(manager).toBeInstanceOf(PluginReloadManager);
681
+ });
682
+ });
683
+
684
+ describe('_findPluginByPath', () => {
685
+ test('should find plugin by manifest path', () => {
686
+ createTestPlugin(pluginsDir, 'test-plugin');
687
+
688
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
689
+ manager.start();
690
+
691
+ const pluginDir = join(pluginsDir, 'test-plugin');
692
+ const manifestPath = join(pluginDir, 'plugin.json');
693
+
694
+ const result = manager._findPluginByPath(manifestPath);
695
+
696
+ expect(result).not.toBeNull();
697
+ expect(result.id).toBe('test-plugin');
698
+
699
+ manager.stop();
700
+ });
701
+
702
+ test('should find plugin by index.js path', () => {
703
+ createTestPlugin(pluginsDir, 'test-plugin');
704
+
705
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
706
+ manager.start();
707
+
708
+ const pluginDir = join(pluginsDir, 'test-plugin');
709
+ const indexPath = join(pluginDir, 'index.js');
710
+
711
+ const result = manager._findPluginByPath(indexPath);
712
+
713
+ expect(result).not.toBeNull();
714
+ expect(result.id).toBe('test-plugin');
715
+
716
+ manager.stop();
717
+ });
718
+
719
+ test('should return null for unknown path', () => {
720
+ createTestPlugin(pluginsDir, 'test-plugin');
721
+
722
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
723
+ manager.start();
724
+
725
+ const result = manager._findPluginByPath('/unknown/path/file.js');
726
+
727
+ expect(result).toBeNull();
728
+
729
+ manager.stop();
730
+ });
731
+ });
732
+
733
+ describe('_clearModuleCache', () => {
734
+ test('should not throw for valid path', () => {
735
+ const manager = new PluginReloadManager();
736
+
737
+ expect(() => {
738
+ manager._clearModuleCache('/some/path/file.js');
739
+ }).not.toThrow();
740
+ });
741
+ });
742
+
743
+ describe('_updateWatchedFiles', () => {
744
+ test('should update watched plugin info', async () => {
745
+ createTestPlugin(pluginsDir, 'test-plugin');
746
+
747
+ const manager = new PluginReloadManager({ widgetLoader, pluginsDir });
748
+ manager.start();
749
+
750
+ const pluginDir = join(pluginsDir, 'test-plugin');
751
+ const manifestPath = join(pluginDir, 'plugin.json');
752
+ const indexPath = join(pluginDir, 'index.js');
753
+
754
+ // Update with slightly different info
755
+ await manager._updateWatchedFiles('test-plugin', pluginDir, manifestPath, indexPath);
756
+
757
+ const info = manager.watchedPlugins.get('test-plugin');
758
+ expect(info).toBeDefined();
759
+ expect(info.pluginPath).toBe(pluginDir);
760
+
761
+ manager.stop();
762
+ });
763
+ });
764
+ });