From 9b524af61734cfce1efde3e1f6ab6d1555832bc2 Mon Sep 17 00:00:00 2001 From: aka James4u Date: Tue, 4 Nov 2025 05:06:44 -0800 Subject: [PATCH] test: adding some web tests (#27792) --- web/__tests__/check-i18n.test.ts | 100 ++++++++ web/__tests__/navigation-utils.test.ts | 112 +++++++++ web/service/_tools_util.spec.ts | 36 +++ web/utils/clipboard.spec.ts | 109 +++++++++ web/utils/emoji.spec.ts | 77 +++++++ web/utils/format.spec.ts | 94 +++++++- web/utils/index.spec.ts | 305 +++++++++++++++++++++++++ web/utils/urlValidation.spec.ts | 49 ++++ web/utils/validators.spec.ts | 139 +++++++++++ web/utils/var.spec.ts | 236 +++++++++++++++++++ 10 files changed, 1256 insertions(+), 1 deletion(-) create mode 100644 web/utils/clipboard.spec.ts create mode 100644 web/utils/emoji.spec.ts create mode 100644 web/utils/urlValidation.spec.ts create mode 100644 web/utils/validators.spec.ts create mode 100644 web/utils/var.spec.ts 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') + }) + }) +})