claw-dashboard 1.9.0 → 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 +5236 -566
  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,183 @@
1
+ /**
2
+ * Tests for Widget Arrangement Mode
3
+ * Verifies the fix for moveWidget() logic that was moving the wrong widget
4
+ *
5
+ * The bug was caused by updating arrangeWidgetIndex BEFORE getting the widgetId,
6
+ * which resulted in getting the widget at the new position instead of the original.
7
+ * This meant the wrong widget would be moved in the array.
8
+ */
9
+
10
+ import { jest } from '@jest/globals';
11
+
12
+ describe('Widget Arrangement Mode', () => {
13
+ describe('moveWidget() logic', () => {
14
+ test('should move the originally selected widget, not the one at the new index', () => {
15
+ // Simulate the dashboard state with widget order
16
+ const state = {
17
+ widgetOrder: ['cpu', 'mem', 'gpu', 'disk'],
18
+ arrangeWidgetIndex: 2, // User has 'gpu' selected
19
+ };
20
+
21
+ // Simulate the FIXED moveWidget(-1) logic
22
+ const moveWidgetFixed = (direction) => {
23
+ const orderedWidgets = [...state.widgetOrder];
24
+ if (orderedWidgets.length <= 1) return;
25
+
26
+ // Get the widget currently selected (must be before any index changes)
27
+ const widgetId = orderedWidgets[state.arrangeWidgetIndex];
28
+ const oldIndex = state.arrangeWidgetIndex;
29
+
30
+ // Calculate target index with wrapping
31
+ let targetIndex = oldIndex + direction;
32
+ if (targetIndex < 0) targetIndex = orderedWidgets.length - 1;
33
+ if (targetIndex >= orderedWidgets.length) targetIndex = 0;
34
+
35
+ // Reorder the array - remove from old position and insert at target
36
+ const newOrder = [...orderedWidgets];
37
+ newOrder.splice(oldIndex, 1);
38
+ newOrder.splice(targetIndex, 0, widgetId);
39
+
40
+ state.widgetOrder = newOrder;
41
+ state.arrangeWidgetIndex = targetIndex;
42
+ };
43
+
44
+ // User presses 'left' to move 'gpu' to the left
45
+ moveWidgetFixed(-1);
46
+
47
+ // 'gpu' should have moved from index 2 to index 1
48
+ expect(state.widgetOrder).toEqual(['cpu', 'gpu', 'mem', 'disk']);
49
+ // The highlight should follow 'gpu' to its new position
50
+ expect(state.arrangeWidgetIndex).toBe(1);
51
+ });
52
+
53
+ test('should move the correct widget when moving right', () => {
54
+ const state = {
55
+ widgetOrder: ['cpu', 'mem', 'gpu', 'disk'],
56
+ arrangeWidgetIndex: 1, // User has 'mem' selected
57
+ };
58
+
59
+ const moveWidgetFixed = (direction) => {
60
+ const orderedWidgets = [...state.widgetOrder];
61
+ if (orderedWidgets.length <= 1) return;
62
+
63
+ const widgetId = orderedWidgets[state.arrangeWidgetIndex];
64
+ const oldIndex = state.arrangeWidgetIndex;
65
+
66
+ let targetIndex = oldIndex + direction;
67
+ if (targetIndex < 0) targetIndex = orderedWidgets.length - 1;
68
+ if (targetIndex >= orderedWidgets.length) targetIndex = 0;
69
+
70
+ const newOrder = [...orderedWidgets];
71
+ newOrder.splice(oldIndex, 1);
72
+ newOrder.splice(targetIndex, 0, widgetId);
73
+
74
+ state.widgetOrder = newOrder;
75
+ state.arrangeWidgetIndex = targetIndex;
76
+ };
77
+
78
+ // User presses 'right' to move 'mem' to the right
79
+ moveWidgetFixed(1);
80
+
81
+ // 'mem' should have moved from index 1 to index 2
82
+ expect(state.widgetOrder).toEqual(['cpu', 'gpu', 'mem', 'disk']);
83
+ expect(state.arrangeWidgetIndex).toBe(2);
84
+ });
85
+
86
+ test('should wrap correctly when moving left from first position', () => {
87
+ const state = {
88
+ widgetOrder: ['cpu', 'mem', 'gpu', 'disk'],
89
+ arrangeWidgetIndex: 0, // User has 'cpu' selected (first)
90
+ };
91
+
92
+ const moveWidgetFixed = (direction) => {
93
+ const orderedWidgets = [...state.widgetOrder];
94
+ if (orderedWidgets.length <= 1) return;
95
+
96
+ const widgetId = orderedWidgets[state.arrangeWidgetIndex];
97
+ const oldIndex = state.arrangeWidgetIndex;
98
+
99
+ let targetIndex = oldIndex + direction;
100
+ if (targetIndex < 0) targetIndex = orderedWidgets.length - 1;
101
+ if (targetIndex >= orderedWidgets.length) targetIndex = 0;
102
+
103
+ const newOrder = [...orderedWidgets];
104
+ newOrder.splice(oldIndex, 1);
105
+ newOrder.splice(targetIndex, 0, widgetId);
106
+
107
+ state.widgetOrder = newOrder;
108
+ state.arrangeWidgetIndex = targetIndex;
109
+ };
110
+
111
+ // User presses 'left' to move 'cpu' - should wrap to end
112
+ moveWidgetFixed(-1);
113
+
114
+ // 'cpu' should have moved from index 0 to index 3 (last)
115
+ expect(state.widgetOrder).toEqual(['mem', 'gpu', 'disk', 'cpu']);
116
+ expect(state.arrangeWidgetIndex).toBe(3);
117
+ });
118
+
119
+ test('should wrap correctly when moving right from last position', () => {
120
+ const state = {
121
+ widgetOrder: ['cpu', 'mem', 'gpu', 'disk'],
122
+ arrangeWidgetIndex: 3, // User has 'disk' selected (last)
123
+ };
124
+
125
+ const moveWidgetFixed = (direction) => {
126
+ const orderedWidgets = [...state.widgetOrder];
127
+ if (orderedWidgets.length <= 1) return;
128
+
129
+ const widgetId = orderedWidgets[state.arrangeWidgetIndex];
130
+ const oldIndex = state.arrangeWidgetIndex;
131
+
132
+ let targetIndex = oldIndex + direction;
133
+ if (targetIndex < 0) targetIndex = orderedWidgets.length - 1;
134
+ if (targetIndex >= orderedWidgets.length) targetIndex = 0;
135
+
136
+ const newOrder = [...orderedWidgets];
137
+ newOrder.splice(oldIndex, 1);
138
+ newOrder.splice(targetIndex, 0, widgetId);
139
+
140
+ state.widgetOrder = newOrder;
141
+ state.arrangeWidgetIndex = targetIndex;
142
+ };
143
+
144
+ // User presses 'right' to move 'disk' - should wrap to start
145
+ moveWidgetFixed(1);
146
+
147
+ // 'disk' should have moved from index 3 to index 0 (first)
148
+ expect(state.widgetOrder).toEqual(['disk', 'cpu', 'mem', 'gpu']);
149
+ expect(state.arrangeWidgetIndex).toBe(0);
150
+ });
151
+
152
+ test('should handle single widget array gracefully', () => {
153
+ const state = {
154
+ widgetOrder: ['cpu'],
155
+ arrangeWidgetIndex: 0,
156
+ };
157
+
158
+ const moveWidgetFixed = (direction) => {
159
+ const orderedWidgets = [...state.widgetOrder];
160
+ if (orderedWidgets.length <= 1) return; // Should return early
161
+
162
+ const widgetId = orderedWidgets[state.arrangeWidgetIndex];
163
+ const oldIndex = state.arrangeWidgetIndex;
164
+
165
+ let targetIndex = oldIndex + direction;
166
+ if (targetIndex < 0) targetIndex = orderedWidgets.length - 1;
167
+ if (targetIndex >= orderedWidgets.length) targetIndex = 0;
168
+
169
+ const newOrder = [...orderedWidgets];
170
+ newOrder.splice(oldIndex, 1);
171
+ newOrder.splice(targetIndex, 0, widgetId);
172
+
173
+ state.widgetOrder = newOrder;
174
+ state.arrangeWidgetIndex = targetIndex;
175
+ };
176
+
177
+ // Should not throw and should not modify array
178
+ moveWidgetFixed(-1);
179
+ expect(state.widgetOrder).toEqual(['cpu']);
180
+ expect(state.arrangeWidgetIndex).toBe(0);
181
+ });
182
+ });
183
+ });
@@ -0,0 +1,465 @@
1
+ /**
2
+ * Widget Config Hot-Reload Tests
3
+ * Tests for configuration hot-reload functionality in WidgetLoader
4
+ */
5
+
6
+ import { jest } from '@jest/globals';
7
+ import { join } from 'path';
8
+ import { mkdtemp, rm, mkdir, writeFile, readFile } from 'fs/promises';
9
+ import { tmpdir } from 'os';
10
+ import { setTimeout } from 'timers/promises';
11
+
12
+ import { WidgetLoader } from '../src/widgets/widget-loader.js';
13
+ import { processWidgetConfig, registerMigration } from '../src/widgets/config-processor.js';
14
+
15
+ describe('Widget Config Hot-Reload', () => {
16
+ let loader;
17
+ let tempDir;
18
+ let pluginDir;
19
+
20
+ beforeEach(async () => {
21
+ tempDir = await mkdtemp(join(tmpdir(), 'hot-reload-test-'));
22
+ pluginDir = join(tempDir, 'test-widget');
23
+ await mkdir(pluginDir, { recursive: true });
24
+
25
+ loader = new WidgetLoader({ pluginsDir: tempDir });
26
+ });
27
+
28
+ afterEach(async () => {
29
+ if (loader) {
30
+ loader.disableConfigHotReload();
31
+ await loader.clear();
32
+ }
33
+ if (tempDir) {
34
+ await rm(tempDir, { recursive: true, force: true });
35
+ }
36
+ });
37
+
38
+ describe('enableConfigHotReload()', () => {
39
+ test('should enable hot-reload and return watcher', async () => {
40
+ const watcher = loader.enableConfigHotReload();
41
+
42
+ expect(watcher).toBeDefined();
43
+ expect(loader.isConfigHotReloadEnabled()).toBe(true);
44
+ });
45
+
46
+ test('should return existing watcher if already enabled', async () => {
47
+ const watcher1 = loader.enableConfigHotReload();
48
+ const watcher2 = loader.enableConfigHotReload();
49
+
50
+ expect(watcher1).toBe(watcher2);
51
+ });
52
+
53
+ test('should initialize reload stats', async () => {
54
+ loader.enableConfigHotReload();
55
+ const stats = loader.getHotReloadStats();
56
+
57
+ expect(stats.enabled).toBe(true);
58
+ expect(stats.reloads).toBe(0);
59
+ expect(stats.errors).toBe(0);
60
+ expect(stats.lastReload).toBeNull();
61
+ });
62
+
63
+ test('should accept custom options', async () => {
64
+ const watcher = loader.enableConfigHotReload({
65
+ debounceMs: 1000,
66
+ usePolling: true,
67
+ });
68
+
69
+ expect(watcher).toBeDefined();
70
+ expect(loader.isConfigHotReloadEnabled()).toBe(true);
71
+ });
72
+ });
73
+
74
+ describe('disableConfigHotReload()', () => {
75
+ test('should disable hot-reload', async () => {
76
+ loader.enableConfigHotReload();
77
+ expect(loader.isConfigHotReloadEnabled()).toBe(true);
78
+
79
+ loader.disableConfigHotReload();
80
+ expect(loader.isConfigHotReloadEnabled()).toBe(false);
81
+ });
82
+
83
+ test('should handle disabling when not enabled', async () => {
84
+ expect(() => loader.disableConfigHotReload()).not.toThrow();
85
+ });
86
+ });
87
+
88
+ describe('getHotReloadStats()', () => {
89
+ test('should return stats when disabled', async () => {
90
+ const stats = loader.getHotReloadStats();
91
+
92
+ expect(stats.enabled).toBe(false);
93
+ expect(stats.reloads).toBe(0);
94
+ expect(stats.errors).toBe(0);
95
+ });
96
+
97
+ test('should track watched files count', async () => {
98
+ // Create a plugin
99
+ await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({
100
+ id: 'test-widget',
101
+ name: 'Test Widget',
102
+ version: '1.0.0',
103
+ type: 'widget',
104
+ }));
105
+ await writeFile(join(pluginDir, 'index.js'), 'export default { render: () => {}, getData: async () => ({}) };');
106
+ await writeFile(join(pluginDir, 'package.json'), JSON.stringify({ type: 'module' }));
107
+
108
+ await loader.loadPlugin(pluginDir);
109
+
110
+ loader.enableConfigHotReload();
111
+ const stats = loader.getHotReloadStats();
112
+
113
+ expect(stats.watchedFiles).toBeGreaterThanOrEqual(0);
114
+ });
115
+ });
116
+
117
+ describe('watchWidgetConfig()', () => {
118
+ test('should warn if hot-reload not enabled', async () => {
119
+ // Create a plugin
120
+ await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({
121
+ id: 'test-widget',
122
+ name: 'Test Widget',
123
+ version: '1.0.0',
124
+ type: 'widget',
125
+ }));
126
+ await writeFile(join(pluginDir, 'index.js'), 'export default { render: () => {}, getData: async () => ({}) };');
127
+ await writeFile(join(pluginDir, 'package.json'), JSON.stringify({ type: 'module' }));
128
+
129
+ await loader.loadPlugin(pluginDir);
130
+
131
+ const result = loader.watchWidgetConfig('test-widget');
132
+ expect(result).toBe(false);
133
+ });
134
+
135
+ test('should warn if widget not found', async () => {
136
+ loader.enableConfigHotReload();
137
+
138
+ const result = loader.watchWidgetConfig('nonexistent');
139
+ expect(result).toBe(false);
140
+ });
141
+ });
142
+
143
+ describe('Plugin path tracking', () => {
144
+ test('should store plugin path in metadata on load', async () => {
145
+ await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({
146
+ id: 'test-widget',
147
+ name: 'Test Widget',
148
+ version: '1.0.0',
149
+ type: 'widget',
150
+ }));
151
+ await writeFile(join(pluginDir, 'index.js'), 'export default { render: () => {}, getData: async () => ({}) };');
152
+ await writeFile(join(pluginDir, 'package.json'), JSON.stringify({ type: 'module' }));
153
+
154
+ await loader.loadPlugin(pluginDir);
155
+
156
+ const metadata = loader.getMetadata('test-widget');
157
+ expect(metadata._pluginPath).toBe(pluginDir);
158
+ expect(metadata._manifestPath).toBe(join(pluginDir, 'plugin.json'));
159
+ expect(metadata._indexPath).toBe(join(pluginDir, 'index.js'));
160
+ });
161
+
162
+ test('should store plugin path on register', async () => {
163
+ await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({
164
+ id: 'test-widget',
165
+ name: 'Test Widget',
166
+ version: '1.0.0',
167
+ type: 'widget',
168
+ }));
169
+ await writeFile(join(pluginDir, 'index.js'), 'export default { render: () => {}, getData: async () => ({}) };');
170
+ await writeFile(join(pluginDir, 'package.json'), JSON.stringify({ type: 'module' }));
171
+
172
+ await loader.registerPlugin(pluginDir);
173
+
174
+ const metadata = loader.getMetadata('test-widget');
175
+ expect(metadata._pluginPath).toBe(pluginDir);
176
+ });
177
+ });
178
+
179
+ describe('Config file hot-reload', () => {
180
+ test('should emit configReloaded event on successful reload', async () => {
181
+ // Create initial plugin config
182
+ await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({
183
+ id: 'test-widget',
184
+ name: 'Test Widget',
185
+ version: '1.0.0',
186
+ type: 'widget',
187
+ config: {
188
+ message: 'Hello World',
189
+ count: 5,
190
+ },
191
+ }));
192
+ await writeFile(join(pluginDir, 'index.js'), `
193
+ export default class TestWidget {
194
+ constructor(config) {
195
+ this.config = config;
196
+ }
197
+ render() {}
198
+ async getData() { return this.config; }
199
+ }
200
+ `);
201
+ await writeFile(join(pluginDir, 'package.json'), JSON.stringify({ type: 'module' }));
202
+
203
+ await loader.loadPlugin(pluginDir);
204
+
205
+ // Set up event listener
206
+ const reloadPromise = new Promise((resolve) => {
207
+ loader.once('configReloaded', resolve);
208
+ });
209
+
210
+ // Enable hot-reload with low debounce for testing
211
+ loader.enableConfigHotReload({ debounceMs: 100 });
212
+
213
+ // Wait a bit then modify the config file
214
+ await setTimeout(50);
215
+ await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({
216
+ id: 'test-widget',
217
+ name: 'Test Widget',
218
+ version: '1.0.0',
219
+ type: 'widget',
220
+ config: {
221
+ message: 'Updated Message',
222
+ count: 10,
223
+ },
224
+ }));
225
+
226
+ // Wait for reload event
227
+ const event = await reloadPromise;
228
+
229
+ expect(event.widgetId).toBe('test-widget');
230
+ expect(event.timestamp).toBeDefined();
231
+ expect(event.config).toBeDefined();
232
+ }, 10000);
233
+
234
+ test('should process environment variable interpolation on reload', async () => {
235
+ process.env.HOT_RELOAD_TEST_VAR = 'interpolated_value';
236
+
237
+ await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({
238
+ id: 'test-widget',
239
+ name: 'Test Widget',
240
+ version: '1.0.0',
241
+ type: 'widget',
242
+ config: {
243
+ value: '${HOT_RELOAD_TEST_VAR}',
244
+ },
245
+ }));
246
+ await writeFile(join(pluginDir, 'index.js'), `
247
+ export default class TestWidget {
248
+ constructor(config) {
249
+ this.config = config;
250
+ }
251
+ render() {}
252
+ async getData() { return this.config; }
253
+ }
254
+ `);
255
+ await writeFile(join(pluginDir, 'package.json'), JSON.stringify({ type: 'module' }));
256
+
257
+ await loader.loadPlugin(pluginDir);
258
+
259
+ // Check initial config processing
260
+ const instance = loader.get('test-widget');
261
+ expect(instance.config.value).toBe('interpolated_value');
262
+ });
263
+
264
+ test('should handle config migration on reload', async () => {
265
+ // Test that config version is properly handled during reload
266
+ // The widget loader processes config through processWidgetConfig
267
+ // which handles versioning
268
+
269
+ await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({
270
+ id: 'test-widget',
271
+ name: 'Test Widget',
272
+ version: '1.0.0',
273
+ type: 'widget',
274
+ config: {
275
+ __version: '1.0.0',
276
+ oldField: 'value',
277
+ },
278
+ }));
279
+ await writeFile(join(pluginDir, 'index.js'), `
280
+ export default class TestWidget {
281
+ constructor(config) {
282
+ this.config = config;
283
+ }
284
+ render() {}
285
+ async getData() { return this.config; }
286
+ }
287
+ `);
288
+ await writeFile(join(pluginDir, 'package.json'), JSON.stringify({ type: 'module' }));
289
+
290
+ await loader.loadPlugin(pluginDir);
291
+
292
+ const instance = loader.get('test-widget');
293
+ // Config should have been processed and passed to the widget
294
+ expect(instance.config.oldField).toBe('value');
295
+ // The __version should be present
296
+ expect(instance.config.__version).toBe('1.0.0');
297
+ });
298
+ });
299
+
300
+ describe('Config reload errors', () => {
301
+ test('should emit configReloadError on invalid JSON', async () => {
302
+ await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({
303
+ id: 'test-widget',
304
+ name: 'Test Widget',
305
+ version: '1.0.0',
306
+ type: 'widget',
307
+ config: {},
308
+ }));
309
+ await writeFile(join(pluginDir, 'index.js'), 'export default { render: () => {}, getData: async () => ({}) };');
310
+ await writeFile(join(pluginDir, 'package.json'), JSON.stringify({ type: 'module' }));
311
+
312
+ await loader.loadPlugin(pluginDir);
313
+
314
+ // Set up event listener for error
315
+ const errorPromise = new Promise((resolve) => {
316
+ loader.once('configReloadError', resolve);
317
+ });
318
+
319
+ loader.enableConfigHotReload({ debounceMs: 100 });
320
+
321
+ // Corrupt the JSON
322
+ await setTimeout(50);
323
+ await writeFile(join(pluginDir, 'plugin.json'), '{ invalid json }');
324
+
325
+ const event = await errorPromise;
326
+ expect(event.error).toBeDefined();
327
+ expect(event.timestamp).toBeDefined();
328
+
329
+ // Stats should track the error
330
+ const stats = loader.getHotReloadStats();
331
+ expect(stats.errors).toBeGreaterThan(0);
332
+ }, 10000);
333
+
334
+ test('should handle missing widget gracefully', async () => {
335
+ loader.enableConfigHotReload({ debounceMs: 100 });
336
+
337
+ // Manually trigger a reload for a non-existent widget path
338
+ const result = await loader._reloadWidgetConfig('nonexistent', join(tempDir, 'plugin.json'));
339
+
340
+ expect(result.success).toBe(false);
341
+ expect(result.error).toContain('not found');
342
+ });
343
+ });
344
+
345
+ describe('onConfigChange callback', () => {
346
+ test('should call onConfigChange when widget supports it', async () => {
347
+ const onConfigChange = jest.fn();
348
+
349
+ await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({
350
+ id: 'test-widget',
351
+ name: 'Test Widget',
352
+ version: '1.0.0',
353
+ type: 'widget',
354
+ config: { value: 1 },
355
+ }));
356
+ await writeFile(join(pluginDir, 'index.js'), `
357
+ export default class TestWidget {
358
+ constructor(config) {
359
+ this.config = config;
360
+ }
361
+ render() {}
362
+ async getData() { return this.config; }
363
+ onConfigChange(newConfig, oldConfig) {
364
+ this.config = newConfig;
365
+ }
366
+ }
367
+ `);
368
+ await writeFile(join(pluginDir, 'package.json'), JSON.stringify({ type: 'module' }));
369
+
370
+ await loader.loadPlugin(pluginDir);
371
+
372
+ // Manually trigger a reload
373
+ const result = await loader._reloadWidgetConfig('test-widget', join(pluginDir, 'plugin.json'));
374
+
375
+ expect(result.success).toBe(true);
376
+ });
377
+ });
378
+
379
+ describe('_findWidgetIdByConfigPath', () => {
380
+ test('should find widget by config path', async () => {
381
+ await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({
382
+ id: 'test-widget',
383
+ name: 'Test Widget',
384
+ version: '1.0.0',
385
+ type: 'widget',
386
+ }));
387
+ await writeFile(join(pluginDir, 'index.js'), 'export default { render: () => {}, getData: async () => ({}) };');
388
+ await writeFile(join(pluginDir, 'package.json'), JSON.stringify({ type: 'module' }));
389
+
390
+ await loader.loadPlugin(pluginDir);
391
+
392
+ const widgetId = loader._findWidgetIdByConfigPath(join(pluginDir, 'plugin.json'));
393
+ expect(widgetId).toBe('test-widget');
394
+ });
395
+
396
+ test('should return null for unknown path', async () => {
397
+ const widgetId = loader._findWidgetIdByConfigPath('/unknown/path/plugin.json');
398
+ expect(widgetId).toBeNull();
399
+ });
400
+ });
401
+ });
402
+
403
+ describe('Widget Config Processing Integration', () => {
404
+ test('should process widget config with env interpolation', () => {
405
+ process.env.WIDGET_TEST_API_URL = 'https://api.example.com';
406
+ process.env.WIDGET_TEST_TIMEOUT = '5000';
407
+
408
+ const config = {
409
+ apiUrl: '${WIDGET_TEST_API_URL}',
410
+ timeout: '${WIDGET_TEST_TIMEOUT:-3000}',
411
+ name: 'static_value',
412
+ };
413
+
414
+ const result = processWidgetConfig(config);
415
+
416
+ expect(result.success).toBe(true);
417
+ expect(result.config.apiUrl).toBe('https://api.example.com');
418
+ expect(result.config.timeout).toBe('5000');
419
+ expect(result.config.name).toBe('static_value');
420
+ });
421
+
422
+ test('should use default value when env var not set', () => {
423
+ delete process.env.WIDGET_TEST_MISSING;
424
+
425
+ const config = {
426
+ value: '${WIDGET_TEST_MISSING:-default_value}',
427
+ };
428
+
429
+ const result = processWidgetConfig(config);
430
+
431
+ expect(result.success).toBe(true);
432
+ expect(result.config.value).toBe('default_value');
433
+ });
434
+
435
+ test('should handle nested config objects', () => {
436
+ process.env.WIDGET_TEST_NESTED = 'nested_value';
437
+
438
+ const config = {
439
+ level1: {
440
+ level2: {
441
+ value: '${WIDGET_TEST_NESTED}',
442
+ },
443
+ },
444
+ array: ['${WIDGET_TEST_NESTED}', 'static'],
445
+ };
446
+
447
+ const result = processWidgetConfig(config);
448
+
449
+ expect(result.success).toBe(true);
450
+ expect(result.config.level1.level2.value).toBe('nested_value');
451
+ expect(result.config.array).toEqual(['nested_value', 'static']);
452
+ });
453
+
454
+ test('should handle config version validation', () => {
455
+ const config = {
456
+ __version: '99.99.99', // Future version
457
+ value: 'test',
458
+ };
459
+
460
+ const result = processWidgetConfig(config, { throwOnError: false });
461
+
462
+ expect(result.success).toBe(false);
463
+ expect(result.error).toContain('newer than current');
464
+ });
465
+ });