import { describe, test, expect, jest } from '@jest/globals'; import { keyToEnv, getBasicAuthHeader, getHexString, normalizeZipEntryPath, deepMerge, uuidv4, humanizedDateTime, tryParse, clientRelativePath, getUniqueName, removeFileExtension, removeColorFormatting, getSeparator, isValidUrl, urlHostnameToIPv6, toBoolean, stringToBool, trimV1, trimTrailingSlash, mutateJsonString, isPathUnderParent, isFileURL, getRequestURL, delay, formatBytes, sanitizeSafeCharacterReplacements, generateTimestamp, mergeObjectWithYaml, excludeKeysByYaml, Cache, MemoryLimitedMap, } from '../src/util'; describe('keyToEnv', () => { test('should convert dotted key to env var format', () => { expect(keyToEnv('extensions.models.speechToText')).toBe('SILLYTAVERN_EXTENSIONS_MODELS_SPEECHTOTEXT'); }); test('should handle simple key without dots', () => { expect(keyToEnv('port')).toBe('SILLYTAVERN_PORT'); }); test('should coerce non-string input via String()', () => { expect(keyToEnv(42)).toBe('SILLYTAVERN_42'); }); }); describe('getBasicAuthHeader', () => { test('should return a valid Basic auth header', () => { expect(getBasicAuthHeader('user:pass')).toBe('Basic dXNlcjpwYXNz'); }); test('should handle empty string', () => { expect(getBasicAuthHeader('')).toBe('Basic '); }); }); describe('getHexString', () => { test('should return a string of the requested length', () => { expect(getHexString(8)).toHaveLength(8); expect(getHexString(32)).toHaveLength(32); }); test('should only contain hex characters', () => { expect(getHexString(64)).toMatch(/^[0-9a-f]+$/); }); test('should return empty string for length 0', () => { expect(getHexString(0)).toBe(''); }); }); describe('normalizeZipEntryPath', () => { test('should normalize backslashes to forward slashes', () => { expect(normalizeZipEntryPath('foo\\bar\\baz.txt')).toBe('foo/bar/baz.txt'); }); test('should strip leading ./', () => { expect(normalizeZipEntryPath('./file.txt')).toBe('file.txt'); }); test('should strip leading /', () => { expect(normalizeZipEntryPath('/absolute/path.txt')).toBe('absolute/path.txt'); }); test('should reject path traversal', () => { expect(normalizeZipEntryPath('../etc/passwd')).toBeNull(); }); test('should reject non-string input', () => { expect(normalizeZipEntryPath(42)).toBeNull(); expect(normalizeZipEntryPath(null)).toBeNull(); }); test('should reject empty or whitespace-only string', () => { expect(normalizeZipEntryPath('')).toBeNull(); expect(normalizeZipEntryPath(' ')).toBeNull(); }); }); describe('deepMerge', () => { test('should merge flat objects', () => { expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 }); }); test('should recursively merge nested objects', () => { const target = { nested: { a: 1, b: 2 } }; const source = { nested: { b: 3, c: 4 } }; expect(deepMerge(target, source)).toEqual({ nested: { a: 1, b: 3, c: 4 } }); }); test('should override primitives with source values', () => { expect(deepMerge({ a: 1 }, { a: 2 })).toEqual({ a: 2 }); }); test('should not mutate original objects', () => { const target = { a: { x: 1 } }; const source = { a: { y: 2 } }; const result = deepMerge(target, source); expect(target).toEqual({ a: { x: 1 } }); expect(result).toEqual({ a: { x: 1, y: 2 } }); }); test('should handle empty source', () => { expect(deepMerge({ a: 1 }, {})).toEqual({ a: 1 }); }); }); describe('uuidv4', () => { test('should return a valid UUIDv4 format', () => { const uuid = uuidv4(); 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}$/); }); test('should return unique values', () => { const a = uuidv4(); const b = uuidv4(); expect(a).not.toBe(b); }); }); describe('humanizedDateTime', () => { test('should format a known timestamp correctly', () => { // 2024-01-15 09:05:03.007 UTC const timestamp = Date.UTC(2024, 0, 15, 9, 5, 3, 7); const result = humanizedDateTime(timestamp); // The output uses local time, so just check the format pattern expect(result).toMatch(/^\d{4}-\d{2}-\d{2}@\d{2}h\d{2}m\d{2}s\d{3}ms$/); }); }); describe('tryParse', () => { test('should parse valid JSON', () => { expect(tryParse('{"a":1}')).toEqual({ a: 1 }); }); test('should parse JSON array', () => { expect(tryParse('[1,2,3]')).toEqual([1, 2, 3]); }); test('should return undefined for invalid JSON', () => { expect(tryParse('not json')).toBeUndefined(); }); test('should return undefined for empty string', () => { expect(tryParse('')).toBeUndefined(); }); }); describe('clientRelativePath', () => { test('should strip the root prefix and use forward slashes', () => { expect(clientRelativePath('/data/user', '/data/user/images/pic.png')).toBe('/images/pic.png'); }); test('should throw if path does not start with root', () => { expect(() => clientRelativePath('/data/user', '/other/path')).toThrow(); }); }); describe('getUniqueName', () => { test('should return base name with index when first try collides', () => { const existing = new Set(['Alice']); const result = getUniqueName('Alice', name => existing.has(name)); expect(result).toBe('Alice (1)'); }); test('should increment index until unique', () => { const existing = new Set(['Bob', 'Bob (1)', 'Bob (2)']); const result = getUniqueName('Bob', name => existing.has(name)); expect(result).toBe('Bob (3)'); }); test('should return null when maxTries exceeded', () => { const result = getUniqueName('X', () => true, { maxTries: 3 }); expect(result).toBeNull(); }); test('should support custom nameBuilder', () => { const existing = new Set(['doc.txt']); const result = getUniqueName('doc.txt', name => existing.has(name), { nameBuilder: (base, i) => `doc (${i}).txt`, }); expect(result).toBe('doc (1).txt'); }); test('should check basename first when startIndex is 0', () => { const result = getUniqueName('Free', () => false, { startIndex: 0 }); expect(result).toBe('Free'); }); }); describe('removeFileExtension', () => { test('should remove a single extension', () => { expect(removeFileExtension('image.png')).toBe('image'); }); test('should remove only the last extension', () => { expect(removeFileExtension('archive.tar.gz')).toBe('archive.tar'); }); test('should return filename unchanged if no extension', () => { expect(removeFileExtension('README')).toBe('README'); }); test('should handle dotfiles', () => { expect(removeFileExtension('.gitignore')).toBe(''); }); }); describe('removeColorFormatting', () => { test('should strip ANSI color codes', () => { expect(removeColorFormatting('\x1b[31mError\x1b[0m')).toBe('Error'); }); test('should return plain text unchanged', () => { expect(removeColorFormatting('no colors here')).toBe('no colors here'); }); }); describe('getSeparator', () => { test('should return n equals signs', () => { expect(getSeparator(5)).toBe('====='); }); test('should return empty string for 0', () => { expect(getSeparator(0)).toBe(''); }); }); describe('isValidUrl', () => { test('should accept valid HTTP URLs', () => { expect(isValidUrl('https://example.com')).toBe(true); expect(isValidUrl('http://localhost:8080/path')).toBe(true); }); test('should accept file URLs', () => { expect(isValidUrl('file:///tmp/test.txt')).toBe(true); }); test('should reject non-URL strings', () => { expect(isValidUrl('not a url')).toBe(false); expect(isValidUrl('')).toBe(false); }); }); describe('urlHostnameToIPv6', () => { test('should strip surrounding brackets', () => { expect(urlHostnameToIPv6('[::1]')).toBe('::1'); }); test('should handle already-clean hostname', () => { expect(urlHostnameToIPv6('::1')).toBe('::1'); }); test('should handle IPv4 passthrough', () => { expect(urlHostnameToIPv6('127.0.0.1')).toBe('127.0.0.1'); }); }); describe('toBoolean', () => { test('should handle "true" and "false" strings case-insensitively', () => { expect(toBoolean('true')).toBe(true); expect(toBoolean('TRUE')).toBe(true); expect(toBoolean('false')).toBe(false); expect(toBoolean('False')).toBe(false); }); test('should handle whitespace around boolean strings', () => { expect(toBoolean(' true ')).toBe(true); }); test('should use JS truthiness for non-boolean strings', () => { expect(toBoolean('hello')).toBe(true); expect(toBoolean('')).toBe(false); }); test('should handle non-string values', () => { expect(toBoolean(1)).toBe(true); expect(toBoolean(0)).toBe(false); expect(toBoolean(null)).toBe(false); expect(toBoolean(undefined)).toBe(false); }); }); describe('stringToBool', () => { test('should convert "true" to true', () => { expect(stringToBool('true')).toBe(true); expect(stringToBool(' TRUE ')).toBe(true); }); test('should convert "false" to false', () => { expect(stringToBool('false')).toBe(false); }); test('should pass through non-boolean strings', () => { expect(stringToBool('hello')).toBe('hello'); }); test('should pass through null', () => { expect(stringToBool(null)).toBe(null); }); }); describe('trimV1', () => { test('should remove trailing /v1', () => { expect(trimV1('https://api.example.com/v1')).toBe('https://api.example.com'); }); test('should remove trailing slash', () => { expect(trimV1('https://api.example.com/')).toBe('https://api.example.com'); }); test('should remove trailing slash then /v1', () => { expect(trimV1('https://api.example.com/v1/')).toBe('https://api.example.com'); }); test('should handle null/undefined gracefully', () => { expect(trimV1(null)).toBe(''); expect(trimV1(undefined)).toBe(''); }); }); describe('trimTrailingSlash', () => { test('should remove trailing slash', () => { expect(trimTrailingSlash('https://example.com/')).toBe('https://example.com'); }); test('should leave non-trailing-slash URLs unchanged', () => { expect(trimTrailingSlash('https://example.com')).toBe('https://example.com'); }); test('should handle null/undefined gracefully', () => { expect(trimTrailingSlash(null)).toBe(''); }); }); describe('mutateJsonString', () => { test('should apply mutation and re-serialize', () => { const result = mutateJsonString('{"a":1}', obj => { obj.b = 2; }); expect(JSON.parse(result)).toEqual({ a: 1, b: 2 }); }); test('should return original string on invalid JSON', () => { const input = 'not json'; const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); expect(mutateJsonString(input, () => {})).toBe(input); spy.mockRestore(); }); }); describe('isPathUnderParent', () => { test('should accept child paths', () => { expect(isPathUnderParent('/data', '/data/users/file.txt')).toBe(true); }); test('should reject traversal attempts', () => { expect(isPathUnderParent('/data', '/data/../etc/passwd')).toBe(false); }); test('should reject sibling paths', () => { expect(isPathUnderParent('/data/a', '/data/b')).toBe(false); }); test('should accept the parent path itself', () => { expect(isPathUnderParent('/data', '/data')).toBe(true); }); }); describe('isFileURL', () => { test('should detect file:// string URLs', () => { expect(isFileURL('file:///tmp/test.txt')).toBe(true); }); test('should reject non-file string URLs', () => { expect(isFileURL('https://example.com')).toBe(false); }); test('should detect file:// URL objects', () => { expect(isFileURL(new URL('file:///tmp/test.txt'))).toBe(true); }); test('should detect file:// Request objects', () => { expect(isFileURL(new Request('file:///tmp/test.txt'))).toBe(true); }); test('should return false for non-matching types', () => { expect(isFileURL(42)).toBe(false); }); }); describe('getRequestURL', () => { test('should return string URLs as-is', () => { expect(getRequestURL('https://example.com')).toBe('https://example.com'); }); test('should extract href from URL objects', () => { expect(getRequestURL(new URL('https://example.com/path'))).toBe('https://example.com/path'); }); test('should extract url from Request objects', () => { expect(getRequestURL(new Request('https://example.com/path'))).toBe('https://example.com/path'); }); test('should throw for invalid types', () => { expect(() => getRequestURL(42)).toThrow(TypeError); }); }); describe('delay', () => { test('should resolve after the specified time', async () => { jest.useFakeTimers(); let resolved = false; delay(50).then(() => { resolved = true; }); expect(resolved).toBe(false); jest.advanceTimersByTime(50); await Promise.resolve(); expect(resolved).toBe(true); jest.useRealTimers(); }); test('should return a promise', () => { jest.useFakeTimers(); const result = delay(0); expect(result).toBeInstanceOf(Promise); jest.useRealTimers(); }); }); describe('formatBytes', () => { test('should format bytes to human-readable string', () => { expect(formatBytes(0)).toBe('0B'); expect(formatBytes(1024)).toBe('1KB'); expect(formatBytes(1048576)).toBe('1MB'); }); test('should return empty string for null/undefined', () => { expect(formatBytes(null)).toBe(''); expect(formatBytes(undefined)).toBe(''); }); }); describe('sanitizeSafeCharacterReplacements', () => { test('should always return underscore', () => { expect(sanitizeSafeCharacterReplacements('/')).toBe('_'); expect(sanitizeSafeCharacterReplacements('\\')).toBe('_'); expect(sanitizeSafeCharacterReplacements(':')).toBe('_'); expect(sanitizeSafeCharacterReplacements('')).toBe('_'); }); }); describe('generateTimestamp', () => { test('should return YYYYMMDD-HHMMSS format for a known date', () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2025-07-15T09:30:45')); expect(generateTimestamp()).toBe('20250715-093045'); jest.useRealTimers(); }); }); describe('mergeObjectWithYaml', () => { test('should merge a YAML object into the target', () => { const obj = { a: 1 }; mergeObjectWithYaml(obj, 'b: 2\nc: 3'); expect(obj).toEqual({ a: 1, b: 2, c: 3 }); }); test('should merge a YAML array of objects into the target', () => { const obj = { a: 1 }; mergeObjectWithYaml(obj, '- b: 2\n- c: 3'); expect(obj).toEqual({ a: 1, b: 2, c: 3 }); }); test('should override existing keys', () => { const obj = { a: 1 }; mergeObjectWithYaml(obj, 'a: 99'); expect(obj.a).toBe(99); }); test('should do nothing for empty/falsy yamlString', () => { const obj = { a: 1 }; mergeObjectWithYaml(obj, ''); mergeObjectWithYaml(obj, null); mergeObjectWithYaml(obj, undefined); expect(obj).toEqual({ a: 1 }); }); test('should not throw on invalid YAML', () => { const obj = { a: 1 }; expect(() => mergeObjectWithYaml(obj, '{{{')).not.toThrow(); expect(obj).toEqual({ a: 1 }); }); test('should skip non-object items in YAML array', () => { const obj = { a: 1 }; mergeObjectWithYaml(obj, '- hello\n- b: 2'); expect(obj).toEqual({ a: 1, b: 2 }); }); }); describe('excludeKeysByYaml', () => { test('should delete keys listed in a YAML array', () => { const obj = { a: 1, b: 2, c: 3 }; excludeKeysByYaml(obj, '- a\n- c'); expect(obj).toEqual({ b: 2 }); }); test('should delete keys from a YAML object', () => { const obj = { a: 1, b: 2 }; excludeKeysByYaml(obj, 'a: whatever\nb: whatever'); expect(obj).toEqual({}); }); test('should delete a single string key', () => { const obj = { a: 1, b: 2 }; excludeKeysByYaml(obj, 'a'); expect(obj).toEqual({ b: 2 }); }); test('should do nothing for empty/falsy yamlString', () => { const obj = { a: 1 }; excludeKeysByYaml(obj, ''); excludeKeysByYaml(obj, null); expect(obj).toEqual({ a: 1 }); }); test('should not throw on invalid YAML', () => { const obj = { a: 1 }; expect(() => excludeKeysByYaml(obj, '{{{')).not.toThrow(); expect(obj).toEqual({ a: 1 }); }); }); describe('Cache', () => { test('should store and retrieve values', () => { const cache = new Cache(1000); cache.set('key', 'value'); expect(cache.get('key')).toBe('value'); }); test('should return null for missing keys', () => { const cache = new Cache(1000); expect(cache.get('missing')).toBeNull(); }); test('should return null for expired entries', () => { jest.useFakeTimers(); const cache = new Cache(10); cache.set('key', 'value'); jest.advanceTimersByTime(20); expect(cache.get('key')).toBeNull(); jest.useRealTimers(); }); test('should remove entries', () => { const cache = new Cache(1000); cache.set('key', 'value'); cache.remove('key'); expect(cache.get('key')).toBeNull(); }); test('should clear all entries', () => { const cache = new Cache(1000); cache.set('a', 1); cache.set('b', 2); cache.clear(); expect(cache.get('a')).toBeNull(); expect(cache.get('b')).toBeNull(); }); }); describe('MemoryLimitedMap', () => { test('should store and retrieve values', () => { const map = new MemoryLimitedMap('1 MB'); map.set('key', 'value'); expect(map.get('key')).toBe('value'); expect(map.has('key')).toBe(true); }); test('should reject non-string keys and values', () => { const map = new MemoryLimitedMap('1 MB'); map.set(123, 'value'); map.set('key', 123); expect(map.size()).toBe(0); }); test('should evict oldest entries when memory limit is reached', () => { // 20 bytes capacity = 10 chars (2 bytes per char) const map = new MemoryLimitedMap('20B'); map.set('a', '12345'); // 10 bytes map.set('b', '12345'); // 10 bytes, fills capacity map.set('c', '12345'); // 10 bytes, should evict 'a' expect(map.has('a')).toBe(false); expect(map.has('b')).toBe(true); expect(map.has('c')).toBe(true); }); test('should reject values larger than max memory', () => { const map = new MemoryLimitedMap('10B'); map.set('key', '123456'); // 12 bytes > 10 byte limit expect(map.has('key')).toBe(false); }); test('should do nothing when maxMemory is 0', () => { const map = new MemoryLimitedMap('0B'); map.set('key', 'value'); expect(map.size()).toBe(0); }); test('should track memory usage', () => { const map = new MemoryLimitedMap('1 MB'); map.set('key', 'hello'); // 10 bytes expect(map.totalMemory()).toBe(10); }); test('should update memory when overwriting a key', () => { const map = new MemoryLimitedMap('1 MB'); map.set('key', 'hi'); // 4 bytes map.set('key', 'hello'); // 10 bytes expect(map.totalMemory()).toBe(10); expect(map.get('key')).toBe('hello'); }); test('should delete entries and free memory', () => { const map = new MemoryLimitedMap('1 MB'); map.set('key', 'hello'); expect(map.delete('key')).toBe(true); expect(map.totalMemory()).toBe(0); expect(map.has('key')).toBe(false); }); test('should return false when deleting non-existent key', () => { const map = new MemoryLimitedMap('1 MB'); expect(map.delete('nope')).toBe(false); }); test('should clear all entries and reset memory', () => { const map = new MemoryLimitedMap('1 MB'); map.set('a', 'hello'); map.set('b', 'world'); map.clear(); expect(map.size()).toBe(0); expect(map.totalMemory()).toBe(0); }); test('should iterate with forEach', () => { const map = new MemoryLimitedMap('1 MB'); map.set('a', '1'); map.set('b', '2'); const entries = []; map.forEach((value, key) => entries.push([key, value])); expect(entries).toEqual([['a', '1'], ['b', '2']]); }); test('should expose keys and values iterators', () => { const map = new MemoryLimitedMap('1 MB'); map.set('a', '1'); map.set('b', '2'); expect([...map.keys()]).toEqual(['a', 'b']); expect([...map.values()]).toEqual(['1', '2']); }); test('estimateStringSize should return 2 bytes per character', () => { expect(MemoryLimitedMap.estimateStringSize('hello')).toBe(10); expect(MemoryLimitedMap.estimateStringSize('')).toBe(0); expect(MemoryLimitedMap.estimateStringSize(null)).toBe(0); }); });