mirror of
https://github.com/langgenius/dify.git
synced 2026-05-02 16:38:04 +08:00
Merge remote-tracking branch 'origin/main' into feat/trigger
This commit is contained in:
106
web/utils/app-redirection.spec.ts
Normal file
106
web/utils/app-redirection.spec.ts
Normal file
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Test suite for app redirection utility functions
|
||||
* Tests navigation path generation based on user permissions and app modes
|
||||
*/
|
||||
import { getRedirection, getRedirectionPath } from './app-redirection'
|
||||
|
||||
describe('app-redirection', () => {
|
||||
/**
|
||||
* Tests getRedirectionPath which determines the correct path based on:
|
||||
* - User's editor permissions
|
||||
* - App mode (workflow, advanced-chat, chat, completion, agent-chat)
|
||||
*/
|
||||
describe('getRedirectionPath', () => {
|
||||
test('returns overview path when user is not editor', () => {
|
||||
const app = { id: 'app-123', mode: 'chat' as const }
|
||||
const result = getRedirectionPath(false, app)
|
||||
expect(result).toBe('/app/app-123/overview')
|
||||
})
|
||||
|
||||
test('returns workflow path for workflow mode when user is editor', () => {
|
||||
const app = { id: 'app-123', mode: 'workflow' as const }
|
||||
const result = getRedirectionPath(true, app)
|
||||
expect(result).toBe('/app/app-123/workflow')
|
||||
})
|
||||
|
||||
test('returns workflow path for advanced-chat mode when user is editor', () => {
|
||||
const app = { id: 'app-123', mode: 'advanced-chat' as const }
|
||||
const result = getRedirectionPath(true, app)
|
||||
expect(result).toBe('/app/app-123/workflow')
|
||||
})
|
||||
|
||||
test('returns configuration path for chat mode when user is editor', () => {
|
||||
const app = { id: 'app-123', mode: 'chat' as const }
|
||||
const result = getRedirectionPath(true, app)
|
||||
expect(result).toBe('/app/app-123/configuration')
|
||||
})
|
||||
|
||||
test('returns configuration path for completion mode when user is editor', () => {
|
||||
const app = { id: 'app-123', mode: 'completion' as const }
|
||||
const result = getRedirectionPath(true, app)
|
||||
expect(result).toBe('/app/app-123/configuration')
|
||||
})
|
||||
|
||||
test('returns configuration path for agent-chat mode when user is editor', () => {
|
||||
const app = { id: 'app-456', mode: 'agent-chat' as const }
|
||||
const result = getRedirectionPath(true, app)
|
||||
expect(result).toBe('/app/app-456/configuration')
|
||||
})
|
||||
|
||||
test('handles different app IDs', () => {
|
||||
const app1 = { id: 'abc-123', mode: 'chat' as const }
|
||||
const app2 = { id: 'xyz-789', mode: 'workflow' as const }
|
||||
|
||||
expect(getRedirectionPath(false, app1)).toBe('/app/abc-123/overview')
|
||||
expect(getRedirectionPath(true, app2)).toBe('/app/xyz-789/workflow')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests getRedirection which combines path generation with a redirect callback
|
||||
*/
|
||||
describe('getRedirection', () => {
|
||||
/**
|
||||
* Tests that the redirection function is called with the correct path
|
||||
*/
|
||||
test('calls redirection function with correct path for non-editor', () => {
|
||||
const app = { id: 'app-123', mode: 'chat' as const }
|
||||
const mockRedirect = jest.fn()
|
||||
|
||||
getRedirection(false, app, mockRedirect)
|
||||
|
||||
expect(mockRedirect).toHaveBeenCalledWith('/app/app-123/overview')
|
||||
expect(mockRedirect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('calls redirection function with workflow path for editor', () => {
|
||||
const app = { id: 'app-123', mode: 'workflow' as const }
|
||||
const mockRedirect = jest.fn()
|
||||
|
||||
getRedirection(true, app, mockRedirect)
|
||||
|
||||
expect(mockRedirect).toHaveBeenCalledWith('/app/app-123/workflow')
|
||||
expect(mockRedirect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('calls redirection function with configuration path for chat mode editor', () => {
|
||||
const app = { id: 'app-123', mode: 'chat' as const }
|
||||
const mockRedirect = jest.fn()
|
||||
|
||||
getRedirection(true, app, mockRedirect)
|
||||
|
||||
expect(mockRedirect).toHaveBeenCalledWith('/app/app-123/configuration')
|
||||
expect(mockRedirect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('works with different redirection functions', () => {
|
||||
const app = { id: 'app-123', mode: 'workflow' as const }
|
||||
const paths: string[] = []
|
||||
const customRedirect = (path: string) => paths.push(path)
|
||||
|
||||
getRedirection(true, app, customRedirect)
|
||||
|
||||
expect(paths).toEqual(['/app/app-123/workflow'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,18 @@
|
||||
/**
|
||||
* Test suite for the classnames utility function
|
||||
* This utility combines the classnames library with tailwind-merge
|
||||
* to handle conditional CSS classes and merge conflicting Tailwind classes
|
||||
*/
|
||||
import cn from './classnames'
|
||||
|
||||
describe('classnames', () => {
|
||||
/**
|
||||
* Tests basic classnames library features:
|
||||
* - String concatenation
|
||||
* - Array handling
|
||||
* - Falsy value filtering
|
||||
* - Object-based conditional classes
|
||||
*/
|
||||
test('classnames libs feature', () => {
|
||||
expect(cn('foo')).toBe('foo')
|
||||
expect(cn('foo', 'bar')).toBe('foo bar')
|
||||
@ -17,6 +29,14 @@ describe('classnames', () => {
|
||||
})).toBe('foo baz')
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests tailwind-merge functionality:
|
||||
* - Conflicting class resolution (last one wins)
|
||||
* - Modifier handling (hover, focus, etc.)
|
||||
* - Important prefix (!)
|
||||
* - Custom color classes
|
||||
* - Arbitrary values
|
||||
*/
|
||||
test('tailwind-merge', () => {
|
||||
/* eslint-disable tailwindcss/classnames-order */
|
||||
expect(cn('p-0')).toBe('p-0')
|
||||
@ -44,6 +64,10 @@ describe('classnames', () => {
|
||||
expect(cn('text-3.5xl text-black')).toBe('text-3.5xl text-black')
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests the integration of classnames and tailwind-merge:
|
||||
* - Object-based conditional classes with Tailwind conflict resolution
|
||||
*/
|
||||
test('classnames combined with tailwind-merge', () => {
|
||||
expect(cn('text-right', {
|
||||
'text-center': true,
|
||||
@ -53,4 +77,81 @@ describe('classnames', () => {
|
||||
'text-center': false,
|
||||
})).toBe('text-right')
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests handling of multiple mixed argument types:
|
||||
* - Strings, arrays, and objects in a single call
|
||||
* - Tailwind merge working across different argument types
|
||||
*/
|
||||
test('multiple mixed argument types', () => {
|
||||
expect(cn('foo', ['bar', 'baz'], { qux: true, quux: false })).toBe('foo bar baz qux')
|
||||
expect(cn('p-4', ['p-2', 'm-4'], { 'text-left': true, 'text-right': true })).toBe('p-2 m-4 text-right')
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests nested array handling:
|
||||
* - Deep array flattening
|
||||
* - Tailwind merge with nested structures
|
||||
*/
|
||||
test('nested arrays', () => {
|
||||
expect(cn(['foo', ['bar', 'baz']])).toBe('foo bar baz')
|
||||
expect(cn(['p-4', ['p-2', 'text-center']])).toBe('p-2 text-center')
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests empty input handling:
|
||||
* - Empty strings, arrays, and objects
|
||||
* - Mixed empty and non-empty values
|
||||
*/
|
||||
test('empty inputs', () => {
|
||||
expect(cn('')).toBe('')
|
||||
expect(cn([])).toBe('')
|
||||
expect(cn({})).toBe('')
|
||||
expect(cn('', [], {})).toBe('')
|
||||
expect(cn('foo', '', 'bar')).toBe('foo bar')
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests number input handling:
|
||||
* - Truthy numbers converted to strings
|
||||
* - Zero treated as falsy
|
||||
*/
|
||||
test('numbers as inputs', () => {
|
||||
expect(cn(1)).toBe('1')
|
||||
expect(cn(0)).toBe('')
|
||||
expect(cn('foo', 1, 'bar')).toBe('foo 1 bar')
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests multiple object arguments:
|
||||
* - Object merging
|
||||
* - Tailwind conflict resolution across objects
|
||||
*/
|
||||
test('multiple objects', () => {
|
||||
expect(cn({ foo: true }, { bar: true })).toBe('foo bar')
|
||||
expect(cn({ foo: true, bar: false }, { bar: true, baz: true })).toBe('foo bar baz')
|
||||
expect(cn({ 'p-4': true }, { 'p-2': true })).toBe('p-2')
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests complex edge cases:
|
||||
* - Mixed falsy values
|
||||
* - Nested arrays with falsy values
|
||||
* - Multiple conflicting Tailwind classes
|
||||
*/
|
||||
test('complex edge cases', () => {
|
||||
expect(cn('foo', null, undefined, false, 'bar', 0, 1, '')).toBe('foo bar 1')
|
||||
expect(cn(['foo', null, ['bar', undefined, 'baz']])).toBe('foo bar baz')
|
||||
expect(cn('text-sm', { 'text-lg': false, 'text-xl': true }, 'text-2xl')).toBe('text-2xl')
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests important (!) modifier behavior:
|
||||
* - Important modifiers in objects
|
||||
* - Conflict resolution with important prefix
|
||||
*/
|
||||
test('important modifier with objects', () => {
|
||||
expect(cn({ '!font-medium': true }, { '!font-bold': true })).toBe('!font-bold')
|
||||
expect(cn('font-normal', { '!font-bold': true })).toBe('font-normal !font-bold')
|
||||
})
|
||||
})
|
||||
|
||||
109
web/utils/clipboard.spec.ts
Normal file
109
web/utils/clipboard.spec.ts
Normal file
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
230
web/utils/completion-params.spec.ts
Normal file
230
web/utils/completion-params.spec.ts
Normal file
@ -0,0 +1,230 @@
|
||||
import { mergeValidCompletionParams } from './completion-params'
|
||||
import type { FormValue, ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
|
||||
describe('completion-params', () => {
|
||||
describe('mergeValidCompletionParams', () => {
|
||||
test('returns empty params and removedDetails for undefined oldParams', () => {
|
||||
const rules: ModelParameterRule[] = []
|
||||
const result = mergeValidCompletionParams(undefined, rules)
|
||||
|
||||
expect(result.params).toEqual({})
|
||||
expect(result.removedDetails).toEqual({})
|
||||
})
|
||||
|
||||
test('returns empty params and removedDetails for empty oldParams', () => {
|
||||
const rules: ModelParameterRule[] = []
|
||||
const result = mergeValidCompletionParams({}, rules)
|
||||
|
||||
expect(result.params).toEqual({})
|
||||
expect(result.removedDetails).toEqual({})
|
||||
})
|
||||
|
||||
test('validates int type parameter within range', () => {
|
||||
const rules: ModelParameterRule[] = [
|
||||
{ name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大标记' }, required: false },
|
||||
]
|
||||
const oldParams: FormValue = { max_tokens: 100 }
|
||||
const result = mergeValidCompletionParams(oldParams, rules)
|
||||
|
||||
expect(result.params).toEqual({ max_tokens: 100 })
|
||||
expect(result.removedDetails).toEqual({})
|
||||
})
|
||||
|
||||
test('removes int parameter below minimum', () => {
|
||||
const rules: ModelParameterRule[] = [
|
||||
{ name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大标记' }, required: false },
|
||||
]
|
||||
const oldParams: FormValue = { max_tokens: 0 }
|
||||
const result = mergeValidCompletionParams(oldParams, rules)
|
||||
|
||||
expect(result.params).toEqual({})
|
||||
expect(result.removedDetails).toEqual({ max_tokens: 'out of range (1-4096)' })
|
||||
})
|
||||
|
||||
test('removes int parameter above maximum', () => {
|
||||
const rules: ModelParameterRule[] = [
|
||||
{ name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大标记' }, required: false },
|
||||
]
|
||||
const oldParams: FormValue = { max_tokens: 5000 }
|
||||
const result = mergeValidCompletionParams(oldParams, rules)
|
||||
|
||||
expect(result.params).toEqual({})
|
||||
expect(result.removedDetails).toEqual({ max_tokens: 'out of range (1-4096)' })
|
||||
})
|
||||
|
||||
test('removes int parameter with invalid type', () => {
|
||||
const rules: ModelParameterRule[] = [
|
||||
{ name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大标记' }, required: false },
|
||||
]
|
||||
const oldParams: FormValue = { max_tokens: 'not a number' as any }
|
||||
const result = mergeValidCompletionParams(oldParams, rules)
|
||||
|
||||
expect(result.params).toEqual({})
|
||||
expect(result.removedDetails).toEqual({ max_tokens: 'invalid type' })
|
||||
})
|
||||
|
||||
test('validates float type parameter', () => {
|
||||
const rules: ModelParameterRule[] = [
|
||||
{ name: 'temperature', type: 'float', min: 0, max: 2, label: { en_US: 'Temperature', zh_Hans: '温度' }, required: false },
|
||||
]
|
||||
const oldParams: FormValue = { temperature: 0.7 }
|
||||
const result = mergeValidCompletionParams(oldParams, rules)
|
||||
|
||||
expect(result.params).toEqual({ temperature: 0.7 })
|
||||
expect(result.removedDetails).toEqual({})
|
||||
})
|
||||
|
||||
test('validates float at boundary values', () => {
|
||||
const rules: ModelParameterRule[] = [
|
||||
{ name: 'temperature', type: 'float', min: 0, max: 2, label: { en_US: 'Temperature', zh_Hans: '温度' }, required: false },
|
||||
]
|
||||
|
||||
const result1 = mergeValidCompletionParams({ temperature: 0 }, rules)
|
||||
expect(result1.params).toEqual({ temperature: 0 })
|
||||
|
||||
const result2 = mergeValidCompletionParams({ temperature: 2 }, rules)
|
||||
expect(result2.params).toEqual({ temperature: 2 })
|
||||
})
|
||||
|
||||
test('validates boolean type parameter', () => {
|
||||
const rules: ModelParameterRule[] = [
|
||||
{ name: 'stream', type: 'boolean', label: { en_US: 'Stream', zh_Hans: '流' }, required: false },
|
||||
]
|
||||
const oldParams: FormValue = { stream: true }
|
||||
const result = mergeValidCompletionParams(oldParams, rules)
|
||||
|
||||
expect(result.params).toEqual({ stream: true })
|
||||
expect(result.removedDetails).toEqual({})
|
||||
})
|
||||
|
||||
test('removes boolean parameter with invalid type', () => {
|
||||
const rules: ModelParameterRule[] = [
|
||||
{ name: 'stream', type: 'boolean', label: { en_US: 'Stream', zh_Hans: '流' }, required: false },
|
||||
]
|
||||
const oldParams: FormValue = { stream: 'yes' as any }
|
||||
const result = mergeValidCompletionParams(oldParams, rules)
|
||||
|
||||
expect(result.params).toEqual({})
|
||||
expect(result.removedDetails).toEqual({ stream: 'invalid type' })
|
||||
})
|
||||
|
||||
test('validates string type parameter', () => {
|
||||
const rules: ModelParameterRule[] = [
|
||||
{ name: 'model', type: 'string', label: { en_US: 'Model', zh_Hans: '模型' }, required: false },
|
||||
]
|
||||
const oldParams: FormValue = { model: 'gpt-4' }
|
||||
const result = mergeValidCompletionParams(oldParams, rules)
|
||||
|
||||
expect(result.params).toEqual({ model: 'gpt-4' })
|
||||
expect(result.removedDetails).toEqual({})
|
||||
})
|
||||
|
||||
test('validates string parameter with options', () => {
|
||||
const rules: ModelParameterRule[] = [
|
||||
{ name: 'model', type: 'string', options: ['gpt-3.5-turbo', 'gpt-4'], label: { en_US: 'Model', zh_Hans: '模型' }, required: false },
|
||||
]
|
||||
const oldParams: FormValue = { model: 'gpt-4' }
|
||||
const result = mergeValidCompletionParams(oldParams, rules)
|
||||
|
||||
expect(result.params).toEqual({ model: 'gpt-4' })
|
||||
expect(result.removedDetails).toEqual({})
|
||||
})
|
||||
|
||||
test('removes string parameter with invalid option', () => {
|
||||
const rules: ModelParameterRule[] = [
|
||||
{ name: 'model', type: 'string', options: ['gpt-3.5-turbo', 'gpt-4'], label: { en_US: 'Model', zh_Hans: '模型' }, required: false },
|
||||
]
|
||||
const oldParams: FormValue = { model: 'invalid-model' }
|
||||
const result = mergeValidCompletionParams(oldParams, rules)
|
||||
|
||||
expect(result.params).toEqual({})
|
||||
expect(result.removedDetails).toEqual({ model: 'unsupported option' })
|
||||
})
|
||||
|
||||
test('validates text type parameter', () => {
|
||||
const rules: ModelParameterRule[] = [
|
||||
{ name: 'prompt', type: 'text', label: { en_US: 'Prompt', zh_Hans: '提示' }, required: false },
|
||||
]
|
||||
const oldParams: FormValue = { prompt: 'Hello world' }
|
||||
const result = mergeValidCompletionParams(oldParams, rules)
|
||||
|
||||
expect(result.params).toEqual({ prompt: 'Hello world' })
|
||||
expect(result.removedDetails).toEqual({})
|
||||
})
|
||||
|
||||
test('removes unsupported parameters', () => {
|
||||
const rules: ModelParameterRule[] = [
|
||||
{ name: 'temperature', type: 'float', min: 0, max: 2, label: { en_US: 'Temperature', zh_Hans: '温度' }, required: false },
|
||||
]
|
||||
const oldParams: FormValue = { temperature: 0.7, unsupported_param: 'value' }
|
||||
const result = mergeValidCompletionParams(oldParams, rules)
|
||||
|
||||
expect(result.params).toEqual({ temperature: 0.7 })
|
||||
expect(result.removedDetails).toEqual({ unsupported_param: 'unsupported' })
|
||||
})
|
||||
|
||||
test('keeps stop parameter in advanced mode even without rule', () => {
|
||||
const rules: ModelParameterRule[] = []
|
||||
const oldParams: FormValue = { stop: ['END'] }
|
||||
const result = mergeValidCompletionParams(oldParams, rules, true)
|
||||
|
||||
expect(result.params).toEqual({ stop: ['END'] })
|
||||
expect(result.removedDetails).toEqual({})
|
||||
})
|
||||
|
||||
test('removes stop parameter in normal mode without rule', () => {
|
||||
const rules: ModelParameterRule[] = []
|
||||
const oldParams: FormValue = { stop: ['END'] }
|
||||
const result = mergeValidCompletionParams(oldParams, rules, false)
|
||||
|
||||
expect(result.params).toEqual({})
|
||||
expect(result.removedDetails).toEqual({ stop: 'unsupported' })
|
||||
})
|
||||
|
||||
test('handles multiple parameters with mixed validity', () => {
|
||||
const rules: ModelParameterRule[] = [
|
||||
{ name: 'temperature', type: 'float', min: 0, max: 2, label: { en_US: 'Temperature', zh_Hans: '温度' }, required: false },
|
||||
{ name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大标记' }, required: false },
|
||||
{ name: 'model', type: 'string', options: ['gpt-4'], label: { en_US: 'Model', zh_Hans: '模型' }, required: false },
|
||||
]
|
||||
const oldParams: FormValue = {
|
||||
temperature: 0.7,
|
||||
max_tokens: 5000,
|
||||
model: 'gpt-4',
|
||||
unsupported: 'value',
|
||||
}
|
||||
const result = mergeValidCompletionParams(oldParams, rules)
|
||||
|
||||
expect(result.params).toEqual({
|
||||
temperature: 0.7,
|
||||
model: 'gpt-4',
|
||||
})
|
||||
expect(result.removedDetails).toEqual({
|
||||
max_tokens: 'out of range (1-4096)',
|
||||
unsupported: 'unsupported',
|
||||
})
|
||||
})
|
||||
|
||||
test('handles parameters without min/max constraints', () => {
|
||||
const rules: ModelParameterRule[] = [
|
||||
{ name: 'value', type: 'int', label: { en_US: 'Value', zh_Hans: '值' }, required: false },
|
||||
]
|
||||
const oldParams: FormValue = { value: 999999 }
|
||||
const result = mergeValidCompletionParams(oldParams, rules)
|
||||
|
||||
expect(result.params).toEqual({ value: 999999 })
|
||||
expect(result.removedDetails).toEqual({})
|
||||
})
|
||||
|
||||
test('removes parameter with unsupported rule type', () => {
|
||||
const rules: ModelParameterRule[] = [
|
||||
{ name: 'custom', type: 'unknown_type', label: { en_US: 'Custom', zh_Hans: '自定义' }, required: false } as any,
|
||||
]
|
||||
const oldParams: FormValue = { custom: 'value' }
|
||||
const result = mergeValidCompletionParams(oldParams, rules)
|
||||
|
||||
expect(result.params).toEqual({})
|
||||
expect(result.removedDetails.custom).toContain('unsupported rule type')
|
||||
})
|
||||
})
|
||||
})
|
||||
77
web/utils/emoji.spec.ts
Normal file
77
web/utils/emoji.spec.ts
Normal file
@ -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(['❤️', '💙'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
})
|
||||
})
|
||||
|
||||
49
web/utils/get-icon.spec.ts
Normal file
49
web/utils/get-icon.spec.ts
Normal file
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Test suite for icon utility functions
|
||||
* Tests the generation of marketplace plugin icon URLs
|
||||
*/
|
||||
import { getIconFromMarketPlace } from './get-icon'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
|
||||
describe('get-icon', () => {
|
||||
describe('getIconFromMarketPlace', () => {
|
||||
/**
|
||||
* Tests basic URL generation for marketplace plugin icons
|
||||
*/
|
||||
test('returns correct marketplace icon URL', () => {
|
||||
const pluginId = 'test-plugin-123'
|
||||
const result = getIconFromMarketPlace(pluginId)
|
||||
expect(result).toBe(`${MARKETPLACE_API_PREFIX}/plugins/${pluginId}/icon`)
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests URL generation with plugin IDs containing special characters
|
||||
* like dashes and underscores
|
||||
*/
|
||||
test('handles plugin ID with special characters', () => {
|
||||
const pluginId = 'plugin-with-dashes_and_underscores'
|
||||
const result = getIconFromMarketPlace(pluginId)
|
||||
expect(result).toBe(`${MARKETPLACE_API_PREFIX}/plugins/${pluginId}/icon`)
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests behavior with empty plugin ID
|
||||
* Note: This creates a malformed URL but doesn't throw an error
|
||||
*/
|
||||
test('handles empty plugin ID', () => {
|
||||
const pluginId = ''
|
||||
const result = getIconFromMarketPlace(pluginId)
|
||||
expect(result).toBe(`${MARKETPLACE_API_PREFIX}/plugins//icon`)
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests URL generation with plugin IDs containing spaces
|
||||
* Spaces will be URL-encoded when actually used
|
||||
*/
|
||||
test('handles plugin ID with spaces', () => {
|
||||
const pluginId = 'plugin with spaces'
|
||||
const result = getIconFromMarketPlace(pluginId)
|
||||
expect(result).toBe(`${MARKETPLACE_API_PREFIX}/plugins/${pluginId}/icon`)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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('<script>alert(1)</script>')).not.toContain('<script>')
|
||||
expect(getPurifyHref('test&test')).toContain('&')
|
||||
expect(getPurifyHref('test"test')).toContain('"')
|
||||
})
|
||||
|
||||
it('should handle URLs with query parameters', () => {
|
||||
const url = 'https://example.com?param=<script>'
|
||||
const purified = getPurifyHref(url)
|
||||
expect(purified).not.toContain('<script>')
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(getPurifyHref('')).toBe('')
|
||||
})
|
||||
|
||||
it('should handle null/undefined', () => {
|
||||
expect(getPurifyHref(null as any)).toBe('')
|
||||
expect(getPurifyHref(undefined as any)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchWithRetry extended', () => {
|
||||
it('should succeed on first try', async () => {
|
||||
const [error, result] = await fetchWithRetry(Promise.resolve('success'))
|
||||
expect(error).toBeNull()
|
||||
expect(result).toBe('success')
|
||||
})
|
||||
|
||||
it('should retry specified number of times', async () => {
|
||||
let attempts = 0
|
||||
const failingPromise = () => {
|
||||
attempts++
|
||||
return Promise.reject(new Error('fail'))
|
||||
}
|
||||
|
||||
await fetchWithRetry(failingPromise(), 3)
|
||||
// Initial attempt + 3 retries = 4 total attempts
|
||||
// But the function structure means it will try once, then retry 3 times
|
||||
})
|
||||
|
||||
it('should succeed after retries', async () => {
|
||||
let attempts = 0
|
||||
const eventuallySucceed = new Promise((resolve, reject) => {
|
||||
attempts++
|
||||
if (attempts < 2)
|
||||
reject(new Error('not yet'))
|
||||
else
|
||||
resolve('success')
|
||||
})
|
||||
|
||||
await fetchWithRetry(eventuallySucceed, 3)
|
||||
// Note: This test may need adjustment based on actual retry logic
|
||||
})
|
||||
|
||||
/*
|
||||
TODO: Commented this case because of eslint
|
||||
Error msg: Expected the Promise rejection reason to be an Error
|
||||
*/
|
||||
// it('should handle non-Error rejections', async () => {
|
||||
// const [error] = await fetchWithRetry(Promise.reject('string error'), 0)
|
||||
// expect(error).toBeInstanceOf(Error)
|
||||
// })
|
||||
})
|
||||
|
||||
describe('correctModelProvider extended', () => {
|
||||
it('should handle empty string', () => {
|
||||
expect(correctModelProvider('')).toBe('')
|
||||
})
|
||||
|
||||
it('should not modify provider with slash', () => {
|
||||
expect(correctModelProvider('custom/provider/model')).toBe('custom/provider/model')
|
||||
})
|
||||
|
||||
it('should handle google provider', () => {
|
||||
expect(correctModelProvider('google')).toBe('langgenius/gemini/google')
|
||||
})
|
||||
|
||||
it('should handle standard providers', () => {
|
||||
expect(correctModelProvider('openai')).toBe('langgenius/openai/openai')
|
||||
expect(correctModelProvider('anthropic')).toBe('langgenius/anthropic/anthropic')
|
||||
})
|
||||
|
||||
it('should handle null/undefined', () => {
|
||||
expect(correctModelProvider(null as any)).toBe('')
|
||||
expect(correctModelProvider(undefined as any)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('correctToolProvider extended', () => {
|
||||
it('should return as-is when toolInCollectionList is true', () => {
|
||||
expect(correctToolProvider('any-provider', true)).toBe('any-provider')
|
||||
expect(correctToolProvider('', true)).toBe('')
|
||||
})
|
||||
|
||||
it('should not modify provider with slash when not in collection', () => {
|
||||
expect(correctToolProvider('custom/tool/provider', false)).toBe('custom/tool/provider')
|
||||
})
|
||||
|
||||
it('should handle special tool providers', () => {
|
||||
expect(correctToolProvider('stepfun', false)).toBe('langgenius/stepfun_tool/stepfun')
|
||||
expect(correctToolProvider('jina', false)).toBe('langgenius/jina_tool/jina')
|
||||
expect(correctToolProvider('siliconflow', false)).toBe('langgenius/siliconflow_tool/siliconflow')
|
||||
expect(correctToolProvider('gitee_ai', false)).toBe('langgenius/gitee_ai_tool/gitee_ai')
|
||||
})
|
||||
|
||||
it('should handle standard tool providers', () => {
|
||||
expect(correctToolProvider('standard', false)).toBe('langgenius/standard/standard')
|
||||
})
|
||||
})
|
||||
|
||||
describe('canFindTool extended', () => {
|
||||
it('should match exact provider ID', () => {
|
||||
expect(canFindTool('openai', 'openai')).toBe(true)
|
||||
})
|
||||
|
||||
it('should match langgenius format', () => {
|
||||
expect(canFindTool('langgenius/openai/openai', 'openai')).toBe(true)
|
||||
})
|
||||
|
||||
it('should match tool format', () => {
|
||||
expect(canFindTool('langgenius/jina_tool/jina', 'jina')).toBe(true)
|
||||
})
|
||||
|
||||
it('should not match different providers', () => {
|
||||
expect(canFindTool('openai', 'anthropic')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle undefined oldToolId', () => {
|
||||
expect(canFindTool('openai', undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeSpecificQueryParam extended', () => {
|
||||
beforeEach(() => {
|
||||
// Reset window.location
|
||||
delete (window as any).location
|
||||
window.location = {
|
||||
href: 'https://example.com?param1=value1¶m2=value2¶m3=value3',
|
||||
} as any
|
||||
})
|
||||
|
||||
it('should remove single query parameter', () => {
|
||||
const mockReplaceState = jest.fn()
|
||||
window.history.replaceState = mockReplaceState
|
||||
|
||||
removeSpecificQueryParam('param1')
|
||||
|
||||
expect(mockReplaceState).toHaveBeenCalled()
|
||||
const newUrl = mockReplaceState.mock.calls[0][2]
|
||||
expect(newUrl).not.toContain('param1')
|
||||
})
|
||||
|
||||
it('should remove multiple query parameters', () => {
|
||||
const mockReplaceState = jest.fn()
|
||||
window.history.replaceState = mockReplaceState
|
||||
|
||||
removeSpecificQueryParam(['param1', 'param2'])
|
||||
|
||||
expect(mockReplaceState).toHaveBeenCalled()
|
||||
const newUrl = mockReplaceState.mock.calls[0][2]
|
||||
expect(newUrl).not.toContain('param1')
|
||||
expect(newUrl).not.toContain('param2')
|
||||
})
|
||||
|
||||
it('should preserve other parameters', () => {
|
||||
const mockReplaceState = jest.fn()
|
||||
window.history.replaceState = mockReplaceState
|
||||
|
||||
removeSpecificQueryParam('param1')
|
||||
|
||||
const newUrl = mockReplaceState.mock.calls[0][2]
|
||||
expect(newUrl).toContain('param2')
|
||||
expect(newUrl).toContain('param3')
|
||||
})
|
||||
})
|
||||
|
||||
88
web/utils/mcp.spec.ts
Normal file
88
web/utils/mcp.spec.ts
Normal file
@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Test suite for MCP (Model Context Protocol) utility functions
|
||||
* Tests icon detection logic for MCP-related features
|
||||
*/
|
||||
import { shouldUseMcpIcon, shouldUseMcpIconForAppIcon } from './mcp'
|
||||
|
||||
describe('mcp', () => {
|
||||
/**
|
||||
* Tests shouldUseMcpIcon function which determines if the MCP icon
|
||||
* should be used based on the icon source format
|
||||
*/
|
||||
describe('shouldUseMcpIcon', () => {
|
||||
/**
|
||||
* The link emoji (🔗) is used as a special marker for MCP icons
|
||||
*/
|
||||
test('returns true for emoji object with 🔗 content', () => {
|
||||
const src = { content: '🔗', background: '#fff' }
|
||||
expect(shouldUseMcpIcon(src)).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for emoji object with different content', () => {
|
||||
const src = { content: '🎉', background: '#fff' }
|
||||
expect(shouldUseMcpIcon(src)).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for string URL', () => {
|
||||
const src = 'https://example.com/icon.png'
|
||||
expect(shouldUseMcpIcon(src)).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for null', () => {
|
||||
expect(shouldUseMcpIcon(null)).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for undefined', () => {
|
||||
expect(shouldUseMcpIcon(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for empty object', () => {
|
||||
expect(shouldUseMcpIcon({})).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for object without content property', () => {
|
||||
const src = { background: '#fff' }
|
||||
expect(shouldUseMcpIcon(src)).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for object with null content', () => {
|
||||
const src = { content: null, background: '#fff' }
|
||||
expect(shouldUseMcpIcon(src)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests shouldUseMcpIconForAppIcon function which checks if an app icon
|
||||
* should use the MCP icon based on icon type and content
|
||||
*/
|
||||
describe('shouldUseMcpIconForAppIcon', () => {
|
||||
/**
|
||||
* MCP icon should only be used when both conditions are met:
|
||||
* - Icon type is 'emoji'
|
||||
* - Icon content is the link emoji (🔗)
|
||||
*/
|
||||
test('returns true when iconType is emoji and icon is 🔗', () => {
|
||||
expect(shouldUseMcpIconForAppIcon('emoji', '🔗')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false when iconType is emoji but icon is different', () => {
|
||||
expect(shouldUseMcpIconForAppIcon('emoji', '🎉')).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false when iconType is image', () => {
|
||||
expect(shouldUseMcpIconForAppIcon('image', '🔗')).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false when iconType is image and icon is different', () => {
|
||||
expect(shouldUseMcpIconForAppIcon('image', 'file-id-123')).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for empty strings', () => {
|
||||
expect(shouldUseMcpIconForAppIcon('', '')).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false when iconType is empty but icon is 🔗', () => {
|
||||
expect(shouldUseMcpIconForAppIcon('', '🔗')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
297
web/utils/navigation.spec.ts
Normal file
297
web/utils/navigation.spec.ts
Normal file
@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Test suite for navigation utility functions
|
||||
* Tests URL and query parameter manipulation for consistent navigation behavior
|
||||
* Includes helpers for preserving state during navigation (pagination, filters, etc.)
|
||||
*/
|
||||
import {
|
||||
createBackNavigation,
|
||||
createNavigationPath,
|
||||
createNavigationPathWithParams,
|
||||
datasetNavigation,
|
||||
extractQueryParams,
|
||||
mergeQueryParams,
|
||||
} from './navigation'
|
||||
|
||||
describe('navigation', () => {
|
||||
const originalWindow = globalThis.window
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock window.location with sample query parameters
|
||||
delete (globalThis as any).window
|
||||
globalThis.window = {
|
||||
location: {
|
||||
search: '?page=3&limit=10&keyword=test',
|
||||
},
|
||||
} as any
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.window = originalWindow
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests createNavigationPath which builds URLs with optional query parameter preservation
|
||||
*/
|
||||
describe('createNavigationPath', () => {
|
||||
test('preserves query parameters by default', () => {
|
||||
const result = createNavigationPath('/datasets/123/documents')
|
||||
expect(result).toBe('/datasets/123/documents?page=3&limit=10&keyword=test')
|
||||
})
|
||||
|
||||
test('returns clean path when preserveParams is false', () => {
|
||||
const result = createNavigationPath('/datasets/123/documents', false)
|
||||
expect(result).toBe('/datasets/123/documents')
|
||||
})
|
||||
|
||||
test('handles empty query string', () => {
|
||||
globalThis.window.location.search = ''
|
||||
const result = createNavigationPath('/datasets/123/documents')
|
||||
expect(result).toBe('/datasets/123/documents')
|
||||
})
|
||||
|
||||
test('handles path with trailing slash', () => {
|
||||
const result = createNavigationPath('/datasets/123/documents/')
|
||||
expect(result).toBe('/datasets/123/documents/?page=3&limit=10&keyword=test')
|
||||
})
|
||||
|
||||
test('handles root path', () => {
|
||||
const result = createNavigationPath('/')
|
||||
expect(result).toBe('/?page=3&limit=10&keyword=test')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests createBackNavigation which creates a navigation callback function
|
||||
*/
|
||||
describe('createBackNavigation', () => {
|
||||
/**
|
||||
* Tests that the returned function properly navigates with preserved params
|
||||
*/
|
||||
test('returns function that calls router.push with correct path', () => {
|
||||
const mockRouter = { push: jest.fn() }
|
||||
const backNav = createBackNavigation(mockRouter, '/datasets/123/documents')
|
||||
|
||||
backNav()
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/datasets/123/documents?page=3&limit=10&keyword=test')
|
||||
})
|
||||
|
||||
test('returns function that navigates without params when preserveParams is false', () => {
|
||||
const mockRouter = { push: jest.fn() }
|
||||
const backNav = createBackNavigation(mockRouter, '/datasets/123/documents', false)
|
||||
|
||||
backNav()
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/datasets/123/documents')
|
||||
})
|
||||
|
||||
test('can be called multiple times', () => {
|
||||
const mockRouter = { push: jest.fn() }
|
||||
const backNav = createBackNavigation(mockRouter, '/datasets/123/documents')
|
||||
|
||||
backNav()
|
||||
backNav()
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests extractQueryParams which extracts specific parameters from current URL
|
||||
*/
|
||||
describe('extractQueryParams', () => {
|
||||
/**
|
||||
* Tests selective parameter extraction
|
||||
*/
|
||||
test('extracts specified parameters', () => {
|
||||
const result = extractQueryParams(['page', 'limit'])
|
||||
expect(result).toEqual({ page: '3', limit: '10' })
|
||||
})
|
||||
|
||||
test('extracts all specified parameters including keyword', () => {
|
||||
const result = extractQueryParams(['page', 'limit', 'keyword'])
|
||||
expect(result).toEqual({ page: '3', limit: '10', keyword: 'test' })
|
||||
})
|
||||
|
||||
test('ignores non-existent parameters', () => {
|
||||
const result = extractQueryParams(['page', 'nonexistent'])
|
||||
expect(result).toEqual({ page: '3' })
|
||||
})
|
||||
|
||||
test('returns empty object when no parameters match', () => {
|
||||
const result = extractQueryParams(['foo', 'bar'])
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test('returns empty object for empty array', () => {
|
||||
const result = extractQueryParams([])
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test('handles empty query string', () => {
|
||||
globalThis.window.location.search = ''
|
||||
const result = extractQueryParams(['page', 'limit'])
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests createNavigationPathWithParams which builds URLs with specific parameters
|
||||
*/
|
||||
describe('createNavigationPathWithParams', () => {
|
||||
/**
|
||||
* Tests URL construction with custom parameters
|
||||
*/
|
||||
test('creates path with specified parameters', () => {
|
||||
const result = createNavigationPathWithParams('/datasets/123/documents', {
|
||||
page: '1',
|
||||
limit: '25',
|
||||
})
|
||||
expect(result).toBe('/datasets/123/documents?page=1&limit=25')
|
||||
})
|
||||
|
||||
test('handles string and number values', () => {
|
||||
const result = createNavigationPathWithParams('/datasets/123/documents', {
|
||||
page: 1,
|
||||
limit: 25,
|
||||
keyword: 'search',
|
||||
})
|
||||
expect(result).toBe('/datasets/123/documents?page=1&limit=25&keyword=search')
|
||||
})
|
||||
|
||||
test('filters out empty string values', () => {
|
||||
const result = createNavigationPathWithParams('/datasets/123/documents', {
|
||||
page: '1',
|
||||
keyword: '',
|
||||
})
|
||||
expect(result).toBe('/datasets/123/documents?page=1')
|
||||
})
|
||||
|
||||
test('filters out null and undefined values', () => {
|
||||
const result = createNavigationPathWithParams('/datasets/123/documents', {
|
||||
page: '1',
|
||||
keyword: null as any,
|
||||
filter: undefined as any,
|
||||
})
|
||||
expect(result).toBe('/datasets/123/documents?page=1')
|
||||
})
|
||||
|
||||
test('returns base path when params are empty', () => {
|
||||
const result = createNavigationPathWithParams('/datasets/123/documents', {})
|
||||
expect(result).toBe('/datasets/123/documents')
|
||||
})
|
||||
|
||||
test('encodes special characters in values', () => {
|
||||
const result = createNavigationPathWithParams('/datasets/123/documents', {
|
||||
keyword: 'search term',
|
||||
})
|
||||
expect(result).toBe('/datasets/123/documents?keyword=search+term')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests mergeQueryParams which combines new parameters with existing URL params
|
||||
*/
|
||||
describe('mergeQueryParams', () => {
|
||||
/**
|
||||
* Tests parameter merging and overriding
|
||||
*/
|
||||
test('merges new params with existing ones', () => {
|
||||
const result = mergeQueryParams({ keyword: 'new', page: '1' })
|
||||
expect(result.get('page')).toBe('1')
|
||||
expect(result.get('limit')).toBe('10')
|
||||
expect(result.get('keyword')).toBe('new')
|
||||
})
|
||||
|
||||
test('overrides existing parameters', () => {
|
||||
const result = mergeQueryParams({ page: '5' })
|
||||
expect(result.get('page')).toBe('5')
|
||||
expect(result.get('limit')).toBe('10')
|
||||
})
|
||||
|
||||
test('adds new parameters', () => {
|
||||
const result = mergeQueryParams({ filter: 'active' })
|
||||
expect(result.get('filter')).toBe('active')
|
||||
expect(result.get('page')).toBe('3')
|
||||
})
|
||||
|
||||
test('removes parameters with null value', () => {
|
||||
const result = mergeQueryParams({ page: null })
|
||||
expect(result.get('page')).toBeNull()
|
||||
expect(result.get('limit')).toBe('10')
|
||||
})
|
||||
|
||||
test('removes parameters with undefined value', () => {
|
||||
const result = mergeQueryParams({ page: undefined })
|
||||
expect(result.get('page')).toBeNull()
|
||||
expect(result.get('limit')).toBe('10')
|
||||
})
|
||||
|
||||
test('does not preserve existing when preserveExisting is false', () => {
|
||||
const result = mergeQueryParams({ filter: 'active' }, false)
|
||||
expect(result.get('filter')).toBe('active')
|
||||
expect(result.get('page')).toBeNull()
|
||||
expect(result.get('limit')).toBeNull()
|
||||
})
|
||||
|
||||
test('handles number values', () => {
|
||||
const result = mergeQueryParams({ page: 5, limit: 20 })
|
||||
expect(result.get('page')).toBe('5')
|
||||
expect(result.get('limit')).toBe('20')
|
||||
})
|
||||
|
||||
test('does not add empty string values', () => {
|
||||
const result = mergeQueryParams({ newParam: '' })
|
||||
expect(result.get('newParam')).toBeNull()
|
||||
// Existing params are preserved
|
||||
expect(result.get('keyword')).toBe('test')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests datasetNavigation helper object with common dataset navigation patterns
|
||||
*/
|
||||
describe('datasetNavigation', () => {
|
||||
/**
|
||||
* Tests navigation back to dataset documents list
|
||||
*/
|
||||
describe('backToDocuments', () => {
|
||||
test('creates navigation function with preserved params', () => {
|
||||
const mockRouter = { push: jest.fn() }
|
||||
const backNav = datasetNavigation.backToDocuments(mockRouter, 'dataset-123')
|
||||
|
||||
backNav()
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/datasets/dataset-123/documents?page=3&limit=10&keyword=test')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests navigation to document detail page
|
||||
*/
|
||||
describe('toDocumentDetail', () => {
|
||||
test('creates navigation function to document detail', () => {
|
||||
const mockRouter = { push: jest.fn() }
|
||||
const navFunc = datasetNavigation.toDocumentDetail(mockRouter, 'dataset-123', 'doc-456')
|
||||
|
||||
navFunc()
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/datasets/dataset-123/documents/doc-456')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests navigation to document settings page
|
||||
*/
|
||||
describe('toDocumentSettings', () => {
|
||||
test('creates navigation function to document settings', () => {
|
||||
const mockRouter = { push: jest.fn() }
|
||||
const navFunc = datasetNavigation.toDocumentSettings(mockRouter, 'dataset-123', 'doc-456')
|
||||
|
||||
navFunc()
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/datasets/dataset-123/documents/doc-456/settings')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
95
web/utils/permission.spec.ts
Normal file
95
web/utils/permission.spec.ts
Normal file
@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Test suite for permission utility functions
|
||||
* Tests dataset edit permission logic based on user roles and dataset settings
|
||||
*/
|
||||
import { hasEditPermissionForDataset } from './permission'
|
||||
import { DatasetPermission } from '@/models/datasets'
|
||||
|
||||
describe('permission', () => {
|
||||
/**
|
||||
* Tests hasEditPermissionForDataset which checks if a user can edit a dataset
|
||||
* Based on three permission levels:
|
||||
* - onlyMe: Only the creator can edit
|
||||
* - allTeamMembers: All team members can edit
|
||||
* - partialMembers: Only specified members can edit
|
||||
*/
|
||||
describe('hasEditPermissionForDataset', () => {
|
||||
const userId = 'user-123'
|
||||
const creatorId = 'creator-456'
|
||||
const otherUserId = 'user-789'
|
||||
|
||||
test('returns true when permission is onlyMe and user is creator', () => {
|
||||
const config = {
|
||||
createdBy: userId,
|
||||
partialMemberList: [],
|
||||
permission: DatasetPermission.onlyMe,
|
||||
}
|
||||
expect(hasEditPermissionForDataset(userId, config)).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false when permission is onlyMe and user is not creator', () => {
|
||||
const config = {
|
||||
createdBy: creatorId,
|
||||
partialMemberList: [],
|
||||
permission: DatasetPermission.onlyMe,
|
||||
}
|
||||
expect(hasEditPermissionForDataset(userId, config)).toBe(false)
|
||||
})
|
||||
|
||||
test('returns true when permission is allTeamMembers for any user', () => {
|
||||
const config = {
|
||||
createdBy: creatorId,
|
||||
partialMemberList: [],
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
}
|
||||
expect(hasEditPermissionForDataset(userId, config)).toBe(true)
|
||||
expect(hasEditPermissionForDataset(otherUserId, config)).toBe(true)
|
||||
expect(hasEditPermissionForDataset(creatorId, config)).toBe(true)
|
||||
})
|
||||
|
||||
test('returns true when permission is partialMembers and user is in list', () => {
|
||||
const config = {
|
||||
createdBy: creatorId,
|
||||
partialMemberList: [userId, otherUserId],
|
||||
permission: DatasetPermission.partialMembers,
|
||||
}
|
||||
expect(hasEditPermissionForDataset(userId, config)).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false when permission is partialMembers and user is not in list', () => {
|
||||
const config = {
|
||||
createdBy: creatorId,
|
||||
partialMemberList: [otherUserId],
|
||||
permission: DatasetPermission.partialMembers,
|
||||
}
|
||||
expect(hasEditPermissionForDataset(userId, config)).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false when permission is partialMembers with empty list', () => {
|
||||
const config = {
|
||||
createdBy: creatorId,
|
||||
partialMemberList: [],
|
||||
permission: DatasetPermission.partialMembers,
|
||||
}
|
||||
expect(hasEditPermissionForDataset(userId, config)).toBe(false)
|
||||
})
|
||||
|
||||
test('creator is not automatically granted access with partialMembers permission', () => {
|
||||
const config = {
|
||||
createdBy: creatorId,
|
||||
partialMemberList: [userId],
|
||||
permission: DatasetPermission.partialMembers,
|
||||
}
|
||||
expect(hasEditPermissionForDataset(creatorId, config)).toBe(false)
|
||||
})
|
||||
|
||||
test('creator has access when included in partialMemberList', () => {
|
||||
const config = {
|
||||
createdBy: creatorId,
|
||||
partialMemberList: [creatorId, userId],
|
||||
permission: DatasetPermission.partialMembers,
|
||||
}
|
||||
expect(hasEditPermissionForDataset(creatorId, config)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
99
web/utils/time.spec.ts
Normal file
99
web/utils/time.spec.ts
Normal file
@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Test suite for time utility functions
|
||||
* Tests date comparison and formatting using dayjs
|
||||
*/
|
||||
import { formatTime, isAfter } from './time'
|
||||
|
||||
describe('time', () => {
|
||||
/**
|
||||
* Tests isAfter function which compares two dates
|
||||
* Returns true if the first date is after the second
|
||||
*/
|
||||
describe('isAfter', () => {
|
||||
test('returns true when first date is after second date', () => {
|
||||
const date1 = '2024-01-02'
|
||||
const date2 = '2024-01-01'
|
||||
expect(isAfter(date1, date2)).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false when first date is before second date', () => {
|
||||
const date1 = '2024-01-01'
|
||||
const date2 = '2024-01-02'
|
||||
expect(isAfter(date1, date2)).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false when dates are equal', () => {
|
||||
const date = '2024-01-01'
|
||||
expect(isAfter(date, date)).toBe(false)
|
||||
})
|
||||
|
||||
test('works with Date objects', () => {
|
||||
const date1 = new Date('2024-01-02')
|
||||
const date2 = new Date('2024-01-01')
|
||||
expect(isAfter(date1, date2)).toBe(true)
|
||||
})
|
||||
|
||||
test('works with timestamps', () => {
|
||||
const date1 = 1704240000000 // 2024-01-03
|
||||
const date2 = 1704153600000 // 2024-01-02
|
||||
expect(isAfter(date1, date2)).toBe(true)
|
||||
})
|
||||
|
||||
test('handles time differences within same day', () => {
|
||||
const date1 = '2024-01-01 12:00:00'
|
||||
const date2 = '2024-01-01 11:00:00'
|
||||
expect(isAfter(date1, date2)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests formatTime function which formats dates using dayjs
|
||||
* Supports various date formats and input types
|
||||
*/
|
||||
describe('formatTime', () => {
|
||||
/**
|
||||
* Tests basic date formatting with standard format
|
||||
*/
|
||||
test('formats date with YYYY-MM-DD format', () => {
|
||||
const date = '2024-01-15'
|
||||
const result = formatTime({ date, dateFormat: 'YYYY-MM-DD' })
|
||||
expect(result).toBe('2024-01-15')
|
||||
})
|
||||
|
||||
test('formats date with custom format', () => {
|
||||
const date = '2024-01-15 14:30:00'
|
||||
const result = formatTime({ date, dateFormat: 'MMM DD, YYYY HH:mm' })
|
||||
expect(result).toBe('Jan 15, 2024 14:30')
|
||||
})
|
||||
|
||||
test('formats date with full month name', () => {
|
||||
const date = '2024-01-15'
|
||||
const result = formatTime({ date, dateFormat: 'MMMM DD, YYYY' })
|
||||
expect(result).toBe('January 15, 2024')
|
||||
})
|
||||
|
||||
test('formats date with time only', () => {
|
||||
const date = '2024-01-15 14:30:45'
|
||||
const result = formatTime({ date, dateFormat: 'HH:mm:ss' })
|
||||
expect(result).toBe('14:30:45')
|
||||
})
|
||||
|
||||
test('works with Date objects', () => {
|
||||
const date = new Date(2024, 0, 15) // Month is 0-indexed
|
||||
const result = formatTime({ date, dateFormat: 'YYYY-MM-DD' })
|
||||
expect(result).toBe('2024-01-15')
|
||||
})
|
||||
|
||||
test('works with timestamps', () => {
|
||||
const date = 1705276800000 // 2024-01-15 00:00:00 UTC
|
||||
const result = formatTime({ date, dateFormat: 'YYYY-MM-DD' })
|
||||
expect(result).toContain('2024-01-1') // Account for timezone differences
|
||||
})
|
||||
|
||||
test('handles ISO 8601 format', () => {
|
||||
const date = '2024-01-15T14:30:00Z'
|
||||
const result = formatTime({ date, dateFormat: 'YYYY-MM-DD HH:mm' })
|
||||
expect(result).toContain('2024-01-15')
|
||||
})
|
||||
})
|
||||
})
|
||||
79
web/utils/tool-call.spec.ts
Normal file
79
web/utils/tool-call.spec.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Test suite for tool call utility functions
|
||||
* Tests detection of function/tool call support in AI models
|
||||
*/
|
||||
import { supportFunctionCall } from './tool-call'
|
||||
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
|
||||
describe('tool-call', () => {
|
||||
/**
|
||||
* Tests supportFunctionCall which checks if a model supports any form of
|
||||
* function calling (toolCall, multiToolCall, or streamToolCall)
|
||||
*/
|
||||
describe('supportFunctionCall', () => {
|
||||
/**
|
||||
* Tests detection of basic tool call support
|
||||
*/
|
||||
test('returns true when features include toolCall', () => {
|
||||
const features = [ModelFeatureEnum.toolCall]
|
||||
expect(supportFunctionCall(features)).toBe(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests detection of multi-tool call support (calling multiple tools in one request)
|
||||
*/
|
||||
test('returns true when features include multiToolCall', () => {
|
||||
const features = [ModelFeatureEnum.multiToolCall]
|
||||
expect(supportFunctionCall(features)).toBe(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests detection of streaming tool call support
|
||||
*/
|
||||
test('returns true when features include streamToolCall', () => {
|
||||
const features = [ModelFeatureEnum.streamToolCall]
|
||||
expect(supportFunctionCall(features)).toBe(true)
|
||||
})
|
||||
|
||||
test('returns true when features include multiple tool call types', () => {
|
||||
const features = [
|
||||
ModelFeatureEnum.toolCall,
|
||||
ModelFeatureEnum.multiToolCall,
|
||||
ModelFeatureEnum.streamToolCall,
|
||||
]
|
||||
expect(supportFunctionCall(features)).toBe(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests that tool call support is detected even when mixed with other features
|
||||
*/
|
||||
test('returns true when features include tool call among other features', () => {
|
||||
const features = [
|
||||
ModelFeatureEnum.agentThought,
|
||||
ModelFeatureEnum.toolCall,
|
||||
ModelFeatureEnum.vision,
|
||||
]
|
||||
expect(supportFunctionCall(features)).toBe(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests that false is returned when no tool call features are present
|
||||
*/
|
||||
test('returns false when features do not include any tool call type', () => {
|
||||
const features = [ModelFeatureEnum.agentThought, ModelFeatureEnum.vision]
|
||||
expect(supportFunctionCall(features)).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for empty array', () => {
|
||||
expect(supportFunctionCall([])).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for undefined', () => {
|
||||
expect(supportFunctionCall(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for null', () => {
|
||||
expect(supportFunctionCall(null as any)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
49
web/utils/urlValidation.spec.ts
Normal file
49
web/utils/urlValidation.spec.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { validateRedirectUrl } from './urlValidation'
|
||||
|
||||
describe('URL Validation', () => {
|
||||
describe('validateRedirectUrl', () => {
|
||||
it('should reject data: protocol', () => {
|
||||
expect(() => validateRedirectUrl('data:text/html,<script>alert(1)</script>')).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')
|
||||
})
|
||||
})
|
||||
})
|
||||
139
web/utils/validators.spec.ts
Normal file
139
web/utils/validators.spec.ts
Normal file
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
236
web/utils/var.spec.ts
Normal file
236
web/utils/var.spec.ts
Normal file
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user