Blame Raw
Cohee · 51ad27fb · · 689 lines (21.7 KB)
2 contributors
1import { describe, test, expect, jest } from '@jest/globals';
2import {
3 keyToEnv,
4 getBasicAuthHeader,
5 getHexString,
6 normalizeZipEntryPath,
7 deepMerge,
8 uuidv4,
9 humanizedDateTime,
10 tryParse,
11 clientRelativePath,
12 getUniqueName,
13 removeFileExtension,
14 removeColorFormatting,
15 getSeparator,
16 isValidUrl,
17 urlHostnameToIPv6,
18 toBoolean,
19 stringToBool,
20 trimV1,
21 trimTrailingSlash,
22 mutateJsonString,
23 isPathUnderParent,
24 isFileURL,
25 getRequestURL,
26 delay,
27 formatBytes,
28 sanitizeSafeCharacterReplacements,
29 generateTimestamp,
30 mergeObjectWithYaml,
31 excludeKeysByYaml,
32 Cache,
33 MemoryLimitedMap,
34} from '../src/util';
35
36describe('keyToEnv', () => {
37 test('should convert dotted key to env var format', () => {
38 expect(keyToEnv('extensions.models.speechToText')).toBe('SILLYTAVERN_EXTENSIONS_MODELS_SPEECHTOTEXT');
39 });
40
41 test('should handle simple key without dots', () => {
42 expect(keyToEnv('port')).toBe('SILLYTAVERN_PORT');
43 });
44
45 test('should coerce non-string input via String()', () => {
46 expect(keyToEnv(42)).toBe('SILLYTAVERN_42');
47 });
48});
49
50describe('getBasicAuthHeader', () => {
51 test('should return a valid Basic auth header', () => {
52 expect(getBasicAuthHeader('user:pass')).toBe('Basic dXNlcjpwYXNz');
53 });
54
55 test('should handle empty string', () => {
56 expect(getBasicAuthHeader('')).toBe('Basic ');
57 });
58});
59
60describe('getHexString', () => {
61 test('should return a string of the requested length', () => {
62 expect(getHexString(8)).toHaveLength(8);
63 expect(getHexString(32)).toHaveLength(32);
64 });
65
66 test('should only contain hex characters', () => {
67 expect(getHexString(64)).toMatch(/^[0-9a-f]+$/);
68 });
69
70 test('should return empty string for length 0', () => {
71 expect(getHexString(0)).toBe('');
72 });
73});
74
75describe('normalizeZipEntryPath', () => {
76 test('should normalize backslashes to forward slashes', () => {
77 expect(normalizeZipEntryPath('foo\\bar\\baz.txt')).toBe('foo/bar/baz.txt');
78 });
79
80 test('should strip leading ./', () => {
81 expect(normalizeZipEntryPath('./file.txt')).toBe('file.txt');
82 });
83
84 test('should strip leading /', () => {
85 expect(normalizeZipEntryPath('/absolute/path.txt')).toBe('absolute/path.txt');
86 });
87
88 test('should reject path traversal', () => {
89 expect(normalizeZipEntryPath('../etc/passwd')).toBeNull();
90 });
91
92 test('should reject non-string input', () => {
93 expect(normalizeZipEntryPath(42)).toBeNull();
94 expect(normalizeZipEntryPath(null)).toBeNull();
95 });
96
97 test('should reject empty or whitespace-only string', () => {
98 expect(normalizeZipEntryPath('')).toBeNull();
99 expect(normalizeZipEntryPath(' ')).toBeNull();
100 });
101});
102
103describe('deepMerge', () => {
104 test('should merge flat objects', () => {
105 expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 });
106 });
107
108 test('should recursively merge nested objects', () => {
109 const target = { nested: { a: 1, b: 2 } };
110 const source = { nested: { b: 3, c: 4 } };
111 expect(deepMerge(target, source)).toEqual({ nested: { a: 1, b: 3, c: 4 } });
112 });
113
114 test('should override primitives with source values', () => {
115 expect(deepMerge({ a: 1 }, { a: 2 })).toEqual({ a: 2 });
116 });
117
118 test('should not mutate original objects', () => {
119 const target = { a: { x: 1 } };
120 const source = { a: { y: 2 } };
121 const result = deepMerge(target, source);
122 expect(target).toEqual({ a: { x: 1 } });
123 expect(result).toEqual({ a: { x: 1, y: 2 } });
124 });
125
126 test('should handle empty source', () => {
127 expect(deepMerge({ a: 1 }, {})).toEqual({ a: 1 });
128 });
129});
130
131describe('uuidv4', () => {
132 test('should return a valid UUIDv4 format', () => {
133 const uuid = uuidv4();
134 expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
135 });
136
137 test('should return unique values', () => {
138 const a = uuidv4();
139 const b = uuidv4();
140 expect(a).not.toBe(b);
141 });
142});
143
144describe('humanizedDateTime', () => {
145 test('should format a known timestamp correctly', () => {
146 // 2024-01-15 09:05:03.007 UTC
147 const timestamp = Date.UTC(2024, 0, 15, 9, 5, 3, 7);
148 const result = humanizedDateTime(timestamp);
149 // The output uses local time, so just check the format pattern
150 expect(result).toMatch(/^\d{4}-\d{2}-\d{2}@\d{2}h\d{2}m\d{2}s\d{3}ms$/);
151 });
152});
153
154describe('tryParse', () => {
155 test('should parse valid JSON', () => {
156 expect(tryParse('{"a":1}')).toEqual({ a: 1 });
157 });
158
159 test('should parse JSON array', () => {
160 expect(tryParse('[1,2,3]')).toEqual([1, 2, 3]);
161 });
162
163 test('should return undefined for invalid JSON', () => {
164 expect(tryParse('not json')).toBeUndefined();
165 });
166
167 test('should return undefined for empty string', () => {
168 expect(tryParse('')).toBeUndefined();
169 });
170});
171
172describe('clientRelativePath', () => {
173 test('should strip the root prefix and use forward slashes', () => {
174 expect(clientRelativePath('/data/user', '/data/user/images/pic.png')).toBe('/images/pic.png');
175 });
176
177 test('should throw if path does not start with root', () => {
178 expect(() => clientRelativePath('/data/user', '/other/path')).toThrow();
179 });
180});
181
182describe('getUniqueName', () => {
183 test('should return base name with index when first try collides', () => {
184 const existing = new Set(['Alice']);
185 const result = getUniqueName('Alice', name => existing.has(name));
186 expect(result).toBe('Alice (1)');
187 });
188
189 test('should increment index until unique', () => {
190 const existing = new Set(['Bob', 'Bob (1)', 'Bob (2)']);
191 const result = getUniqueName('Bob', name => existing.has(name));
192 expect(result).toBe('Bob (3)');
193 });
194
195 test('should return null when maxTries exceeded', () => {
196 const result = getUniqueName('X', () => true, { maxTries: 3 });
197 expect(result).toBeNull();
198 });
199
200 test('should support custom nameBuilder', () => {
201 const existing = new Set(['doc.txt']);
202 const result = getUniqueName('doc.txt', name => existing.has(name), {
203 nameBuilder: (base, i) => `doc (${i}).txt`,
204 });
205 expect(result).toBe('doc (1).txt');
206 });
207
208 test('should check basename first when startIndex is 0', () => {
209 const result = getUniqueName('Free', () => false, { startIndex: 0 });
210 expect(result).toBe('Free');
211 });
212});
213
214describe('removeFileExtension', () => {
215 test('should remove a single extension', () => {
216 expect(removeFileExtension('image.png')).toBe('image');
217 });
218
219 test('should remove only the last extension', () => {
220 expect(removeFileExtension('archive.tar.gz')).toBe('archive.tar');
221 });
222
223 test('should return filename unchanged if no extension', () => {
224 expect(removeFileExtension('README')).toBe('README');
225 });
226
227 test('should handle dotfiles', () => {
228 expect(removeFileExtension('.gitignore')).toBe('');
229 });
230});
231
232describe('removeColorFormatting', () => {
233 test('should strip ANSI color codes', () => {
234 expect(removeColorFormatting('\x1b[31mError\x1b[0m')).toBe('Error');
235 });
236
237 test('should return plain text unchanged', () => {
238 expect(removeColorFormatting('no colors here')).toBe('no colors here');
239 });
240});
241
242describe('getSeparator', () => {
243 test('should return n equals signs', () => {
244 expect(getSeparator(5)).toBe('=====');
245 });
246
247 test('should return empty string for 0', () => {
248 expect(getSeparator(0)).toBe('');
249 });
250});
251
252describe('isValidUrl', () => {
253 test('should accept valid HTTP URLs', () => {
254 expect(isValidUrl('https://example.com')).toBe(true);
255 expect(isValidUrl('http://localhost:8080/path')).toBe(true);
256 });
257
258 test('should accept file URLs', () => {
259 expect(isValidUrl('file:///tmp/test.txt')).toBe(true);
260 });
261
262 test('should reject non-URL strings', () => {
263 expect(isValidUrl('not a url')).toBe(false);
264 expect(isValidUrl('')).toBe(false);
265 });
266});
267
268describe('urlHostnameToIPv6', () => {
269 test('should strip surrounding brackets', () => {
270 expect(urlHostnameToIPv6('[::1]')).toBe('::1');
271 });
272
273 test('should handle already-clean hostname', () => {
274 expect(urlHostnameToIPv6('::1')).toBe('::1');
275 });
276
277 test('should handle IPv4 passthrough', () => {
278 expect(urlHostnameToIPv6('127.0.0.1')).toBe('127.0.0.1');
279 });
280});
281
282describe('toBoolean', () => {
283 test('should handle "true" and "false" strings case-insensitively', () => {
284 expect(toBoolean('true')).toBe(true);
285 expect(toBoolean('TRUE')).toBe(true);
286 expect(toBoolean('false')).toBe(false);
287 expect(toBoolean('False')).toBe(false);
288 });
289
290 test('should handle whitespace around boolean strings', () => {
291 expect(toBoolean(' true ')).toBe(true);
292 });
293
294 test('should use JS truthiness for non-boolean strings', () => {
295 expect(toBoolean('hello')).toBe(true);
296 expect(toBoolean('')).toBe(false);
297 });
298
299 test('should handle non-string values', () => {
300 expect(toBoolean(1)).toBe(true);
301 expect(toBoolean(0)).toBe(false);
302 expect(toBoolean(null)).toBe(false);
303 expect(toBoolean(undefined)).toBe(false);
304 });
305});
306
307describe('stringToBool', () => {
308 test('should convert "true" to true', () => {
309 expect(stringToBool('true')).toBe(true);
310 expect(stringToBool(' TRUE ')).toBe(true);
311 });
312
313 test('should convert "false" to false', () => {
314 expect(stringToBool('false')).toBe(false);
315 });
316
317 test('should pass through non-boolean strings', () => {
318 expect(stringToBool('hello')).toBe('hello');
319 });
320
321 test('should pass through null', () => {
322 expect(stringToBool(null)).toBe(null);
323 });
324});
325
326describe('trimV1', () => {
327 test('should remove trailing /v1', () => {
328 expect(trimV1('https://api.example.com/v1')).toBe('https://api.example.com');
329 });
330
331 test('should remove trailing slash', () => {
332 expect(trimV1('https://api.example.com/')).toBe('https://api.example.com');
333 });
334
335 test('should remove trailing slash then /v1', () => {
336 expect(trimV1('https://api.example.com/v1/')).toBe('https://api.example.com');
337 });
338
339 test('should handle null/undefined gracefully', () => {
340 expect(trimV1(null)).toBe('');
341 expect(trimV1(undefined)).toBe('');
342 });
343});
344
345describe('trimTrailingSlash', () => {
346 test('should remove trailing slash', () => {
347 expect(trimTrailingSlash('https://example.com/')).toBe('https://example.com');
348 });
349
350 test('should leave non-trailing-slash URLs unchanged', () => {
351 expect(trimTrailingSlash('https://example.com')).toBe('https://example.com');
352 });
353
354 test('should handle null/undefined gracefully', () => {
355 expect(trimTrailingSlash(null)).toBe('');
356 });
357});
358
359describe('mutateJsonString', () => {
360 test('should apply mutation and re-serialize', () => {
361 const result = mutateJsonString('{"a":1}', obj => { obj.b = 2; });
362 expect(JSON.parse(result)).toEqual({ a: 1, b: 2 });
363 });
364
365 test('should return original string on invalid JSON', () => {
366 const input = 'not json';
367 const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
368 expect(mutateJsonString(input, () => {})).toBe(input);
369 spy.mockRestore();
370 });
371});
372
373describe('isPathUnderParent', () => {
374 test('should accept child paths', () => {
375 expect(isPathUnderParent('/data', '/data/users/file.txt')).toBe(true);
376 });
377
378 test('should reject traversal attempts', () => {
379 expect(isPathUnderParent('/data', '/data/../etc/passwd')).toBe(false);
380 });
381
382 test('should reject sibling paths', () => {
383 expect(isPathUnderParent('/data/a', '/data/b')).toBe(false);
384 });
385
386 test('should accept the parent path itself', () => {
387 expect(isPathUnderParent('/data', '/data')).toBe(true);
388 });
389});
390
391describe('isFileURL', () => {
392 test('should detect file:// string URLs', () => {
393 expect(isFileURL('file:///tmp/test.txt')).toBe(true);
394 });
395
396 test('should reject non-file string URLs', () => {
397 expect(isFileURL('https://example.com')).toBe(false);
398 });
399
400 test('should detect file:// URL objects', () => {
401 expect(isFileURL(new URL('file:///tmp/test.txt'))).toBe(true);
402 });
403
404 test('should detect file:// Request objects', () => {
405 expect(isFileURL(new Request('file:///tmp/test.txt'))).toBe(true);
406 });
407
408 test('should return false for non-matching types', () => {
409 expect(isFileURL(42)).toBe(false);
410 });
411});
412
413describe('getRequestURL', () => {
414 test('should return string URLs as-is', () => {
415 expect(getRequestURL('https://example.com')).toBe('https://example.com');
416 });
417
418 test('should extract href from URL objects', () => {
419 expect(getRequestURL(new URL('https://example.com/path'))).toBe('https://example.com/path');
420 });
421
422 test('should extract url from Request objects', () => {
423 expect(getRequestURL(new Request('https://example.com/path'))).toBe('https://example.com/path');
424 });
425
426 test('should throw for invalid types', () => {
427 expect(() => getRequestURL(42)).toThrow(TypeError);
428 });
429});
430
431describe('delay', () => {
432 test('should resolve after the specified time', async () => {
433 jest.useFakeTimers();
434 let resolved = false;
435 delay(50).then(() => { resolved = true; });
436 expect(resolved).toBe(false);
437 jest.advanceTimersByTime(50);
438 await Promise.resolve();
439 expect(resolved).toBe(true);
440 jest.useRealTimers();
441 });
442
443 test('should return a promise', () => {
444 jest.useFakeTimers();
445 const result = delay(0);
446 expect(result).toBeInstanceOf(Promise);
447 jest.useRealTimers();
448 });
449});
450
451describe('formatBytes', () => {
452 test('should format bytes to human-readable string', () => {
453 expect(formatBytes(0)).toBe('0B');
454 expect(formatBytes(1024)).toBe('1KB');
455 expect(formatBytes(1048576)).toBe('1MB');
456 });
457
458 test('should return empty string for null/undefined', () => {
459 expect(formatBytes(null)).toBe('');
460 expect(formatBytes(undefined)).toBe('');
461 });
462});
463
464describe('sanitizeSafeCharacterReplacements', () => {
465 test('should always return underscore', () => {
466 expect(sanitizeSafeCharacterReplacements('/')).toBe('_');
467 expect(sanitizeSafeCharacterReplacements('\\')).toBe('_');
468 expect(sanitizeSafeCharacterReplacements(':')).toBe('_');
469 expect(sanitizeSafeCharacterReplacements('')).toBe('_');
470 });
471});
472
473describe('generateTimestamp', () => {
474 test('should return YYYYMMDD-HHMMSS format for a known date', () => {
475 jest.useFakeTimers();
476 jest.setSystemTime(new Date('2025-07-15T09:30:45'));
477 expect(generateTimestamp()).toBe('20250715-093045');
478 jest.useRealTimers();
479 });
480});
481
482describe('mergeObjectWithYaml', () => {
483 test('should merge a YAML object into the target', () => {
484 const obj = { a: 1 };
485 mergeObjectWithYaml(obj, 'b: 2\nc: 3');
486 expect(obj).toEqual({ a: 1, b: 2, c: 3 });
487 });
488
489 test('should merge a YAML array of objects into the target', () => {
490 const obj = { a: 1 };
491 mergeObjectWithYaml(obj, '- b: 2\n- c: 3');
492 expect(obj).toEqual({ a: 1, b: 2, c: 3 });
493 });
494
495 test('should override existing keys', () => {
496 const obj = { a: 1 };
497 mergeObjectWithYaml(obj, 'a: 99');
498 expect(obj.a).toBe(99);
499 });
500
501 test('should do nothing for empty/falsy yamlString', () => {
502 const obj = { a: 1 };
503 mergeObjectWithYaml(obj, '');
504 mergeObjectWithYaml(obj, null);
505 mergeObjectWithYaml(obj, undefined);
506 expect(obj).toEqual({ a: 1 });
507 });
508
509 test('should not throw on invalid YAML', () => {
510 const obj = { a: 1 };
511 expect(() => mergeObjectWithYaml(obj, '{{{')).not.toThrow();
512 expect(obj).toEqual({ a: 1 });
513 });
514
515 test('should skip non-object items in YAML array', () => {
516 const obj = { a: 1 };
517 mergeObjectWithYaml(obj, '- hello\n- b: 2');
518 expect(obj).toEqual({ a: 1, b: 2 });
519 });
520});
521
522describe('excludeKeysByYaml', () => {
523 test('should delete keys listed in a YAML array', () => {
524 const obj = { a: 1, b: 2, c: 3 };
525 excludeKeysByYaml(obj, '- a\n- c');
526 expect(obj).toEqual({ b: 2 });
527 });
528
529 test('should delete keys from a YAML object', () => {
530 const obj = { a: 1, b: 2 };
531 excludeKeysByYaml(obj, 'a: whatever\nb: whatever');
532 expect(obj).toEqual({});
533 });
534
535 test('should delete a single string key', () => {
536 const obj = { a: 1, b: 2 };
537 excludeKeysByYaml(obj, 'a');
538 expect(obj).toEqual({ b: 2 });
539 });
540
541 test('should do nothing for empty/falsy yamlString', () => {
542 const obj = { a: 1 };
543 excludeKeysByYaml(obj, '');
544 excludeKeysByYaml(obj, null);
545 expect(obj).toEqual({ a: 1 });
546 });
547
548 test('should not throw on invalid YAML', () => {
549 const obj = { a: 1 };
550 expect(() => excludeKeysByYaml(obj, '{{{')).not.toThrow();
551 expect(obj).toEqual({ a: 1 });
552 });
553});
554
555describe('Cache', () => {
556 test('should store and retrieve values', () => {
557 const cache = new Cache(1000);
558 cache.set('key', 'value');
559 expect(cache.get('key')).toBe('value');
560 });
561
562 test('should return null for missing keys', () => {
563 const cache = new Cache(1000);
564 expect(cache.get('missing')).toBeNull();
565 });
566
567 test('should return null for expired entries', () => {
568 jest.useFakeTimers();
569 const cache = new Cache(10);
570 cache.set('key', 'value');
571 jest.advanceTimersByTime(20);
572 expect(cache.get('key')).toBeNull();
573 jest.useRealTimers();
574 });
575
576 test('should remove entries', () => {
577 const cache = new Cache(1000);
578 cache.set('key', 'value');
579 cache.remove('key');
580 expect(cache.get('key')).toBeNull();
581 });
582
583 test('should clear all entries', () => {
584 const cache = new Cache(1000);
585 cache.set('a', 1);
586 cache.set('b', 2);
587 cache.clear();
588 expect(cache.get('a')).toBeNull();
589 expect(cache.get('b')).toBeNull();
590 });
591});
592
593describe('MemoryLimitedMap', () => {
594 test('should store and retrieve values', () => {
595 const map = new MemoryLimitedMap('1 MB');
596 map.set('key', 'value');
597 expect(map.get('key')).toBe('value');
598 expect(map.has('key')).toBe(true);
599 });
600
601 test('should reject non-string keys and values', () => {
602 const map = new MemoryLimitedMap('1 MB');
603 map.set(123, 'value');
604 map.set('key', 123);
605 expect(map.size()).toBe(0);
606 });
607
608 test('should evict oldest entries when memory limit is reached', () => {
609 // 20 bytes capacity = 10 chars (2 bytes per char)
610 const map = new MemoryLimitedMap('20B');
611 map.set('a', '12345'); // 10 bytes
612 map.set('b', '12345'); // 10 bytes, fills capacity
613 map.set('c', '12345'); // 10 bytes, should evict 'a'
614 expect(map.has('a')).toBe(false);
615 expect(map.has('b')).toBe(true);
616 expect(map.has('c')).toBe(true);
617 });
618
619 test('should reject values larger than max memory', () => {
620 const map = new MemoryLimitedMap('10B');
621 map.set('key', '123456'); // 12 bytes > 10 byte limit
622 expect(map.has('key')).toBe(false);
623 });
624
625 test('should do nothing when maxMemory is 0', () => {
626 const map = new MemoryLimitedMap('0B');
627 map.set('key', 'value');
628 expect(map.size()).toBe(0);
629 });
630
631 test('should track memory usage', () => {
632 const map = new MemoryLimitedMap('1 MB');
633 map.set('key', 'hello'); // 10 bytes
634 expect(map.totalMemory()).toBe(10);
635 });
636
637 test('should update memory when overwriting a key', () => {
638 const map = new MemoryLimitedMap('1 MB');
639 map.set('key', 'hi'); // 4 bytes
640 map.set('key', 'hello'); // 10 bytes
641 expect(map.totalMemory()).toBe(10);
642 expect(map.get('key')).toBe('hello');
643 });
644
645 test('should delete entries and free memory', () => {
646 const map = new MemoryLimitedMap('1 MB');
647 map.set('key', 'hello');
648 expect(map.delete('key')).toBe(true);
649 expect(map.totalMemory()).toBe(0);
650 expect(map.has('key')).toBe(false);
651 });
652
653 test('should return false when deleting non-existent key', () => {
654 const map = new MemoryLimitedMap('1 MB');
655 expect(map.delete('nope')).toBe(false);
656 });
657
658 test('should clear all entries and reset memory', () => {
659 const map = new MemoryLimitedMap('1 MB');
660 map.set('a', 'hello');
661 map.set('b', 'world');
662 map.clear();
663 expect(map.size()).toBe(0);
664 expect(map.totalMemory()).toBe(0);
665 });
666
667 test('should iterate with forEach', () => {
668 const map = new MemoryLimitedMap('1 MB');
669 map.set('a', '1');
670 map.set('b', '2');
671 const entries = [];
672 map.forEach((value, key) => entries.push([key, value]));
673 expect(entries).toEqual([['a', '1'], ['b', '2']]);
674 });
675
676 test('should expose keys and values iterators', () => {
677 const map = new MemoryLimitedMap('1 MB');
678 map.set('a', '1');
679 map.set('b', '2');
680 expect([...map.keys()]).toEqual(['a', 'b']);
681 expect([...map.values()]).toEqual(['1', '2']);
682 });
683
684 test('estimateStringSize should return 2 bytes per character', () => {
685 expect(MemoryLimitedMap.estimateStringSize('hello')).toBe(10);
686 expect(MemoryLimitedMap.estimateStringSize('')).toBe(0);
687 expect(MemoryLimitedMap.estimateStringSize(null)).toBe(0);
688 });
689});