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.
- package/CHANGELOG.md +29 -1
- package/docs/API.md +1 -2
- package/index.js +31 -41
- package/package.json +22 -12
- package/src/auto-save.js +11 -5
- package/src/cli/export-snapshot.js +3 -1
- package/src/cli/import-snapshot.js +3 -1
- package/src/config.js +9 -15
- package/src/errors.js +0 -9
- package/src/security.js +6 -2
- package/src/snapshot.js +73 -26
- package/src/web-server.js +7 -10
- package/.c8rc.json +0 -38
- package/.dockerignore +0 -68
- package/.github/dependabot.yml +0 -45
- package/.github/pull_request_template.md +0 -39
- package/.github/workflows/ci.yml +0 -147
- package/.github/workflows/release.yml +0 -40
- package/.github/workflows/security.yml +0 -84
- package/.husky/pre-commit +0 -1
- package/.planning/codebase/ARCHITECTURE.md +0 -206
- package/.planning/codebase/INTEGRATIONS.md +0 -150
- package/.planning/codebase/STACK.md +0 -122
- package/.planning/codebase/STRUCTURE.md +0 -201
- package/CONTRIBUTING.md +0 -378
- package/Dockerfile +0 -54
- package/FEATURES.md +0 -307
- package/TODO.md +0 -28
- package/ai.openclaw.dashboard.plist +0 -35
- package/build-cjs.js +0 -127
- package/cjs-shim.js +0 -13
- package/dist/clawdash +0 -1729
- package/dist/clawdash.meta.json +0 -2236
- package/dist/widgets.cjs +0 -6511
- package/docker-compose.yml +0 -67
- package/esbuild.config.js +0 -158
- package/eslint.config.js +0 -56
- package/examples/plugins/README.md +0 -122
- package/examples/plugins/api-status/index.js +0 -294
- package/examples/plugins/api-status/plugin.json +0 -19
- package/examples/plugins/hello-world/index.js +0 -104
- package/examples/plugins/hello-world/plugin.json +0 -15
- package/examples/plugins/system-metrics-chart/index.js +0 -339
- package/examples/plugins/system-metrics-chart/plugin.json +0 -18
- package/examples/plugins/weather-widget/index.js +0 -136
- package/examples/plugins/weather-widget/plugin.json +0 -19
- package/index.cjs +0 -23005
- package/jest.config.js +0 -11
- package/scripts/release.js +0 -595
- package/src/database.js +0 -734
- package/tests/alerts.test.js +0 -437
- package/tests/auto-save.test.js +0 -529
- package/tests/cache.test.js +0 -317
- package/tests/cli.test.js +0 -351
- package/tests/command-palette.test.js +0 -320
- package/tests/config-processor.test.js +0 -452
- package/tests/config-validator.test.js +0 -710
- package/tests/config-watcher.test.js +0 -594
- package/tests/config.test.js +0 -755
- package/tests/database.test.js +0 -438
- package/tests/errors.test.js +0 -189
- package/tests/example-plugins.test.js +0 -624
- package/tests/integration.test.js +0 -1321
- package/tests/loading-states.test.js +0 -300
- package/tests/manifest-validation-on-load.test.js +0 -671
- package/tests/performance-monitor.test.js +0 -190
- package/tests/plugin-api-rate-limit.test.js +0 -302
- package/tests/plugin-errors.test.js +0 -311
- package/tests/plugin-lifecycle-e2e.test.js +0 -1036
- package/tests/plugin-reload.test.js +0 -764
- package/tests/rate-limiter.test.js +0 -539
- package/tests/removed-dependencies.test.js +0 -74
- package/tests/retry.test.js +0 -308
- package/tests/security.test.js +0 -411
- package/tests/settings-modal.test.js +0 -226
- package/tests/theme-selector.test.js +0 -57
- package/tests/utils.js +0 -242
- package/tests/utils.test.js +0 -317
- package/tests/validate-plugin-cli.test.js +0 -359
- package/tests/validation.test.js +0 -837
- package/tests/web-server.test.js +0 -646
- package/tests/widget-arrange-mode.test.js +0 -183
- package/tests/widget-config-hot-reload.test.js +0 -465
- package/tests/widget-dependency.test.js +0 -591
- package/tests/widget-error-boundary.test.js +0 -749
- package/tests/widget-error-isolation.test.js +0 -469
- package/tests/widget-integration.test.js +0 -1147
- package/tests/widget-refresh-intervals.test.js +0 -284
- package/tests/worker-pool.test.js +0 -522
|
@@ -1,183 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,465 +0,0 @@
|
|
|
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
|
-
});
|