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.
- package/.c8rc.json +38 -0
- package/.dockerignore +68 -0
- package/.github/dependabot.yml +45 -0
- package/.github/pull_request_template.md +39 -0
- package/.github/workflows/ci.yml +147 -0
- package/.github/workflows/release.yml +40 -0
- package/.github/workflows/security.yml +84 -0
- package/.husky/pre-commit +1 -0
- package/.planning/codebase/ARCHITECTURE.md +206 -0
- package/.planning/codebase/INTEGRATIONS.md +150 -0
- package/.planning/codebase/STACK.md +122 -0
- package/.planning/codebase/STRUCTURE.md +201 -0
- package/CHANGELOG.md +293 -0
- package/CONTRIBUTING.md +378 -0
- package/Dockerfile +54 -0
- package/FEATURES.md +195 -21
- package/README.md +83 -6
- package/TODO.md +28 -0
- package/build-cjs.js +127 -0
- package/cjs-shim.js +13 -0
- package/dist/clawdash +1729 -0
- package/dist/clawdash.meta.json +2236 -0
- package/dist/widgets.cjs +6511 -0
- package/docker-compose.yml +67 -0
- package/docs/API.md +1050 -0
- package/docs/PLUGINS.md +1504 -0
- package/esbuild.config.js +158 -0
- package/eslint.config.js +56 -0
- package/examples/plugins/README.md +122 -0
- package/examples/plugins/api-status/index.js +294 -0
- package/examples/plugins/api-status/plugin.json +19 -0
- package/examples/plugins/hello-world/index.js +104 -0
- package/examples/plugins/hello-world/plugin.json +15 -0
- package/examples/plugins/system-metrics-chart/index.js +339 -0
- package/examples/plugins/system-metrics-chart/plugin.json +18 -0
- package/examples/plugins/weather-widget/index.js +136 -0
- package/examples/plugins/weather-widget/plugin.json +19 -0
- package/index.cjs +23005 -0
- package/index.js +5331 -512
- package/jest.config.js +11 -0
- package/man/clawdash.1 +276 -0
- package/package.json +54 -5
- package/schemas/plugin-manifest.json +128 -0
- package/scripts/release.js +595 -0
- package/src/alerts.js +693 -0
- package/src/auto-save.js +584 -0
- package/src/cache.js +390 -0
- package/src/checksum.js +146 -0
- package/src/cli/args.js +110 -0
- package/src/cli/export-schedule.js +423 -0
- package/src/cli/export-snapshot.js +138 -0
- package/src/cli/help.js +69 -0
- package/src/cli/import-snapshot.js +230 -0
- package/src/cli/index.js +14 -0
- package/src/cli/list-templates.js +69 -0
- package/src/cli/validate-config.js +76 -0
- package/src/cli/validate-plugin.js +187 -0
- package/src/cli/version.js +15 -0
- package/src/config-validator.js +586 -0
- package/src/config-watcher.js +401 -0
- package/src/config.js +684 -0
- package/src/container-detector.js +499 -0
- package/src/database.js +734 -0
- package/src/differential-render.js +327 -0
- package/src/errors.js +169 -0
- package/src/export-scheduler.js +581 -0
- package/src/gateway-manager.js +685 -0
- package/src/hints.js +371 -0
- package/src/loading-states.js +509 -0
- package/src/logger.js +185 -0
- package/src/memory-pressure.js +467 -0
- package/src/performance-monitor.js +374 -0
- package/src/plugin-errors.js +586 -0
- package/src/plugin-manifest-validator.js +219 -0
- package/src/plugin-reload.js +481 -0
- package/src/plugin-scaffold.js +1941 -0
- package/src/plugin-validator.js +284 -0
- package/src/retry.js +218 -0
- package/src/security.js +860 -0
- package/src/snapshot.js +372 -0
- package/src/splash.js +115 -0
- package/src/theme-selector.js +411 -0
- package/src/themes.js +874 -0
- package/src/transitions.js +534 -0
- package/src/types.d.ts +174 -0
- package/src/validation.js +926 -0
- package/src/web-server.js +885 -0
- package/src/widgets/builtin-widgets.js +1057 -0
- package/src/widgets/config-processor.js +401 -0
- package/src/widgets/dependency-resolver.js +596 -0
- package/src/widgets/index.js +79 -0
- package/src/widgets/plugin-api.js +845 -0
- package/src/widgets/widget-error-boundary.js +773 -0
- package/src/widgets/widget-error-isolation.js +551 -0
- package/src/widgets/widget-loader.js +1437 -0
- package/src/workers/system-worker.js +130 -0
- package/src/workers/worker-pool.js +633 -0
- package/tests/alerts.test.js +437 -0
- package/tests/auto-save.test.js +529 -0
- package/tests/cache.test.js +317 -0
- package/tests/cli.test.js +351 -0
- package/tests/command-palette.test.js +320 -0
- package/tests/config-processor.test.js +452 -0
- package/tests/config-validator.test.js +710 -0
- package/tests/config-watcher.test.js +594 -0
- package/tests/config.test.js +755 -0
- package/tests/database.test.js +438 -0
- package/tests/errors.test.js +189 -0
- package/tests/example-plugins.test.js +624 -0
- package/tests/integration.test.js +1321 -0
- package/tests/loading-states.test.js +300 -0
- package/tests/manifest-validation-on-load.test.js +671 -0
- package/tests/performance-monitor.test.js +190 -0
- package/tests/plugin-api-rate-limit.test.js +302 -0
- package/tests/plugin-errors.test.js +311 -0
- package/tests/plugin-lifecycle-e2e.test.js +1036 -0
- package/tests/plugin-reload.test.js +764 -0
- package/tests/rate-limiter.test.js +539 -0
- package/tests/retry.test.js +308 -0
- package/tests/security.test.js +411 -0
- package/tests/settings-modal.test.js +226 -0
- package/tests/theme-selector.test.js +57 -0
- package/tests/utils.js +242 -0
- package/tests/utils.test.js +317 -0
- package/tests/validate-plugin-cli.test.js +359 -0
- package/tests/validation.test.js +837 -0
- package/tests/web-server.test.js +646 -0
- package/tests/widget-arrange-mode.test.js +183 -0
- package/tests/widget-config-hot-reload.test.js +465 -0
- package/tests/widget-dependency.test.js +591 -0
- package/tests/widget-error-boundary.test.js +749 -0
- package/tests/widget-error-isolation.test.js +469 -0
- package/tests/widget-integration.test.js +1147 -0
- package/tests/widget-refresh-intervals.test.js +284 -0
- package/tests/worker-pool.test.js +522 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for auto-save module
|
|
3
|
+
* Dashboard state persistence and auto-save functionality
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { jest } from '@jest/globals';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
const __dirname = path.dirname(__filename);
|
|
14
|
+
|
|
15
|
+
// Import the modules to test - using dynamic import to handle ESM mocks
|
|
16
|
+
let autoSaveModule;
|
|
17
|
+
|
|
18
|
+
describe('auto-save', () => {
|
|
19
|
+
let tempDir;
|
|
20
|
+
let statePath;
|
|
21
|
+
|
|
22
|
+
beforeAll(async () => {
|
|
23
|
+
// Create temporary directory for test files
|
|
24
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'autosave-test-'));
|
|
25
|
+
statePath = path.join(tempDir, 'dashboard-state.json');
|
|
26
|
+
|
|
27
|
+
// Import the module fresh for each test
|
|
28
|
+
autoSaveModule = await import('../src/auto-save.js');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(() => {
|
|
32
|
+
// Clean up temp directory
|
|
33
|
+
if (fs.existsSync(tempDir)) {
|
|
34
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
// Clear all mocks
|
|
40
|
+
jest.clearAllMocks();
|
|
41
|
+
|
|
42
|
+
// Clean up any files in temp directory
|
|
43
|
+
if (fs.existsSync(tempDir)) {
|
|
44
|
+
fs.readdirSync(tempDir).forEach(f => {
|
|
45
|
+
try {
|
|
46
|
+
fs.unlinkSync(path.join(tempDir, f));
|
|
47
|
+
} catch {
|
|
48
|
+
// Ignore cleanup errors
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('AutoSaveManager', () => {
|
|
55
|
+
test('should create instance with default options', () => {
|
|
56
|
+
const { AutoSaveManager } = autoSaveModule;
|
|
57
|
+
const manager = new AutoSaveManager({});
|
|
58
|
+
|
|
59
|
+
expect(manager.enabled).toBe(true);
|
|
60
|
+
expect(manager.intervalMs).toBe(30000);
|
|
61
|
+
expect(manager.isDirty).toBe(false);
|
|
62
|
+
expect(manager.saveCount).toBe(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('should create instance with custom options', () => {
|
|
66
|
+
const { AutoSaveManager } = autoSaveModule;
|
|
67
|
+
const getState = () => ({ test: 'state' });
|
|
68
|
+
const getSettings = () => ({ test: 'settings' });
|
|
69
|
+
const saveSettings = jest.fn();
|
|
70
|
+
|
|
71
|
+
const manager = new AutoSaveManager({
|
|
72
|
+
enabled: false,
|
|
73
|
+
intervalMs: 5000,
|
|
74
|
+
statePath: '/custom/path/state.json',
|
|
75
|
+
getState,
|
|
76
|
+
getSettings,
|
|
77
|
+
saveSettings,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(manager.enabled).toBe(false);
|
|
81
|
+
expect(manager.intervalMs).toBe(5000);
|
|
82
|
+
expect(manager.statePath).toBe('/custom/path/state.json');
|
|
83
|
+
expect(manager.getState).toBe(getState);
|
|
84
|
+
expect(manager.getSettings).toBe(getSettings);
|
|
85
|
+
expect(manager.saveSettings).toBe(saveSettings);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('should start and stop timer', () => {
|
|
89
|
+
jest.useFakeTimers();
|
|
90
|
+
|
|
91
|
+
const { AutoSaveManager } = autoSaveModule;
|
|
92
|
+
const manager = new AutoSaveManager({
|
|
93
|
+
statePath,
|
|
94
|
+
intervalMs: 1000,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(manager.timer).toBeNull();
|
|
98
|
+
|
|
99
|
+
manager.start();
|
|
100
|
+
expect(manager.timer).not.toBeNull();
|
|
101
|
+
|
|
102
|
+
manager.stop();
|
|
103
|
+
expect(manager.timer).toBeNull();
|
|
104
|
+
|
|
105
|
+
jest.useRealTimers();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('should not start when disabled', () => {
|
|
109
|
+
const { AutoSaveManager } = autoSaveModule;
|
|
110
|
+
const manager = new AutoSaveManager({
|
|
111
|
+
enabled: false,
|
|
112
|
+
statePath,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
manager.start();
|
|
116
|
+
expect(manager.timer).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('should mark dirty', () => {
|
|
120
|
+
const { AutoSaveManager } = autoSaveModule;
|
|
121
|
+
const manager = new AutoSaveManager({ statePath });
|
|
122
|
+
|
|
123
|
+
expect(manager.isDirty).toBe(false);
|
|
124
|
+
manager.markDirty();
|
|
125
|
+
expect(manager.isDirty).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('should perform save and write file', async () => {
|
|
129
|
+
const { AutoSaveManager } = autoSaveModule;
|
|
130
|
+
const manager = new AutoSaveManager({
|
|
131
|
+
statePath,
|
|
132
|
+
getState: () => ({
|
|
133
|
+
selectedSessionIndex: 1,
|
|
134
|
+
paginationOffset: 2,
|
|
135
|
+
sessionSearchQuery: 'query',
|
|
136
|
+
isSearchMode: true,
|
|
137
|
+
showFavoritesOnly: true,
|
|
138
|
+
focusedWidgetIndex: 0,
|
|
139
|
+
currentRefreshInterval: 5000,
|
|
140
|
+
}),
|
|
141
|
+
getSettings: () => ({ theme: 'dark' }),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Trigger save
|
|
145
|
+
const result = manager.saveNow();
|
|
146
|
+
|
|
147
|
+
expect(result).toBe(true);
|
|
148
|
+
expect(manager.saveCount).toBe(1);
|
|
149
|
+
expect(fs.existsSync(statePath)).toBe(true);
|
|
150
|
+
|
|
151
|
+
// Verify file content
|
|
152
|
+
const savedData = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
153
|
+
expect(savedData.settings).toEqual({ theme: 'dark' });
|
|
154
|
+
expect(savedData.ui.selectedSessionIndex).toBe(1);
|
|
155
|
+
expect(savedData.ui.sessionSearchQuery).toBe('query');
|
|
156
|
+
expect(savedData.ui.isSearchMode).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('should skip save if state unchanged', () => {
|
|
160
|
+
const { AutoSaveManager } = autoSaveModule;
|
|
161
|
+
const manager = new AutoSaveManager({
|
|
162
|
+
statePath,
|
|
163
|
+
getState: () => ({ test: 'state' }),
|
|
164
|
+
getSettings: () => ({ test: 'settings' }),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// First save
|
|
168
|
+
manager.performAutoSave();
|
|
169
|
+
expect(manager.saveCount).toBe(1);
|
|
170
|
+
|
|
171
|
+
// Second save with same state should be skipped
|
|
172
|
+
manager.performAutoSave();
|
|
173
|
+
expect(manager.saveCount).toBe(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('should save on state change', () => {
|
|
177
|
+
let state = { version: 1 };
|
|
178
|
+
const { AutoSaveManager } = autoSaveModule;
|
|
179
|
+
const manager = new AutoSaveManager({
|
|
180
|
+
statePath,
|
|
181
|
+
getState: () => state,
|
|
182
|
+
getSettings: () => ({}),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// First save
|
|
186
|
+
manager.performAutoSave();
|
|
187
|
+
expect(manager.saveCount).toBe(1);
|
|
188
|
+
|
|
189
|
+
// Change state and save again
|
|
190
|
+
state = { version: 2 };
|
|
191
|
+
manager.isDirty = true;
|
|
192
|
+
manager.performAutoSave();
|
|
193
|
+
expect(manager.saveCount).toBe(2);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('should return false when disabled', () => {
|
|
197
|
+
const { AutoSaveManager } = autoSaveModule;
|
|
198
|
+
const manager = new AutoSaveManager({
|
|
199
|
+
enabled: false,
|
|
200
|
+
statePath,
|
|
201
|
+
getState: () => ({ test: 'state' }),
|
|
202
|
+
getSettings: () => ({}),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const result = manager.performAutoSave();
|
|
206
|
+
expect(result).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('should return stats', () => {
|
|
210
|
+
const { AutoSaveManager } = autoSaveModule;
|
|
211
|
+
const manager = new AutoSaveManager({
|
|
212
|
+
statePath: '/test/state.json',
|
|
213
|
+
intervalMs: 5000,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const stats = manager.getStats();
|
|
217
|
+
|
|
218
|
+
expect(stats.enabled).toBe(true);
|
|
219
|
+
expect(stats.intervalMs).toBe(5000);
|
|
220
|
+
expect(stats.saveCount).toBe(0);
|
|
221
|
+
expect(stats.statePath).toBe('/test/state.json');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('should update config', () => {
|
|
225
|
+
const { AutoSaveManager } = autoSaveModule;
|
|
226
|
+
const manager = new AutoSaveManager({
|
|
227
|
+
statePath,
|
|
228
|
+
intervalMs: 1000,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
manager.start();
|
|
232
|
+
expect(manager.intervalMs).toBe(1000);
|
|
233
|
+
|
|
234
|
+
manager.updateConfig({ intervalMs: 2000 });
|
|
235
|
+
expect(manager.intervalMs).toBe(2000);
|
|
236
|
+
|
|
237
|
+
manager.stop();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('should save state file with correct structure', () => {
|
|
241
|
+
const { AutoSaveManager } = autoSaveModule;
|
|
242
|
+
const manager = new AutoSaveManager({
|
|
243
|
+
statePath,
|
|
244
|
+
getState: () => ({
|
|
245
|
+
selectedSessionIndex: 5,
|
|
246
|
+
paginationOffset: 10,
|
|
247
|
+
sessionSearchQuery: 'test',
|
|
248
|
+
isSearchMode: true,
|
|
249
|
+
showFavoritesOnly: false,
|
|
250
|
+
focusedWidgetIndex: 2,
|
|
251
|
+
currentRefreshInterval: 5000,
|
|
252
|
+
}),
|
|
253
|
+
getSettings: () => ({ theme: 'dark' }),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
manager.performAutoSave();
|
|
257
|
+
|
|
258
|
+
const savedData = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
259
|
+
|
|
260
|
+
expect(savedData.timestamp).toBeDefined();
|
|
261
|
+
expect(savedData.settings).toEqual({ theme: 'dark' });
|
|
262
|
+
expect(savedData.ui.selectedSessionIndex).toBe(5);
|
|
263
|
+
expect(savedData.ui.paginationOffset).toBe(10);
|
|
264
|
+
expect(savedData.ui.sessionSearchQuery).toBe('test');
|
|
265
|
+
expect(savedData.ui.isSearchMode).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('should create backup before overwriting existing state', () => {
|
|
269
|
+
const { AutoSaveManager } = autoSaveModule;
|
|
270
|
+
const manager = new AutoSaveManager({
|
|
271
|
+
statePath,
|
|
272
|
+
getState: () => ({ selectedSessionIndex: 0 }),
|
|
273
|
+
getSettings: () => ({ theme: 'dark' }),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// First save - creates initial state
|
|
277
|
+
manager.performAutoSave();
|
|
278
|
+
expect(fs.existsSync(statePath)).toBe(true);
|
|
279
|
+
|
|
280
|
+
// Second save - should create backup
|
|
281
|
+
manager.getState = () => ({ selectedSessionIndex: 1 });
|
|
282
|
+
manager.isDirty = true;
|
|
283
|
+
manager.performAutoSave();
|
|
284
|
+
|
|
285
|
+
// Check backup was created
|
|
286
|
+
const dir = path.dirname(statePath);
|
|
287
|
+
const backups = fs.readdirSync(dir).filter(f => f.includes('dashboard-state.json') && f.endsWith('.backup'));
|
|
288
|
+
expect(backups.length).toBeGreaterThan(0);
|
|
289
|
+
|
|
290
|
+
// Verify backup contains first save data
|
|
291
|
+
const backupPath = path.join(dir, backups[0]);
|
|
292
|
+
const backupData = JSON.parse(fs.readFileSync(backupPath, 'utf8'));
|
|
293
|
+
expect(backupData.ui.selectedSessionIndex).toBe(0);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('should not create backup when backupEnabled is false', () => {
|
|
297
|
+
const { AutoSaveManager } = autoSaveModule;
|
|
298
|
+
const manager = new AutoSaveManager({
|
|
299
|
+
statePath,
|
|
300
|
+
backupEnabled: false,
|
|
301
|
+
getState: () => ({ selectedSessionIndex: 0 }),
|
|
302
|
+
getSettings: () => ({ theme: 'dark' }),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// First save
|
|
306
|
+
manager.performAutoSave();
|
|
307
|
+
|
|
308
|
+
// Second save - should not create backup
|
|
309
|
+
manager.getState = () => ({ selectedSessionIndex: 1 });
|
|
310
|
+
manager.isDirty = true;
|
|
311
|
+
manager.performAutoSave();
|
|
312
|
+
|
|
313
|
+
// Check no backups were created
|
|
314
|
+
const dir = path.dirname(statePath);
|
|
315
|
+
const backups = fs.readdirSync(dir).filter(f => f.endsWith('.backup'));
|
|
316
|
+
expect(backups.length).toBe(0);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('should clean up old backups keeping only backupCount', () => {
|
|
320
|
+
// Use isolated temp directory for this test to avoid interference
|
|
321
|
+
const isolatedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'autosave-backup-test-'));
|
|
322
|
+
const isolatedStatePath = path.join(isolatedDir, 'dashboard-state.json');
|
|
323
|
+
|
|
324
|
+
const { AutoSaveManager } = autoSaveModule;
|
|
325
|
+
const manager = new AutoSaveManager({
|
|
326
|
+
statePath: isolatedStatePath,
|
|
327
|
+
backupCount: 3,
|
|
328
|
+
getState: () => ({ selectedSessionIndex: 0 }),
|
|
329
|
+
getSettings: () => ({ theme: 'dark' }),
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// Create multiple saves to generate backups
|
|
333
|
+
// First save creates initial state (no backup)
|
|
334
|
+
// Subsequent saves create backups (i=1..5 creates 5 backups, cleanup removes oldest 2, keeps 3)
|
|
335
|
+
for (let i = 0; i < 6; i++) {
|
|
336
|
+
manager.getState = () => ({ selectedSessionIndex: i });
|
|
337
|
+
manager.isDirty = true;
|
|
338
|
+
manager.performAutoSave();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Check only 3 backups remain
|
|
342
|
+
const backups = fs.readdirSync(isolatedDir)
|
|
343
|
+
.filter(f => f.endsWith('.backup'));
|
|
344
|
+
expect(backups.length).toBe(3);
|
|
345
|
+
|
|
346
|
+
// Cleanup
|
|
347
|
+
fs.rmSync(isolatedDir, { recursive: true, force: true });
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('should track statistics on saves', () => {
|
|
351
|
+
const { AutoSaveManager } = autoSaveModule;
|
|
352
|
+
const manager = new AutoSaveManager({
|
|
353
|
+
statePath,
|
|
354
|
+
getState: () => ({ selectedSessionIndex: 5 }),
|
|
355
|
+
getSettings: () => ({ theme: 'dark' }),
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
manager.performAutoSave();
|
|
359
|
+
|
|
360
|
+
expect(manager.saveCount).toBe(1);
|
|
361
|
+
expect(manager.stats.totalBytesWritten).toBeGreaterThan(0);
|
|
362
|
+
expect(manager.stats.totalSaveTimeMs).toBeGreaterThanOrEqual(0);
|
|
363
|
+
expect(manager.stats.averageSaveTimeMs).toBeGreaterThanOrEqual(0);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('should return extended stats including backup info', () => {
|
|
367
|
+
const { AutoSaveManager } = autoSaveModule;
|
|
368
|
+
const manager = new AutoSaveManager({
|
|
369
|
+
statePath: '/test/state.json',
|
|
370
|
+
backupCount: 3,
|
|
371
|
+
backupEnabled: true,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const stats = manager.getStats();
|
|
375
|
+
|
|
376
|
+
expect(stats.backupEnabled).toBe(true);
|
|
377
|
+
expect(stats.backupCount).toBe(3);
|
|
378
|
+
expect(stats.totalBytesWritten).toBe(0);
|
|
379
|
+
expect(stats.totalBackupsCreated).toBe(0);
|
|
380
|
+
expect(stats.totalBackupsCleaned).toBe(0);
|
|
381
|
+
expect(stats.lastBackupPath).toBeNull();
|
|
382
|
+
expect(stats.averageSaveTimeMs).toBe(0);
|
|
383
|
+
expect(stats.totalSaveTimeMs).toBe(0);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('should log stats periodically', () => {
|
|
387
|
+
jest.useFakeTimers();
|
|
388
|
+
const { AutoSaveManager } = autoSaveModule;
|
|
389
|
+
const manager = new AutoSaveManager({
|
|
390
|
+
statePath,
|
|
391
|
+
statsLogIntervalMs: 1000,
|
|
392
|
+
getState: () => ({ selectedSessionIndex: 0 }),
|
|
393
|
+
getSettings: () => ({ theme: 'dark' }),
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// First save should log initial stats
|
|
397
|
+
manager.performAutoSave();
|
|
398
|
+
expect(manager.lastStatsLogTime).toBeGreaterThan(0);
|
|
399
|
+
|
|
400
|
+
// Fast-forward time
|
|
401
|
+
jest.advanceTimersByTime(500);
|
|
402
|
+
|
|
403
|
+
// Second save should not log (interval not passed)
|
|
404
|
+
const prevLogTime = manager.lastStatsLogTime;
|
|
405
|
+
manager.isDirty = true;
|
|
406
|
+
manager.performAutoSave();
|
|
407
|
+
expect(manager.lastStatsLogTime).toBe(prevLogTime);
|
|
408
|
+
|
|
409
|
+
// Fast-forward past interval
|
|
410
|
+
jest.advanceTimersByTime(600);
|
|
411
|
+
|
|
412
|
+
// Third save should log again
|
|
413
|
+
manager.isDirty = true;
|
|
414
|
+
manager.performAutoSave();
|
|
415
|
+
expect(manager.lastStatsLogTime).toBeGreaterThan(prevLogTime);
|
|
416
|
+
|
|
417
|
+
jest.useRealTimers();
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe('loadDashboardState', () => {
|
|
422
|
+
test('should load existing state file', () => {
|
|
423
|
+
const { loadDashboardState } = autoSaveModule;
|
|
424
|
+
const stateData = {
|
|
425
|
+
timestamp: Date.now(),
|
|
426
|
+
settings: { theme: 'dark' },
|
|
427
|
+
ui: { selectedSessionIndex: 3 },
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
fs.writeFileSync(statePath, JSON.stringify(stateData, null, 2));
|
|
431
|
+
|
|
432
|
+
const loaded = loadDashboardState(statePath);
|
|
433
|
+
|
|
434
|
+
expect(loaded.settings.theme).toBe('dark');
|
|
435
|
+
expect(loaded.ui.selectedSessionIndex).toBe(3);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test('should return null for non-existent file', () => {
|
|
439
|
+
const { loadDashboardState } = autoSaveModule;
|
|
440
|
+
const loaded = loadDashboardState(path.join(tempDir, 'nonexistent-state.json'));
|
|
441
|
+
expect(loaded).toBeNull();
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test('should return null for invalid JSON', () => {
|
|
445
|
+
const { loadDashboardState } = autoSaveModule;
|
|
446
|
+
const badStatePath = path.join(tempDir, 'bad-state.json');
|
|
447
|
+
fs.writeFileSync(badStatePath, 'invalid json');
|
|
448
|
+
const loaded = loadDashboardState(badStatePath);
|
|
449
|
+
expect(loaded).toBeNull();
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
describe('restoreDashboardState', () => {
|
|
454
|
+
test('should restore UI state to dashboard', () => {
|
|
455
|
+
const { restoreDashboardState } = autoSaveModule;
|
|
456
|
+
const dashboard = {
|
|
457
|
+
selectedSessionIndex: 0,
|
|
458
|
+
paginationOffset: 0,
|
|
459
|
+
sessionSearchQuery: '',
|
|
460
|
+
isSearchMode: false,
|
|
461
|
+
showFavoritesOnly: false,
|
|
462
|
+
focusedWidgetIndex: -1,
|
|
463
|
+
currentRefreshInterval: 2000,
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const savedState = {
|
|
467
|
+
ui: {
|
|
468
|
+
selectedSessionIndex: 5,
|
|
469
|
+
paginationOffset: 10,
|
|
470
|
+
sessionSearchQuery: 'test query',
|
|
471
|
+
isSearchMode: true,
|
|
472
|
+
showFavoritesOnly: true,
|
|
473
|
+
focusedWidgetIndex: 2,
|
|
474
|
+
currentRefreshInterval: 5000,
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const result = restoreDashboardState(savedState, dashboard);
|
|
479
|
+
|
|
480
|
+
expect(result).toBe(true);
|
|
481
|
+
expect(dashboard.selectedSessionIndex).toBe(5);
|
|
482
|
+
expect(dashboard.paginationOffset).toBe(10);
|
|
483
|
+
expect(dashboard.sessionSearchQuery).toBe('test query');
|
|
484
|
+
expect(dashboard.isSearchMode).toBe(true);
|
|
485
|
+
expect(dashboard.showFavoritesOnly).toBe(true);
|
|
486
|
+
expect(dashboard.focusedWidgetIndex).toBe(2);
|
|
487
|
+
expect(dashboard.currentRefreshInterval).toBe(5000);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('should return false for null savedState', () => {
|
|
491
|
+
const { restoreDashboardState } = autoSaveModule;
|
|
492
|
+
const dashboard = {};
|
|
493
|
+
const result = restoreDashboardState(null, dashboard);
|
|
494
|
+
expect(result).toBe(false);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test('should return false for missing ui data', () => {
|
|
498
|
+
const { restoreDashboardState } = autoSaveModule;
|
|
499
|
+
const dashboard = {};
|
|
500
|
+
const result = restoreDashboardState({}, dashboard);
|
|
501
|
+
expect(result).toBe(false);
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
describe('cleanupOldStateFiles', () => {
|
|
506
|
+
test('should clean up old state files', () => {
|
|
507
|
+
const { cleanupOldStateFiles } = autoSaveModule;
|
|
508
|
+
|
|
509
|
+
// Create test files
|
|
510
|
+
for (let i = 0; i < 5; i++) {
|
|
511
|
+
const filePath = path.join(tempDir, `dashboard-state-${i}.json`);
|
|
512
|
+
fs.writeFileSync(filePath, '{}');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
cleanupOldStateFiles(tempDir, 2);
|
|
516
|
+
|
|
517
|
+
const files = fs.readdirSync(tempDir).filter(f => f.startsWith('dashboard-state'));
|
|
518
|
+
expect(files.length).toBe(2);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('should handle non-existent directory', () => {
|
|
522
|
+
const { cleanupOldStateFiles } = autoSaveModule;
|
|
523
|
+
// Should not throw
|
|
524
|
+
expect(() => {
|
|
525
|
+
cleanupOldStateFiles('/nonexistent/directory', 3);
|
|
526
|
+
}).not.toThrow();
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
});
|