claw-dashboard 1.9.0 → 2.1.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 +5285 -566
- package/jest.config.js +11 -0
- package/man/clawdash.1 +276 -0
- package/package.json +54 -6
- 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 +1934 -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 +1056 -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,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for security module
|
|
3
|
+
* Path validation, sanitization, and widget config security
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
isValidPath,
|
|
8
|
+
isSafeToChmod,
|
|
9
|
+
isSafeToChmodSync,
|
|
10
|
+
setSecurePermissions,
|
|
11
|
+
setSecurePermissionsSync,
|
|
12
|
+
sanitizeWidgetConfig,
|
|
13
|
+
validateWidgetConfig,
|
|
14
|
+
WidgetConfigValidator,
|
|
15
|
+
validatePluginPath,
|
|
16
|
+
validatePluginName,
|
|
17
|
+
} from '../src/security.js';
|
|
18
|
+
|
|
19
|
+
import { writeFileSync, unlinkSync, mkdirSync, rmdirSync, chmodSync, symlinkSync } from 'fs';
|
|
20
|
+
import { join } from 'path';
|
|
21
|
+
import { mkdtempSync } from 'fs';
|
|
22
|
+
import { tmpdir } from 'os';
|
|
23
|
+
|
|
24
|
+
describe('security', () => {
|
|
25
|
+
describe('isValidPath', () => {
|
|
26
|
+
test('should return false for null/undefined', () => {
|
|
27
|
+
expect(isValidPath(null)).toBe(false);
|
|
28
|
+
expect(isValidPath(undefined)).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('should return false for non-string types', () => {
|
|
32
|
+
expect(isValidPath(123)).toBe(false);
|
|
33
|
+
expect(isValidPath({})).toBe(false);
|
|
34
|
+
expect(isValidPath([])).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('should return false for empty string', () => {
|
|
38
|
+
expect(isValidPath('')).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('should return false for paths with null bytes', () => {
|
|
42
|
+
expect(isValidPath('/path/to/file\0')).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('should return false for paths over 4096 chars', () => {
|
|
46
|
+
const longPath = '/' + 'a'.repeat(4096);
|
|
47
|
+
expect(isValidPath(longPath)).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('should return true for valid paths', () => {
|
|
51
|
+
expect(isValidPath('/path/to/file')).toBe(true);
|
|
52
|
+
expect(isValidPath('./relative/path')).toBe(true);
|
|
53
|
+
expect(isValidPath('simple.txt')).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('isSafeToChmodSync', () => {
|
|
58
|
+
let testDir;
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
testDir = mkdtempSync(join(tmpdir(), 'security-test-'));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
try {
|
|
66
|
+
if (existsSync(testDir)) {
|
|
67
|
+
rmdirSync(testDir, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
} catch (e) {
|
|
70
|
+
// Ignore cleanup errors
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('should return false for non-existent path', () => {
|
|
75
|
+
expect(isSafeToChmodSync('/nonexistent/path')).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('should return true for regular files', () => {
|
|
79
|
+
const testFile = join(testDir, 'test.txt');
|
|
80
|
+
writeFileSync(testFile, 'content');
|
|
81
|
+
expect(isSafeToChmodSync(testFile)).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('should return false for directories', () => {
|
|
85
|
+
expect(isSafeToChmodSync(testDir)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('should return false for symlinks', () => {
|
|
89
|
+
const testFile = join(testDir, 'test.txt');
|
|
90
|
+
const symlinkFile = join(testDir, 'link.txt');
|
|
91
|
+
writeFileSync(testFile, 'content');
|
|
92
|
+
symlinkSync(testFile, symlinkFile);
|
|
93
|
+
expect(isSafeToChmodSync(symlinkFile)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('setSecurePermissionsSync', () => {
|
|
98
|
+
let testDir;
|
|
99
|
+
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
testDir = mkdtempSync(join(tmpdir(), 'security-test-'));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
afterEach(() => {
|
|
105
|
+
try {
|
|
106
|
+
if (existsSync(testDir)) {
|
|
107
|
+
rmdirSync(testDir, { recursive: true });
|
|
108
|
+
}
|
|
109
|
+
} catch (e) {
|
|
110
|
+
// Ignore cleanup errors
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('should return false for invalid path', () => {
|
|
115
|
+
expect(setSecurePermissionsSync(null)).toBe(false);
|
|
116
|
+
expect(setSecurePermissionsSync('')).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('should return false for non-existent file', () => {
|
|
120
|
+
expect(setSecurePermissionsSync('/nonexistent/file')).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('should set permissions on valid file', () => {
|
|
124
|
+
const testFile = join(testDir, 'test.txt');
|
|
125
|
+
writeFileSync(testFile, 'content');
|
|
126
|
+
expect(setSecurePermissionsSync(testFile)).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('should return false for directory', () => {
|
|
130
|
+
expect(setSecurePermissionsSync(testDir)).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('WidgetConfigValidator', () => {
|
|
135
|
+
test('should create with default options', () => {
|
|
136
|
+
const validator = new WidgetConfigValidator();
|
|
137
|
+
expect(validator.maxStringLength).toBe(1000);
|
|
138
|
+
expect(validator.maxDepth).toBe(10);
|
|
139
|
+
expect(validator.maxArrayLength).toBe(100);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('should create with custom options', () => {
|
|
143
|
+
const validator = new WidgetConfigValidator({
|
|
144
|
+
maxStringLength: 500,
|
|
145
|
+
maxDepth: 5,
|
|
146
|
+
});
|
|
147
|
+
expect(validator.maxStringLength).toBe(500);
|
|
148
|
+
expect(validator.maxDepth).toBe(5);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('should handle null/undefined config', () => {
|
|
152
|
+
const validator = new WidgetConfigValidator();
|
|
153
|
+
expect(validator.validate(null)).toEqual({});
|
|
154
|
+
expect(validator.validate(undefined)).toEqual({});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('should throw for non-object config', () => {
|
|
158
|
+
const validator = new WidgetConfigValidator();
|
|
159
|
+
expect(() => validator.validate('string')).toThrow('must be an object');
|
|
160
|
+
expect(() => validator.validate(123)).toThrow('must be an object');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('should sanitize strings', () => {
|
|
164
|
+
const validator = new WidgetConfigValidator({ maxStringLength: 10 });
|
|
165
|
+
const result = validator.validate({ key: 'longer than 10 chars' });
|
|
166
|
+
expect(result.key).toBe('longer tha');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('should strip null bytes from strings', () => {
|
|
170
|
+
const validator = new WidgetConfigValidator();
|
|
171
|
+
const result = validator.validate({ key: 'hello\0world' });
|
|
172
|
+
expect(result.key).toBe('helloworld');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('should sanitize numbers', () => {
|
|
176
|
+
const validator = new WidgetConfigValidator();
|
|
177
|
+
const result = validator.validate({
|
|
178
|
+
normal: 42,
|
|
179
|
+
nan: NaN,
|
|
180
|
+
infinite: Infinity,
|
|
181
|
+
negInfinite: -Infinity,
|
|
182
|
+
});
|
|
183
|
+
expect(result.normal).toBe(42);
|
|
184
|
+
expect(result.nan).toBe(0);
|
|
185
|
+
expect(result.infinite).toBe(0);
|
|
186
|
+
expect(result.negInfinite).toBe(0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('should limit depth in nested objects', () => {
|
|
190
|
+
const validator = new WidgetConfigValidator({ maxDepth: 2 });
|
|
191
|
+
const deepObj = { a: { b: { c: { d: 'deep' } } } };
|
|
192
|
+
const result = validator.validate(deepObj);
|
|
193
|
+
// Depth is limited - nested objects beyond maxDepth get null values
|
|
194
|
+
expect(result.a).toBeDefined();
|
|
195
|
+
expect(result.a.b).toBeDefined();
|
|
196
|
+
// Objects beyond depth are null (not undefined)
|
|
197
|
+
expect(result.a.b.c).toBeNull();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('should sanitize arrays', () => {
|
|
201
|
+
const validator = new WidgetConfigValidator({ maxArrayLength: 2 });
|
|
202
|
+
const result = validator.validate({ arr: [1, 2, 3, 4, 5] });
|
|
203
|
+
expect(result.arr).toEqual([1, 2]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('should validate with schema', () => {
|
|
207
|
+
const validator = new WidgetConfigValidator();
|
|
208
|
+
const config = {
|
|
209
|
+
name: 'test',
|
|
210
|
+
count: 5,
|
|
211
|
+
unknown: 'should be removed',
|
|
212
|
+
};
|
|
213
|
+
const schema = {
|
|
214
|
+
properties: {
|
|
215
|
+
name: { type: 'string' },
|
|
216
|
+
count: { type: 'number', default: 0 },
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
const result = validator.validate(config, schema);
|
|
220
|
+
expect(result.name).toBe('test');
|
|
221
|
+
expect(result.count).toBe(5);
|
|
222
|
+
expect(result.unknown).toBeUndefined();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('sanitizeWidgetConfig', () => {
|
|
227
|
+
test('should sanitize basic config', () => {
|
|
228
|
+
const config = { name: 'test', count: 5 };
|
|
229
|
+
const result = sanitizeWidgetConfig(config);
|
|
230
|
+
expect(result).toEqual(config);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('should handle null config', () => {
|
|
234
|
+
expect(sanitizeWidgetConfig(null)).toEqual({});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('should strip null bytes', () => {
|
|
238
|
+
const config = { key: 'value\0' };
|
|
239
|
+
const result = sanitizeWidgetConfig(config);
|
|
240
|
+
expect(result.key).toBe('value');
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('validateWidgetConfig', () => {
|
|
245
|
+
test('should return valid for correct config', () => {
|
|
246
|
+
const config = { name: 'test' };
|
|
247
|
+
const schema = {
|
|
248
|
+
properties: {
|
|
249
|
+
name: { type: 'string' },
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
const result = validateWidgetConfig(config, schema);
|
|
253
|
+
expect(result.valid).toBe(true);
|
|
254
|
+
expect(result.errors).toEqual([]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('should handle null config gracefully', () => {
|
|
258
|
+
const result = validateWidgetConfig(null, {});
|
|
259
|
+
// Implementation gracefully handles null by returning empty object
|
|
260
|
+
expect(result.valid).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('validatePluginPath', () => {
|
|
265
|
+
let testDir;
|
|
266
|
+
|
|
267
|
+
beforeEach(() => {
|
|
268
|
+
testDir = mkdtempSync(join(tmpdir(), 'plugin-test-'));
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
afterEach(() => {
|
|
272
|
+
try {
|
|
273
|
+
if (existsSync(testDir)) {
|
|
274
|
+
rmdirSync(testDir, { recursive: true });
|
|
275
|
+
}
|
|
276
|
+
} catch (e) {
|
|
277
|
+
// Ignore cleanup errors
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('should reject null/undefined paths', () => {
|
|
282
|
+
expect(validatePluginPath(null).valid).toBe(false);
|
|
283
|
+
expect(validatePluginPath(undefined).valid).toBe(false);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('should reject empty strings', () => {
|
|
287
|
+
expect(validatePluginPath('').valid).toBe(false);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('should reject paths with null bytes', () => {
|
|
291
|
+
expect(validatePluginPath('path\0').valid).toBe(false);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('should reject absolute paths by default', () => {
|
|
295
|
+
expect(validatePluginPath('/absolute/path').valid).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('should allow absolute paths when option set', () => {
|
|
299
|
+
const result = validatePluginPath('/tmp/test', { allowAbsolute: true });
|
|
300
|
+
expect(result.valid).toBe(true);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('should reject path traversal attempts', () => {
|
|
304
|
+
expect(validatePluginPath('../etc/passwd').valid).toBe(false);
|
|
305
|
+
expect(validatePluginPath('foo/../bar').valid).toBe(false);
|
|
306
|
+
expect(validatePluginPath('foo/../../bar').valid).toBe(false);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test('should validate path stays within allowed directories', () => {
|
|
310
|
+
const result = validatePluginPath('subdir/file', {
|
|
311
|
+
allowedDirs: [testDir],
|
|
312
|
+
});
|
|
313
|
+
expect(result.valid).toBe(true);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('should reject paths outside allowed directories', () => {
|
|
317
|
+
const result = validatePluginPath('../other', {
|
|
318
|
+
allowedDirs: [testDir],
|
|
319
|
+
});
|
|
320
|
+
expect(result.valid).toBe(false);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('should check path exists when required', () => {
|
|
324
|
+
const testFile = join(testDir, 'exists.txt');
|
|
325
|
+
writeFileSync(testFile, 'content');
|
|
326
|
+
|
|
327
|
+
expect(validatePluginPath('exists.txt', {
|
|
328
|
+
allowedDirs: [testDir],
|
|
329
|
+
mustExist: true,
|
|
330
|
+
expectedType: 'file',
|
|
331
|
+
}).valid).toBe(true);
|
|
332
|
+
|
|
333
|
+
expect(validatePluginPath('nonexistent.txt', {
|
|
334
|
+
allowedDirs: [testDir],
|
|
335
|
+
mustExist: true,
|
|
336
|
+
}).valid).toBe(false);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('should reject invalid characters in path', () => {
|
|
340
|
+
expect(validatePluginPath('path with spaces/file').valid).toBe(false);
|
|
341
|
+
expect(validatePluginPath('path$with$special/file').valid).toBe(false);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('should allow valid plugin names', () => {
|
|
345
|
+
expect(validatePluginPath('my-plugin').valid).toBe(true);
|
|
346
|
+
expect(validatePluginPath('my_plugin').valid).toBe(true);
|
|
347
|
+
expect(validatePluginPath('Plugin123').valid).toBe(true);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('should reject hidden files', () => {
|
|
351
|
+
expect(validatePluginPath('.hidden/file').valid).toBe(false);
|
|
352
|
+
expect(validatePluginPath('dir/.secret').valid).toBe(false);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('should allow specific hidden files', () => {
|
|
356
|
+
expect(validatePluginPath('.gitkeep').valid).toBe(true);
|
|
357
|
+
expect(validatePluginPath('.gitignore').valid).toBe(true);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe('validatePluginName', () => {
|
|
362
|
+
test('should reject null/undefined', () => {
|
|
363
|
+
expect(validatePluginName(null).valid).toBe(false);
|
|
364
|
+
expect(validatePluginName(undefined).valid).toBe(false);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test('should reject non-strings', () => {
|
|
368
|
+
expect(validatePluginName(123).valid).toBe(false);
|
|
369
|
+
expect(validatePluginName({}).valid).toBe(false);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test('should reject empty strings', () => {
|
|
373
|
+
expect(validatePluginName('').valid).toBe(false);
|
|
374
|
+
expect(validatePluginName(' ').valid).toBe(false);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('should reject names over 100 chars', () => {
|
|
378
|
+
const longName = 'a'.repeat(101);
|
|
379
|
+
expect(validatePluginName(longName).valid).toBe(false);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test('should reject reserved names', () => {
|
|
383
|
+
expect(validatePluginName('node_modules').valid).toBe(false);
|
|
384
|
+
expect(validatePluginName('.git').valid).toBe(false);
|
|
385
|
+
expect(validatePluginName('package.json').valid).toBe(false);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('should reject names not starting with alphanumeric', () => {
|
|
389
|
+
expect(validatePluginName('-plugin').valid).toBe(false);
|
|
390
|
+
expect(validatePluginName('_plugin').valid).toBe(false);
|
|
391
|
+
expect(validatePluginName('.plugin').valid).toBe(false);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('should reject names with invalid characters', () => {
|
|
395
|
+
expect(validatePluginName('my plugin').valid).toBe(false);
|
|
396
|
+
expect(validatePluginName('my/plugin').valid).toBe(false);
|
|
397
|
+
expect(validatePluginName('my:plugin').valid).toBe(false);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test('should allow valid plugin names', () => {
|
|
401
|
+
expect(validatePluginName('my-plugin').valid).toBe(true);
|
|
402
|
+
expect(validatePluginName('my_plugin').valid).toBe(true);
|
|
403
|
+
expect(validatePluginName('Plugin123').valid).toBe(true);
|
|
404
|
+
expect(validatePluginName('a').valid).toBe(true);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test('should trim whitespace', () => {
|
|
408
|
+
expect(validatePluginName(' my-plugin ').valid).toBe(true);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Settings Modal Lifecycle
|
|
3
|
+
* Verifies the fix for navigation crash after opening/closing settings menu
|
|
4
|
+
*
|
|
5
|
+
* The crash was caused by a race condition during the async closeSettings transition.
|
|
6
|
+
* When settings was closing (150ms transition), navigation keys could still trigger
|
|
7
|
+
* because the settingsList.focused check wasn't accounting for the closing state.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { jest } from '@jest/globals';
|
|
11
|
+
|
|
12
|
+
describe('Settings Modal Lifecycle', () => {
|
|
13
|
+
describe('_settingsClosing flag behavior', () => {
|
|
14
|
+
test('should block navigation when settings is closing', () => {
|
|
15
|
+
// Simulate the state during settings close transition
|
|
16
|
+
const state = {
|
|
17
|
+
_settingsClosing: false,
|
|
18
|
+
w: {
|
|
19
|
+
settingsList: null,
|
|
20
|
+
settingsBox: null,
|
|
21
|
+
},
|
|
22
|
+
isModalActive: false,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Simulate navigation guard logic
|
|
26
|
+
const shouldBlockNavigation = () => {
|
|
27
|
+
return !!(state._settingsClosing || (state.w.settingsList && state.w.settingsList.focused));
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Initially, navigation should NOT be blocked
|
|
31
|
+
expect(shouldBlockNavigation()).toBe(false);
|
|
32
|
+
|
|
33
|
+
// Simulate opening settings
|
|
34
|
+
state.w.settingsBox = { destroy: jest.fn() };
|
|
35
|
+
state.w.settingsList = { focused: true };
|
|
36
|
+
state.isModalActive = true;
|
|
37
|
+
expect(shouldBlockNavigation()).toBe(true); // Blocked while settings is open
|
|
38
|
+
|
|
39
|
+
// Simulate closeSettings() being called - the critical fix
|
|
40
|
+
// _settingsClosing is set synchronously BEFORE the async transition
|
|
41
|
+
state._settingsClosing = true;
|
|
42
|
+
state.isModalActive = false;
|
|
43
|
+
// settingsList still exists during transition
|
|
44
|
+
expect(shouldBlockNavigation()).toBe(true); // Still blocked during transition!
|
|
45
|
+
|
|
46
|
+
// After transition completes
|
|
47
|
+
state._settingsClosing = false;
|
|
48
|
+
state.w.settingsList = null;
|
|
49
|
+
state.w.settingsBox = null;
|
|
50
|
+
expect(shouldBlockNavigation()).toBe(false); // Now navigation is allowed
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('should handle rapid open/close cycles', () => {
|
|
54
|
+
const state = {
|
|
55
|
+
_settingsClosing: false,
|
|
56
|
+
w: {
|
|
57
|
+
settingsList: null,
|
|
58
|
+
settingsBox: null,
|
|
59
|
+
},
|
|
60
|
+
isModalActive: false,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const shouldBlockNavigation = () => {
|
|
64
|
+
return !!(state._settingsClosing || (state.w.settingsList && state.w.settingsList.focused));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Rapid open/close cycle
|
|
68
|
+
// Open
|
|
69
|
+
state.w.settingsBox = {};
|
|
70
|
+
state.w.settingsList = { focused: true };
|
|
71
|
+
state.isModalActive = true;
|
|
72
|
+
expect(shouldBlockNavigation()).toBe(true);
|
|
73
|
+
|
|
74
|
+
// Close starts
|
|
75
|
+
state._settingsClosing = true;
|
|
76
|
+
state.isModalActive = false;
|
|
77
|
+
expect(shouldBlockNavigation()).toBe(true);
|
|
78
|
+
|
|
79
|
+
// Close completes
|
|
80
|
+
state._settingsClosing = false;
|
|
81
|
+
state.w.settingsList = null;
|
|
82
|
+
state.w.settingsBox = null;
|
|
83
|
+
expect(shouldBlockNavigation()).toBe(false);
|
|
84
|
+
|
|
85
|
+
// Rapidly re-open before any navigation
|
|
86
|
+
state.w.settingsBox = {};
|
|
87
|
+
state.w.settingsList = { focused: true };
|
|
88
|
+
state.isModalActive = true;
|
|
89
|
+
expect(shouldBlockNavigation()).toBe(true);
|
|
90
|
+
|
|
91
|
+
// Close again
|
|
92
|
+
state._settingsClosing = true;
|
|
93
|
+
state.isModalActive = false;
|
|
94
|
+
expect(shouldBlockNavigation()).toBe(true);
|
|
95
|
+
|
|
96
|
+
// Final cleanup
|
|
97
|
+
state._settingsClosing = false;
|
|
98
|
+
state.w.settingsList = null;
|
|
99
|
+
state.w.settingsBox = null;
|
|
100
|
+
expect(shouldBlockNavigation()).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('should use try/finally pattern to ensure flag is always cleared', () => {
|
|
104
|
+
// This test verifies the code pattern used in closeSettings()
|
|
105
|
+
// The flag should be cleared even if an error occurs
|
|
106
|
+
|
|
107
|
+
const state = {
|
|
108
|
+
_settingsClosing: false,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Simulate the closeSettings pattern
|
|
112
|
+
const simulateClose = async (shouldThrow = false) => {
|
|
113
|
+
state._settingsClosing = true;
|
|
114
|
+
try {
|
|
115
|
+
if (shouldThrow) {
|
|
116
|
+
throw new Error('Transition failed');
|
|
117
|
+
}
|
|
118
|
+
// Simulate async transition
|
|
119
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
120
|
+
} finally {
|
|
121
|
+
state._settingsClosing = false;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Normal close
|
|
126
|
+
return simulateClose(false).then(() => {
|
|
127
|
+
expect(state._settingsClosing).toBe(false);
|
|
128
|
+
|
|
129
|
+
// Close with error
|
|
130
|
+
return simulateClose(true).catch(() => {
|
|
131
|
+
// Error is expected
|
|
132
|
+
});
|
|
133
|
+
}).then(() => {
|
|
134
|
+
// Flag should still be cleared even after error
|
|
135
|
+
expect(state._settingsClosing).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('isModalActive state management', () => {
|
|
141
|
+
test('should be set false synchronously when closeSettings starts', () => {
|
|
142
|
+
// This test verifies the fix for the race condition where
|
|
143
|
+
// isModalActive was only set after the async transition completed
|
|
144
|
+
|
|
145
|
+
const state = {
|
|
146
|
+
isModalActive: false,
|
|
147
|
+
_settingsClosing: false,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Simulate closeSettings() start
|
|
151
|
+
const closeSettingsStart = () => {
|
|
152
|
+
state._settingsClosing = true;
|
|
153
|
+
state.isModalActive = false;
|
|
154
|
+
// At this point, even though transition hasn't completed,
|
|
155
|
+
// isModalActive is already false
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Open settings first
|
|
159
|
+
state.isModalActive = true;
|
|
160
|
+
expect(state.isModalActive).toBe(true);
|
|
161
|
+
|
|
162
|
+
// Close starts - isModalActive should be false immediately
|
|
163
|
+
closeSettingsStart();
|
|
164
|
+
expect(state.isModalActive).toBe(false);
|
|
165
|
+
expect(state._settingsClosing).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('Navigation guard logic', () => {
|
|
170
|
+
test('should check _settingsClosing first for performance', () => {
|
|
171
|
+
// The guard uses || with _settingsClosing first
|
|
172
|
+
// This means if _settingsClosing is true, we don't even check settingsList.focused
|
|
173
|
+
// which could cause issues with destroyed widgets
|
|
174
|
+
|
|
175
|
+
let settingsListFocusedCallCount = 0;
|
|
176
|
+
const state = {
|
|
177
|
+
_settingsClosing: false,
|
|
178
|
+
w: {
|
|
179
|
+
settingsList: {
|
|
180
|
+
get focused() {
|
|
181
|
+
settingsListFocusedCallCount++;
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const shouldBlockNavigation = () => {
|
|
189
|
+
return !!(state._settingsClosing || (state.w.settingsList && state.w.settingsList.focused));
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// When _settingsClosing is false, settingsList.focused is checked
|
|
193
|
+
settingsListFocusedCallCount = 0;
|
|
194
|
+
state._settingsClosing = false;
|
|
195
|
+
shouldBlockNavigation();
|
|
196
|
+
expect(settingsListFocusedCallCount).toBe(1);
|
|
197
|
+
|
|
198
|
+
// When _settingsClosing is true, settingsList.focused is NOT checked (short-circuit)
|
|
199
|
+
settingsListFocusedCallCount = 0;
|
|
200
|
+
state._settingsClosing = true;
|
|
201
|
+
shouldBlockNavigation();
|
|
202
|
+
expect(settingsListFocusedCallCount).toBe(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('should handle null settingsList gracefully', () => {
|
|
206
|
+
const state = {
|
|
207
|
+
_settingsClosing: false,
|
|
208
|
+
w: {
|
|
209
|
+
settingsList: null,
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const shouldBlockNavigation = () => {
|
|
214
|
+
return !!(state._settingsClosing || (state.w.settingsList && state.w.settingsList.focused));
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Should not throw when settingsList is null
|
|
218
|
+
expect(() => shouldBlockNavigation()).not.toThrow();
|
|
219
|
+
expect(shouldBlockNavigation()).toBe(false);
|
|
220
|
+
|
|
221
|
+
// Should still work when _settingsClosing is true
|
|
222
|
+
state._settingsClosing = true;
|
|
223
|
+
expect(shouldBlockNavigation()).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for theme-selector.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
getThemeInfo,
|
|
7
|
+
getAllThemesInfo
|
|
8
|
+
} from '../src/theme-selector.js';
|
|
9
|
+
|
|
10
|
+
describe('Theme Selector', () => {
|
|
11
|
+
describe('getThemeInfo', () => {
|
|
12
|
+
test('should return theme info for a theme', () => {
|
|
13
|
+
const info = getThemeInfo('dark');
|
|
14
|
+
|
|
15
|
+
expect(info).toBeDefined();
|
|
16
|
+
expect(info.name).toBe('dark');
|
|
17
|
+
expect(typeof info.displayName).toBe('string');
|
|
18
|
+
expect(typeof info.isCurrent).toBe('boolean');
|
|
19
|
+
expect(info.isAuto).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('should identify auto theme', () => {
|
|
23
|
+
const info = getThemeInfo('auto');
|
|
24
|
+
|
|
25
|
+
expect(info.isAuto).toBe(true);
|
|
26
|
+
expect(info.displayName).toBe('Auto-detect');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('should include color categories for regular themes', () => {
|
|
30
|
+
const info = getThemeInfo('dark');
|
|
31
|
+
|
|
32
|
+
expect(Array.isArray(info.colors)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('getAllThemesInfo', () => {
|
|
37
|
+
test('should return info for all themes', () => {
|
|
38
|
+
const themes = getAllThemesInfo();
|
|
39
|
+
|
|
40
|
+
expect(Array.isArray(themes)).toBe(true);
|
|
41
|
+
expect(themes.length).toBeGreaterThan(0);
|
|
42
|
+
|
|
43
|
+
// Check for expected theme names
|
|
44
|
+
const names = themes.map(t => t.name);
|
|
45
|
+
expect(names).toContain('default');
|
|
46
|
+
expect(names).toContain('dark');
|
|
47
|
+
expect(names).toContain('auto');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('should mark one theme as current', () => {
|
|
51
|
+
const themes = getAllThemesInfo();
|
|
52
|
+
const currentThemes = themes.filter(t => t.isCurrent);
|
|
53
|
+
|
|
54
|
+
expect(currentThemes.length).toBe(1);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|