diff --git a/web/__tests__/check-i18n.test.ts b/web/__tests__/check-i18n.test.ts
index b579f22d4b..7773edcdbb 100644
--- a/web/__tests__/check-i18n.test.ts
+++ b/web/__tests__/check-i18n.test.ts
@@ -759,4 +759,104 @@ export default translation`
expect(result).not.toContain('Zbuduj inteligentnego agenta')
})
})
+
+ describe('Performance and Scalability', () => {
+ it('should handle large translation files efficiently', async () => {
+ // Create a large translation file with 1000 keys
+ const largeContent = `const translation = {
+${Array.from({ length: 1000 }, (_, i) => ` key${i}: 'value${i}',`).join('\n')}
+}
+
+export default translation`
+
+ fs.writeFileSync(path.join(testEnDir, 'large.ts'), largeContent)
+
+ const startTime = Date.now()
+ const keys = await getKeysFromLanguage('en-US')
+ const endTime = Date.now()
+
+ expect(keys.length).toBe(1000)
+ expect(endTime - startTime).toBeLessThan(1000) // Should complete in under 1 second
+ })
+
+ it('should handle multiple translation files concurrently', async () => {
+ // Create multiple files
+ for (let i = 0; i < 10; i++) {
+ const content = `const translation = {
+ key${i}: 'value${i}',
+ nested${i}: {
+ subkey: 'subvalue'
+ }
+}
+
+export default translation`
+ fs.writeFileSync(path.join(testEnDir, `file${i}.ts`), content)
+ }
+
+ const startTime = Date.now()
+ const keys = await getKeysFromLanguage('en-US')
+ const endTime = Date.now()
+
+ expect(keys.length).toBe(20) // 10 files * 2 keys each
+ expect(endTime - startTime).toBeLessThan(500)
+ })
+ })
+
+ describe('Unicode and Internationalization', () => {
+ it('should handle Unicode characters in keys and values', async () => {
+ const unicodeContent = `const translation = {
+ '中文键': '中文值',
+ 'العربية': 'قيمة',
+ 'emoji_😀': 'value with emoji 🎉',
+ 'mixed_中文_English': 'mixed value'
+}
+
+export default translation`
+
+ fs.writeFileSync(path.join(testEnDir, 'unicode.ts'), unicodeContent)
+
+ const keys = await getKeysFromLanguage('en-US')
+
+ expect(keys).toContain('unicode.中文键')
+ expect(keys).toContain('unicode.العربية')
+ expect(keys).toContain('unicode.emoji_😀')
+ expect(keys).toContain('unicode.mixed_中文_English')
+ })
+
+ it('should handle RTL language files', async () => {
+ const rtlContent = `const translation = {
+ مرحبا: 'Hello',
+ العالم: 'World',
+ nested: {
+ مفتاح: 'key'
+ }
+}
+
+export default translation`
+
+ fs.writeFileSync(path.join(testEnDir, 'rtl.ts'), rtlContent)
+
+ const keys = await getKeysFromLanguage('en-US')
+
+ expect(keys).toContain('rtl.مرحبا')
+ expect(keys).toContain('rtl.العالم')
+ expect(keys).toContain('rtl.nested.مفتاح')
+ })
+ })
+
+ describe('Error Recovery', () => {
+ it('should handle syntax errors in translation files gracefully', async () => {
+ const invalidContent = `const translation = {
+ validKey: 'valid value',
+ invalidKey: 'missing quote,
+ anotherKey: 'another value'
+}
+
+export default translation`
+
+ fs.writeFileSync(path.join(testEnDir, 'invalid.ts'), invalidContent)
+
+ await expect(getKeysFromLanguage('en-US')).rejects.toThrow()
+ })
+ })
})
diff --git a/web/__tests__/navigation-utils.test.ts b/web/__tests__/navigation-utils.test.ts
index fa4986e63d..3eeba52943 100644
--- a/web/__tests__/navigation-utils.test.ts
+++ b/web/__tests__/navigation-utils.test.ts
@@ -286,4 +286,116 @@ describe('Navigation Utilities', () => {
expect(mockPush).toHaveBeenCalledWith('/datasets/filtered-set/documents?page=1&limit=50&status=active&type=pdf&sort=created_at&order=desc')
})
})
+
+ describe('Edge Cases and Error Handling', () => {
+ test('handles special characters in query parameters', () => {
+ Object.defineProperty(window, 'location', {
+ value: { search: '?keyword=hello%20world&filter=type%3Apdf&tag=%E4%B8%AD%E6%96%87' },
+ writable: true,
+ })
+
+ const path = createNavigationPath('/datasets/123/documents')
+ expect(path).toContain('hello+world')
+ expect(path).toContain('type%3Apdf')
+ expect(path).toContain('%E4%B8%AD%E6%96%87')
+ })
+
+ test('handles duplicate query parameters', () => {
+ Object.defineProperty(window, 'location', {
+ value: { search: '?tag=tag1&tag=tag2&tag=tag3' },
+ writable: true,
+ })
+
+ const params = extractQueryParams(['tag'])
+ // URLSearchParams.get() returns the first value
+ expect(params.tag).toBe('tag1')
+ })
+
+ test('handles very long query strings', () => {
+ const longValue = 'a'.repeat(1000)
+ Object.defineProperty(window, 'location', {
+ value: { search: `?data=${longValue}` },
+ writable: true,
+ })
+
+ const path = createNavigationPath('/datasets/123/documents')
+ expect(path).toContain(longValue)
+ expect(path.length).toBeGreaterThan(1000)
+ })
+
+ test('handles empty string values in query parameters', () => {
+ const path = createNavigationPathWithParams('/datasets/123/documents', {
+ page: 1,
+ keyword: '',
+ filter: '',
+ sort: 'name',
+ })
+
+ expect(path).toBe('/datasets/123/documents?page=1&sort=name')
+ expect(path).not.toContain('keyword=')
+ expect(path).not.toContain('filter=')
+ })
+
+ test('handles null and undefined values in mergeQueryParams', () => {
+ Object.defineProperty(window, 'location', {
+ value: { search: '?page=1&limit=10&keyword=test' },
+ writable: true,
+ })
+
+ const merged = mergeQueryParams({
+ keyword: null,
+ filter: undefined,
+ sort: 'name',
+ })
+ const result = merged.toString()
+
+ expect(result).toContain('page=1')
+ expect(result).toContain('limit=10')
+ expect(result).not.toContain('keyword')
+ expect(result).toContain('sort=name')
+ })
+
+ test('handles navigation with hash fragments', () => {
+ Object.defineProperty(window, 'location', {
+ value: { search: '?page=1', hash: '#section-2' },
+ writable: true,
+ })
+
+ const path = createNavigationPath('/datasets/123/documents')
+ // Should preserve query params but not hash
+ expect(path).toBe('/datasets/123/documents?page=1')
+ })
+
+ test('handles malformed query strings gracefully', () => {
+ Object.defineProperty(window, 'location', {
+ value: { search: '?page=1&invalid&limit=10&=value&key=' },
+ writable: true,
+ })
+
+ const params = extractQueryParams(['page', 'limit', 'invalid', 'key'])
+ expect(params.page).toBe('1')
+ expect(params.limit).toBe('10')
+ // Malformed params should be handled by URLSearchParams
+ expect(params.invalid).toBe('') // for `&invalid`
+ expect(params.key).toBe('') // for `&key=`
+ })
+ })
+
+ describe('Performance Tests', () => {
+ test('handles large number of query parameters efficiently', () => {
+ const manyParams = Array.from({ length: 50 }, (_, i) => `param${i}=value${i}`).join('&')
+ Object.defineProperty(window, 'location', {
+ value: { search: `?${manyParams}` },
+ writable: true,
+ })
+
+ const startTime = Date.now()
+ const path = createNavigationPath('/datasets/123/documents')
+ const endTime = Date.now()
+
+ expect(endTime - startTime).toBeLessThan(50) // Should be fast
+ expect(path).toContain('param0=value0')
+ expect(path).toContain('param49=value49')
+ })
+ })
})
diff --git a/web/service/_tools_util.spec.ts b/web/service/_tools_util.spec.ts
index f06e5a1e34..658c276df1 100644
--- a/web/service/_tools_util.spec.ts
+++ b/web/service/_tools_util.spec.ts
@@ -14,3 +14,39 @@ describe('makeProviderQuery', () => {
expect(buildProviderQuery('ABC?DEF')).toBe('provider=ABC%3FDEF')
})
})
+
+describe('Tools Utilities', () => {
+ describe('buildProviderQuery', () => {
+ it('should build query string with provider parameter', () => {
+ const result = buildProviderQuery('openai')
+ expect(result).toBe('provider=openai')
+ })
+
+ it('should handle provider names with special characters', () => {
+ const result = buildProviderQuery('provider-name')
+ expect(result).toBe('provider=provider-name')
+ })
+
+ it('should handle empty string', () => {
+ const result = buildProviderQuery('')
+ expect(result).toBe('provider=')
+ })
+
+ it('should URL encode special characters', () => {
+ const result = buildProviderQuery('provider name')
+ expect(result).toBe('provider=provider+name')
+ })
+
+ it('should handle Unicode characters', () => {
+ const result = buildProviderQuery('提供者')
+ expect(result).toContain('provider=')
+ expect(decodeURIComponent(result)).toBe('provider=提供者')
+ })
+
+ it('should handle provider names with slashes', () => {
+ const result = buildProviderQuery('langgenius/openai/gpt-4')
+ expect(result).toContain('provider=')
+ expect(decodeURIComponent(result)).toBe('provider=langgenius/openai/gpt-4')
+ })
+ })
+})
diff --git a/web/utils/clipboard.spec.ts b/web/utils/clipboard.spec.ts
new file mode 100644
index 0000000000..ccdafe83f4
--- /dev/null
+++ b/web/utils/clipboard.spec.ts
@@ -0,0 +1,109 @@
+import { writeTextToClipboard } from './clipboard'
+
+describe('Clipboard Utilities', () => {
+ describe('writeTextToClipboard', () => {
+ afterEach(() => {
+ jest.restoreAllMocks()
+ })
+
+ it('should use navigator.clipboard.writeText when available', async () => {
+ const mockWriteText = jest.fn().mockResolvedValue(undefined)
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText: mockWriteText },
+ writable: true,
+ configurable: true,
+ })
+
+ await writeTextToClipboard('test text')
+ expect(mockWriteText).toHaveBeenCalledWith('test text')
+ })
+
+ it('should fallback to execCommand when clipboard API not available', async () => {
+ Object.defineProperty(navigator, 'clipboard', {
+ value: undefined,
+ writable: true,
+ configurable: true,
+ })
+
+ const mockExecCommand = jest.fn().mockReturnValue(true)
+ document.execCommand = mockExecCommand
+
+ const appendChildSpy = jest.spyOn(document.body, 'appendChild')
+ const removeChildSpy = jest.spyOn(document.body, 'removeChild')
+
+ await writeTextToClipboard('fallback text')
+
+ expect(appendChildSpy).toHaveBeenCalled()
+ expect(mockExecCommand).toHaveBeenCalledWith('copy')
+ expect(removeChildSpy).toHaveBeenCalled()
+ })
+
+ it('should handle execCommand failure', async () => {
+ Object.defineProperty(navigator, 'clipboard', {
+ value: undefined,
+ writable: true,
+ configurable: true,
+ })
+
+ const mockExecCommand = jest.fn().mockReturnValue(false)
+ document.execCommand = mockExecCommand
+
+ await expect(writeTextToClipboard('fail text')).rejects.toThrow()
+ })
+
+ it('should handle execCommand exception', async () => {
+ Object.defineProperty(navigator, 'clipboard', {
+ value: undefined,
+ writable: true,
+ configurable: true,
+ })
+
+ const mockExecCommand = jest.fn().mockImplementation(() => {
+ throw new Error('execCommand error')
+ })
+ document.execCommand = mockExecCommand
+
+ await expect(writeTextToClipboard('error text')).rejects.toThrow('execCommand error')
+ })
+
+ it('should clean up textarea after fallback', async () => {
+ Object.defineProperty(navigator, 'clipboard', {
+ value: undefined,
+ writable: true,
+ configurable: true,
+ })
+
+ document.execCommand = jest.fn().mockReturnValue(true)
+ const removeChildSpy = jest.spyOn(document.body, 'removeChild')
+
+ await writeTextToClipboard('cleanup test')
+
+ expect(removeChildSpy).toHaveBeenCalled()
+ })
+
+ it('should handle empty string', async () => {
+ const mockWriteText = jest.fn().mockResolvedValue(undefined)
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText: mockWriteText },
+ writable: true,
+ configurable: true,
+ })
+
+ await writeTextToClipboard('')
+ expect(mockWriteText).toHaveBeenCalledWith('')
+ })
+
+ it('should handle special characters', async () => {
+ const mockWriteText = jest.fn().mockResolvedValue(undefined)
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText: mockWriteText },
+ writable: true,
+ configurable: true,
+ })
+
+ const specialText = 'Test\n\t"quotes"\n中文\n😀'
+ await writeTextToClipboard(specialText)
+ expect(mockWriteText).toHaveBeenCalledWith(specialText)
+ })
+ })
+})
diff --git a/web/utils/emoji.spec.ts b/web/utils/emoji.spec.ts
new file mode 100644
index 0000000000..df9520234a
--- /dev/null
+++ b/web/utils/emoji.spec.ts
@@ -0,0 +1,77 @@
+import { searchEmoji } from './emoji'
+import { SearchIndex } from 'emoji-mart'
+
+jest.mock('emoji-mart', () => ({
+ SearchIndex: {
+ search: jest.fn(),
+ },
+}))
+
+describe('Emoji Utilities', () => {
+ describe('searchEmoji', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should return emoji natives for search results', async () => {
+ const mockEmojis = [
+ { skins: [{ native: '😀' }] },
+ { skins: [{ native: '😃' }] },
+ { skins: [{ native: '😄' }] },
+ ]
+ ;(SearchIndex.search as jest.Mock).mockResolvedValue(mockEmojis)
+
+ const result = await searchEmoji('smile')
+ expect(result).toEqual(['😀', '😃', '😄'])
+ })
+
+ it('should return empty array when no results', async () => {
+ ;(SearchIndex.search as jest.Mock).mockResolvedValue([])
+
+ const result = await searchEmoji('nonexistent')
+ expect(result).toEqual([])
+ })
+
+ it('should return empty array when search returns null', async () => {
+ ;(SearchIndex.search as jest.Mock).mockResolvedValue(null)
+
+ const result = await searchEmoji('test')
+ expect(result).toEqual([])
+ })
+
+ it('should handle search with empty string', async () => {
+ ;(SearchIndex.search as jest.Mock).mockResolvedValue([])
+
+ const result = await searchEmoji('')
+ expect(result).toEqual([])
+ expect(SearchIndex.search).toHaveBeenCalledWith('')
+ })
+
+ it('should extract native from first skin', async () => {
+ const mockEmojis = [
+ {
+ skins: [
+ { native: '👍' },
+ { native: '👍🏻' },
+ { native: '👍🏼' },
+ ],
+ },
+ ]
+ ;(SearchIndex.search as jest.Mock).mockResolvedValue(mockEmojis)
+
+ const result = await searchEmoji('thumbs')
+ expect(result).toEqual(['👍'])
+ })
+
+ it('should handle multiple search terms', async () => {
+ const mockEmojis = [
+ { skins: [{ native: '❤️' }] },
+ { skins: [{ native: '💙' }] },
+ ]
+ ;(SearchIndex.search as jest.Mock).mockResolvedValue(mockEmojis)
+
+ const result = await searchEmoji('heart love')
+ expect(result).toEqual(['❤️', '💙'])
+ })
+ })
+})
diff --git a/web/utils/format.spec.ts b/web/utils/format.spec.ts
index c94495d597..20e54fe1a4 100644
--- a/web/utils/format.spec.ts
+++ b/web/utils/format.spec.ts
@@ -1,4 +1,4 @@
-import { downloadFile, formatFileSize, formatNumber, formatTime } from './format'
+import { downloadFile, formatFileSize, formatNumber, formatNumberAbbreviated, formatTime } from './format'
describe('formatNumber', () => {
test('should correctly format integers', () => {
@@ -102,3 +102,95 @@ describe('downloadFile', () => {
jest.restoreAllMocks()
})
})
+
+describe('formatNumberAbbreviated', () => {
+ it('should return number as string when less than 1000', () => {
+ expect(formatNumberAbbreviated(0)).toBe('0')
+ expect(formatNumberAbbreviated(1)).toBe('1')
+ expect(formatNumberAbbreviated(999)).toBe('999')
+ })
+
+ it('should format thousands with k suffix', () => {
+ expect(formatNumberAbbreviated(1000)).toBe('1k')
+ expect(formatNumberAbbreviated(1200)).toBe('1.2k')
+ expect(formatNumberAbbreviated(1500)).toBe('1.5k')
+ expect(formatNumberAbbreviated(9999)).toBe('10k')
+ })
+
+ it('should format millions with M suffix', () => {
+ expect(formatNumberAbbreviated(1000000)).toBe('1M')
+ expect(formatNumberAbbreviated(1500000)).toBe('1.5M')
+ expect(formatNumberAbbreviated(2300000)).toBe('2.3M')
+ expect(formatNumberAbbreviated(999999999)).toBe('1000M')
+ })
+
+ it('should format billions with B suffix', () => {
+ expect(formatNumberAbbreviated(1000000000)).toBe('1B')
+ expect(formatNumberAbbreviated(1500000000)).toBe('1.5B')
+ expect(formatNumberAbbreviated(2300000000)).toBe('2.3B')
+ })
+
+ it('should remove .0 from whole numbers', () => {
+ expect(formatNumberAbbreviated(1000)).toBe('1k')
+ expect(formatNumberAbbreviated(2000000)).toBe('2M')
+ expect(formatNumberAbbreviated(3000000000)).toBe('3B')
+ })
+
+ it('should keep decimal for non-whole numbers', () => {
+ expect(formatNumberAbbreviated(1100)).toBe('1.1k')
+ expect(formatNumberAbbreviated(1500000)).toBe('1.5M')
+ expect(formatNumberAbbreviated(2700000000)).toBe('2.7B')
+ })
+
+ it('should handle edge cases', () => {
+ expect(formatNumberAbbreviated(950)).toBe('950')
+ expect(formatNumberAbbreviated(1001)).toBe('1k')
+ expect(formatNumberAbbreviated(999999)).toBe('1000k')
+ })
+})
+
+describe('formatNumber edge cases', () => {
+ it('should handle very large numbers', () => {
+ expect(formatNumber(1234567890123)).toBe('1,234,567,890,123')
+ })
+
+ it('should handle numbers with many decimal places', () => {
+ expect(formatNumber(1234.56789)).toBe('1,234.56789')
+ })
+
+ it('should handle negative decimals', () => {
+ expect(formatNumber(-1234.56)).toBe('-1,234.56')
+ })
+
+ it('should handle string with decimals', () => {
+ expect(formatNumber('9876543.21')).toBe('9,876,543.21')
+ })
+})
+
+describe('formatFileSize edge cases', () => {
+ it('should handle exactly 1024 bytes', () => {
+ expect(formatFileSize(1024)).toBe('1.00 KB')
+ })
+
+ it('should handle fractional bytes', () => {
+ expect(formatFileSize(512.5)).toBe('512.50 bytes')
+ })
+})
+
+describe('formatTime edge cases', () => {
+ it('should handle exactly 60 seconds', () => {
+ expect(formatTime(60)).toBe('1.00 min')
+ })
+
+ it('should handle exactly 3600 seconds', () => {
+ expect(formatTime(3600)).toBe('1.00 h')
+ })
+
+ it('should handle fractional seconds', () => {
+ expect(formatTime(45.5)).toBe('45.50 sec')
+ })
+
+ it('should handle very large durations', () => {
+ expect(formatTime(86400)).toBe('24.00 h') // 24 hours
+ })
+})
diff --git a/web/utils/index.spec.ts b/web/utils/index.spec.ts
index 21a0d80dd0..beda974e5c 100644
--- a/web/utils/index.spec.ts
+++ b/web/utils/index.spec.ts
@@ -293,3 +293,308 @@ describe('removeSpecificQueryParam', () => {
expect(replaceStateCall[2]).toMatch(/param3=value3/)
})
})
+
+describe('sleep', () => {
+ it('should resolve after specified milliseconds', async () => {
+ const start = Date.now()
+ await sleep(100)
+ const end = Date.now()
+ expect(end - start).toBeGreaterThanOrEqual(90) // Allow some tolerance
+ })
+
+ it('should handle zero milliseconds', async () => {
+ await expect(sleep(0)).resolves.toBeUndefined()
+ })
+})
+
+describe('asyncRunSafe extended', () => {
+ it('should handle promise that resolves with null', async () => {
+ const [error, result] = await asyncRunSafe(Promise.resolve(null))
+ expect(error).toBeNull()
+ expect(result).toBeNull()
+ })
+
+ it('should handle promise that resolves with undefined', async () => {
+ const [error, result] = await asyncRunSafe(Promise.resolve(undefined))
+ expect(error).toBeNull()
+ expect(result).toBeUndefined()
+ })
+
+ it('should handle promise that resolves with false', async () => {
+ const [error, result] = await asyncRunSafe(Promise.resolve(false))
+ expect(error).toBeNull()
+ expect(result).toBe(false)
+ })
+
+ it('should handle promise that resolves with 0', async () => {
+ const [error, result] = await asyncRunSafe(Promise.resolve(0))
+ expect(error).toBeNull()
+ expect(result).toBe(0)
+ })
+
+ // TODO: pre-commit blocks this test case
+ // Error msg: "Expected the Promise rejection reason to be an Error"
+
+ // it('should handle promise that rejects with null', async () => {
+ // const [error] = await asyncRunSafe(Promise.reject(null))
+ // expect(error).toBeInstanceOf(Error)
+ // expect(error?.message).toBe('unknown error')
+ // })
+})
+
+describe('getTextWidthWithCanvas', () => {
+ it('should return 0 when canvas context is not available', () => {
+ const mockGetContext = jest.fn().mockReturnValue(null)
+ jest.spyOn(document, 'createElement').mockReturnValue({
+ getContext: mockGetContext,
+ } as any)
+
+ const width = getTextWidthWithCanvas('test')
+ expect(width).toBe(0)
+
+ jest.restoreAllMocks()
+ })
+
+ it('should measure text width with custom font', () => {
+ const mockMeasureText = jest.fn().mockReturnValue({ width: 123.456 })
+ const mockContext = {
+ font: '',
+ measureText: mockMeasureText,
+ }
+ jest.spyOn(document, 'createElement').mockReturnValue({
+ getContext: jest.fn().mockReturnValue(mockContext),
+ } as any)
+
+ const width = getTextWidthWithCanvas('test', '16px Arial')
+ expect(mockContext.font).toBe('16px Arial')
+ expect(width).toBe(123.46)
+
+ jest.restoreAllMocks()
+ })
+
+ it('should handle empty string', () => {
+ const mockMeasureText = jest.fn().mockReturnValue({ width: 0 })
+ jest.spyOn(document, 'createElement').mockReturnValue({
+ getContext: jest.fn().mockReturnValue({
+ font: '',
+ measureText: mockMeasureText,
+ }),
+ } as any)
+
+ const width = getTextWidthWithCanvas('')
+ expect(width).toBe(0)
+
+ jest.restoreAllMocks()
+ })
+})
+
+describe('randomString extended', () => {
+ it('should generate string of exact length', () => {
+ expect(randomString(10).length).toBe(10)
+ expect(randomString(50).length).toBe(50)
+ expect(randomString(100).length).toBe(100)
+ })
+
+ it('should generate different strings on multiple calls', () => {
+ const str1 = randomString(20)
+ const str2 = randomString(20)
+ const str3 = randomString(20)
+ expect(str1).not.toBe(str2)
+ expect(str2).not.toBe(str3)
+ expect(str1).not.toBe(str3)
+ })
+
+ it('should only contain valid characters', () => {
+ const validChars = /^[0-9a-zA-Z_-]+$/
+ const str = randomString(100)
+ expect(validChars.test(str)).toBe(true)
+ })
+
+ it('should handle length of 1', () => {
+ const str = randomString(1)
+ expect(str.length).toBe(1)
+ })
+
+ it('should handle length of 0', () => {
+ const str = randomString(0)
+ expect(str).toBe('')
+ })
+})
+
+describe('getPurifyHref extended', () => {
+ it('should escape HTML entities', () => {
+ expect(getPurifyHref('')).not.toContain('')).toThrow('Authorization URL must be HTTP or HTTPS')
+ })
+
+ it('should reject file: protocol', () => {
+ expect(() => validateRedirectUrl('file:///etc/passwd')).toThrow('Authorization URL must be HTTP or HTTPS')
+ })
+
+ it('should reject ftp: protocol', () => {
+ expect(() => validateRedirectUrl('ftp://example.com')).toThrow('Authorization URL must be HTTP or HTTPS')
+ })
+
+ it('should reject vbscript: protocol', () => {
+ expect(() => validateRedirectUrl('vbscript:msgbox(1)')).toThrow('Authorization URL must be HTTP or HTTPS')
+ })
+
+ it('should reject malformed URLs', () => {
+ expect(() => validateRedirectUrl('not a url')).toThrow('Invalid URL')
+ expect(() => validateRedirectUrl('://example.com')).toThrow('Invalid URL')
+ expect(() => validateRedirectUrl('')).toThrow('Invalid URL')
+ })
+
+ it('should handle URLs with query parameters', () => {
+ expect(() => validateRedirectUrl('https://example.com?param=value')).not.toThrow()
+ expect(() => validateRedirectUrl('https://example.com?redirect=http://evil.com')).not.toThrow()
+ })
+
+ it('should handle URLs with fragments', () => {
+ expect(() => validateRedirectUrl('https://example.com#section')).not.toThrow()
+ expect(() => validateRedirectUrl('https://example.com/path#fragment')).not.toThrow()
+ })
+
+ it('should handle URLs with authentication', () => {
+ expect(() => validateRedirectUrl('https://user:pass@example.com')).not.toThrow()
+ })
+
+ it('should handle international domain names', () => {
+ expect(() => validateRedirectUrl('https://例え.jp')).not.toThrow()
+ })
+
+ it('should reject protocol-relative URLs', () => {
+ expect(() => validateRedirectUrl('//example.com')).toThrow('Invalid URL')
+ })
+ })
+})
diff --git a/web/utils/validators.spec.ts b/web/utils/validators.spec.ts
new file mode 100644
index 0000000000..b09955d12e
--- /dev/null
+++ b/web/utils/validators.spec.ts
@@ -0,0 +1,139 @@
+import { draft07Validator, forbidBooleanProperties } from './validators'
+
+describe('Validators', () => {
+ describe('draft07Validator', () => {
+ it('should validate a valid JSON schema', () => {
+ const validSchema = {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ age: { type: 'number' },
+ },
+ }
+ const result = draft07Validator(validSchema)
+ expect(result.valid).toBe(true)
+ expect(result.errors).toHaveLength(0)
+ })
+
+ it('should invalidate schema with unknown type', () => {
+ const invalidSchema = {
+ type: 'invalid_type',
+ }
+ const result = draft07Validator(invalidSchema)
+ expect(result.valid).toBe(false)
+ expect(result.errors.length).toBeGreaterThan(0)
+ })
+
+ it('should validate nested schemas', () => {
+ const nestedSchema = {
+ type: 'object',
+ properties: {
+ user: {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ address: {
+ type: 'object',
+ properties: {
+ street: { type: 'string' },
+ city: { type: 'string' },
+ },
+ },
+ },
+ },
+ },
+ }
+ const result = draft07Validator(nestedSchema)
+ expect(result.valid).toBe(true)
+ })
+
+ it('should validate array schemas', () => {
+ const arraySchema = {
+ type: 'array',
+ items: { type: 'string' },
+ }
+ const result = draft07Validator(arraySchema)
+ expect(result.valid).toBe(true)
+ })
+ })
+
+ describe('forbidBooleanProperties', () => {
+ it('should return empty array for schema without boolean properties', () => {
+ const schema = {
+ properties: {
+ name: { type: 'string' },
+ age: { type: 'number' },
+ },
+ }
+ const errors = forbidBooleanProperties(schema)
+ expect(errors).toHaveLength(0)
+ })
+
+ it('should detect boolean property at root level', () => {
+ const schema = {
+ properties: {
+ name: true,
+ age: { type: 'number' },
+ },
+ }
+ const errors = forbidBooleanProperties(schema)
+ expect(errors).toHaveLength(1)
+ expect(errors[0]).toContain('name')
+ })
+
+ it('should detect boolean properties in nested objects', () => {
+ const schema = {
+ properties: {
+ user: {
+ properties: {
+ name: true,
+ profile: {
+ properties: {
+ bio: false,
+ },
+ },
+ },
+ },
+ },
+ }
+ const errors = forbidBooleanProperties(schema)
+ expect(errors).toHaveLength(2)
+ expect(errors.some(e => e.includes('user.name'))).toBe(true)
+ expect(errors.some(e => e.includes('user.profile.bio'))).toBe(true)
+ })
+
+ it('should handle schema without properties', () => {
+ const schema = { type: 'string' }
+ const errors = forbidBooleanProperties(schema)
+ expect(errors).toHaveLength(0)
+ })
+
+ it('should handle null schema', () => {
+ const errors = forbidBooleanProperties(null)
+ expect(errors).toHaveLength(0)
+ })
+
+ it('should handle empty schema', () => {
+ const errors = forbidBooleanProperties({})
+ expect(errors).toHaveLength(0)
+ })
+
+ it('should provide correct path in error messages', () => {
+ const schema = {
+ properties: {
+ level1: {
+ properties: {
+ level2: {
+ properties: {
+ level3: true,
+ },
+ },
+ },
+ },
+ },
+ }
+ const errors = forbidBooleanProperties(schema)
+ expect(errors[0]).toContain('level1.level2.level3')
+ })
+ })
+})
diff --git a/web/utils/var.spec.ts b/web/utils/var.spec.ts
new file mode 100644
index 0000000000..6f55df0d34
--- /dev/null
+++ b/web/utils/var.spec.ts
@@ -0,0 +1,236 @@
+import {
+ checkKey,
+ checkKeys,
+ getMarketplaceUrl,
+ getNewVar,
+ getNewVarInWorkflow,
+ getVars,
+ hasDuplicateStr,
+ replaceSpaceWithUnderscoreInVarNameInput,
+} from './var'
+import { InputVarType } from '@/app/components/workflow/types'
+
+describe('Variable Utilities', () => {
+ describe('checkKey', () => {
+ it('should return error for empty key when canBeEmpty is false', () => {
+ expect(checkKey('', false)).toBe('canNoBeEmpty')
+ })
+
+ it('should return true for empty key when canBeEmpty is true', () => {
+ expect(checkKey('', true)).toBe(true)
+ })
+
+ it('should return error for key that is too long', () => {
+ const longKey = 'a'.repeat(101) // Assuming MAX_VAR_KEY_LENGTH is 100
+ expect(checkKey(longKey)).toBe('tooLong')
+ })
+
+ it('should return error for key starting with number', () => {
+ expect(checkKey('1variable')).toBe('notStartWithNumber')
+ })
+
+ it('should return true for valid key', () => {
+ expect(checkKey('valid_variable_name')).toBe(true)
+ expect(checkKey('validVariableName')).toBe(true)
+ expect(checkKey('valid123')).toBe(true)
+ })
+
+ it('should return error for invalid characters', () => {
+ expect(checkKey('invalid-key')).toBe('notValid')
+ expect(checkKey('invalid key')).toBe('notValid')
+ expect(checkKey('invalid.key')).toBe('notValid')
+ expect(checkKey('invalid@key')).toBe('notValid')
+ })
+
+ it('should handle underscore correctly', () => {
+ expect(checkKey('_valid')).toBe(true)
+ expect(checkKey('valid_name')).toBe(true)
+ expect(checkKey('valid_name_123')).toBe(true)
+ })
+ })
+
+ describe('checkKeys', () => {
+ it('should return valid for all valid keys', () => {
+ const result = checkKeys(['key1', 'key2', 'validKey'])
+ expect(result.isValid).toBe(true)
+ expect(result.errorKey).toBe('')
+ expect(result.errorMessageKey).toBe('')
+ })
+
+ it('should return error for first invalid key', () => {
+ const result = checkKeys(['validKey', '1invalid', 'anotherValid'])
+ expect(result.isValid).toBe(false)
+ expect(result.errorKey).toBe('1invalid')
+ expect(result.errorMessageKey).toBe('notStartWithNumber')
+ })
+
+ it('should handle empty array', () => {
+ const result = checkKeys([])
+ expect(result.isValid).toBe(true)
+ })
+
+ it('should stop checking after first error', () => {
+ const result = checkKeys(['valid', 'invalid-key', '1invalid'])
+ expect(result.isValid).toBe(false)
+ expect(result.errorKey).toBe('invalid-key')
+ expect(result.errorMessageKey).toBe('notValid')
+ })
+ })
+
+ describe('hasDuplicateStr', () => {
+ it('should return false for unique strings', () => {
+ expect(hasDuplicateStr(['a', 'b', 'c'])).toBe(false)
+ })
+
+ it('should return true for duplicate strings', () => {
+ expect(hasDuplicateStr(['a', 'b', 'a'])).toBe(true)
+ expect(hasDuplicateStr(['test', 'test'])).toBe(true)
+ })
+
+ it('should handle empty array', () => {
+ expect(hasDuplicateStr([])).toBe(false)
+ })
+
+ it('should handle single element', () => {
+ expect(hasDuplicateStr(['single'])).toBe(false)
+ })
+
+ it('should handle multiple duplicates', () => {
+ expect(hasDuplicateStr(['a', 'b', 'a', 'b', 'c'])).toBe(true)
+ })
+ })
+
+ describe('getVars', () => {
+ it('should extract variables from template string', () => {
+ const result = getVars('Hello {{name}}, your age is {{age}}')
+ expect(result).toEqual(['name', 'age'])
+ })
+
+ it('should handle empty string', () => {
+ expect(getVars('')).toEqual([])
+ })
+
+ it('should handle string without variables', () => {
+ expect(getVars('Hello world')).toEqual([])
+ })
+
+ it('should remove duplicate variables', () => {
+ const result = getVars('{{name}} and {{name}} again')
+ expect(result).toEqual(['name'])
+ })
+
+ it('should filter out placeholder variables', () => {
+ const result = getVars('{{#context#}} {{name}} {{#histories#}}')
+ expect(result).toEqual(['name'])
+ })
+
+ it('should handle variables with underscores', () => {
+ const result = getVars('{{user_name}} {{user_age}}')
+ expect(result).toEqual(['user_name', 'user_age'])
+ })
+
+ it('should handle variables with numbers', () => {
+ const result = getVars('{{var1}} {{var2}} {{var123}}')
+ expect(result).toEqual(['var1', 'var2', 'var123'])
+ })
+
+ it('should ignore invalid variable names', () => {
+ const result = getVars('{{1invalid}} {{valid}} {{-invalid}}')
+ expect(result).toEqual(['valid'])
+ })
+
+ it('should filter out variables that are too long', () => {
+ const longVar = 'a'.repeat(101)
+ const result = getVars(`{{${longVar}}} {{valid}}`)
+ expect(result).toEqual(['valid'])
+ })
+ })
+
+ describe('getNewVar', () => {
+ it('should create new string variable', () => {
+ const result = getNewVar('testKey', 'string')
+ expect(result.key).toBe('testKey')
+ expect(result.type).toBe('string')
+ expect(result.name).toBe('testKey')
+ })
+
+ it('should create new number variable', () => {
+ const result = getNewVar('numKey', 'number')
+ expect(result.key).toBe('numKey')
+ expect(result.type).toBe('number')
+ })
+
+ it('should truncate long names', () => {
+ const longKey = 'a'.repeat(100)
+ const result = getNewVar(longKey, 'string')
+ expect(result.name.length).toBeLessThanOrEqual(result.key.length)
+ })
+ })
+
+ describe('getNewVarInWorkflow', () => {
+ it('should create text input variable by default', () => {
+ const result = getNewVarInWorkflow('testVar')
+ expect(result.variable).toBe('testVar')
+ expect(result.type).toBe(InputVarType.textInput)
+ expect(result.label).toBe('testVar')
+ })
+
+ it('should create select variable', () => {
+ const result = getNewVarInWorkflow('selectVar', InputVarType.select)
+ expect(result.variable).toBe('selectVar')
+ expect(result.type).toBe(InputVarType.select)
+ })
+
+ it('should create number variable', () => {
+ const result = getNewVarInWorkflow('numVar', InputVarType.number)
+ expect(result.variable).toBe('numVar')
+ expect(result.type).toBe(InputVarType.number)
+ })
+ })
+
+ describe('getMarketplaceUrl', () => {
+ beforeEach(() => {
+ Object.defineProperty(window, 'location', {
+ value: { origin: 'https://example.com' },
+ writable: true,
+ })
+ })
+
+ it('should add additional parameters', () => {
+ const url = getMarketplaceUrl('/plugins', { category: 'ai', version: '1.0' })
+ expect(url).toContain('category=ai')
+ expect(url).toContain('version=1.0')
+ })
+
+ it('should skip undefined parameters', () => {
+ const url = getMarketplaceUrl('/plugins', { category: 'ai', version: undefined })
+ expect(url).toContain('category=ai')
+ expect(url).not.toContain('version=')
+ })
+ })
+
+ describe('replaceSpaceWithUnderscoreInVarNameInput', () => {
+ it('should replace spaces with underscores', () => {
+ const input = document.createElement('input')
+ input.value = 'test variable name'
+ replaceSpaceWithUnderscoreInVarNameInput(input)
+ expect(input.value).toBe('test_variable_name')
+ })
+
+ it('should preserve cursor position', () => {
+ const input = document.createElement('input')
+ input.value = 'test name'
+ input.setSelectionRange(5, 5)
+ replaceSpaceWithUnderscoreInVarNameInput(input)
+ expect(input.selectionStart).toBe(5)
+ expect(input.selectionEnd).toBe(5)
+ })
+
+ it('should handle multiple spaces', () => {
+ const input = document.createElement('input')
+ input.value = 'test multiple spaces'
+ replaceSpaceWithUnderscoreInVarNameInput(input)
+ expect(input.value).toBe('test__multiple___spaces')
+ })
+ })
+})