dify
This commit is contained in:
107
dify/web/utils/app-redirection.spec.ts
Normal file
107
dify/web/utils/app-redirection.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Test suite for app redirection utility functions
|
||||
* Tests navigation path generation based on user permissions and app modes
|
||||
*/
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
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: AppModeEnum.CHAT }
|
||||
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: AppModeEnum.WORKFLOW }
|
||||
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: AppModeEnum.ADVANCED_CHAT }
|
||||
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: AppModeEnum.CHAT }
|
||||
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: AppModeEnum.COMPLETION }
|
||||
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: AppModeEnum.AGENT_CHAT }
|
||||
const result = getRedirectionPath(true, app)
|
||||
expect(result).toBe('/app/app-456/configuration')
|
||||
})
|
||||
|
||||
test('handles different app IDs', () => {
|
||||
const app1 = { id: 'abc-123', mode: AppModeEnum.CHAT }
|
||||
const app2 = { id: 'xyz-789', mode: AppModeEnum.WORKFLOW }
|
||||
|
||||
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: AppModeEnum.CHAT }
|
||||
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: AppModeEnum.WORKFLOW }
|
||||
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: AppModeEnum.CHAT }
|
||||
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: AppModeEnum.WORKFLOW }
|
||||
const paths: string[] = []
|
||||
const customRedirect = (path: string) => paths.push(path)
|
||||
|
||||
getRedirection(true, app, customRedirect)
|
||||
|
||||
expect(paths).toEqual(['/app/app-123/workflow'])
|
||||
})
|
||||
})
|
||||
})
|
||||
25
dify/web/utils/app-redirection.ts
Normal file
25
dify/web/utils/app-redirection.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
export const getRedirectionPath = (
|
||||
isCurrentWorkspaceEditor: boolean,
|
||||
app: { id: string, mode: AppModeEnum },
|
||||
) => {
|
||||
if (!isCurrentWorkspaceEditor) {
|
||||
return `/app/${app.id}/overview`
|
||||
}
|
||||
else {
|
||||
if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT)
|
||||
return `/app/${app.id}/workflow`
|
||||
else
|
||||
return `/app/${app.id}/configuration`
|
||||
}
|
||||
}
|
||||
|
||||
export const getRedirection = (
|
||||
isCurrentWorkspaceEditor: boolean,
|
||||
app: { id: string, mode: AppModeEnum },
|
||||
redirectionFunc: (href: string) => void,
|
||||
) => {
|
||||
const redirectionPath = getRedirectionPath(isCurrentWorkspaceEditor, app)
|
||||
redirectionFunc(redirectionPath)
|
||||
}
|
||||
157
dify/web/utils/classnames.spec.ts
Normal file
157
dify/web/utils/classnames.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 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')
|
||||
expect(cn(['foo', 'bar'])).toBe('foo bar')
|
||||
|
||||
expect(cn(undefined)).toBe('')
|
||||
expect(cn(null)).toBe('')
|
||||
expect(cn(false)).toBe('')
|
||||
|
||||
expect(cn({
|
||||
foo: true,
|
||||
bar: false,
|
||||
baz: true,
|
||||
})).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')
|
||||
expect(cn('text-right text-center text-left')).toBe('text-left')
|
||||
expect(cn('pl-4 p-8')).toBe('p-8')
|
||||
expect(cn('m-[2px] m-[4px]')).toBe('m-[4px]')
|
||||
expect(cn('m-1 m-[4px]')).toBe('m-[4px]')
|
||||
expect(cn('overflow-x-auto hover:overflow-x-hidden overflow-x-scroll')).toBe(
|
||||
'hover:overflow-x-hidden overflow-x-scroll',
|
||||
)
|
||||
expect(cn('h-10 h-min')).toBe('h-min')
|
||||
expect(cn('bg-grey-5 bg-hotpink')).toBe('bg-hotpink')
|
||||
|
||||
expect(cn('hover:block hover:inline')).toBe('hover:inline')
|
||||
|
||||
expect(cn('font-medium !font-bold')).toBe('font-medium !font-bold')
|
||||
expect(cn('!font-medium !font-bold')).toBe('!font-bold')
|
||||
|
||||
expect(cn('text-gray-100 text-primary-200')).toBe('text-primary-200')
|
||||
expect(cn('text-some-unknown-color text-components-input-bg-disabled text-primary-200')).toBe('text-primary-200')
|
||||
expect(cn('bg-some-unknown-color bg-components-input-bg-disabled bg-primary-200')).toBe('bg-primary-200')
|
||||
|
||||
expect(cn('border-t border-white/10')).toBe('border-t border-white/10')
|
||||
expect(cn('border-t border-white')).toBe('border-t border-white')
|
||||
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,
|
||||
})).toBe('text-center')
|
||||
|
||||
expect(cn('text-right', {
|
||||
'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')
|
||||
})
|
||||
})
|
||||
8
dify/web/utils/classnames.ts
Normal file
8
dify/web/utils/classnames.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import cn from 'classnames'
|
||||
|
||||
const classNames = (...cls: cn.ArgumentArray) => {
|
||||
return twMerge(cn(cls))
|
||||
}
|
||||
|
||||
export default classNames
|
||||
148
dify/web/utils/clipboard.spec.ts
Normal file
148
dify/web/utils/clipboard.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Test suite for clipboard utilities
|
||||
*
|
||||
* This module provides cross-browser clipboard functionality with automatic fallback:
|
||||
* 1. Modern Clipboard API (navigator.clipboard.writeText) - preferred method
|
||||
* 2. Legacy execCommand('copy') - fallback for older browsers
|
||||
*
|
||||
* The implementation ensures clipboard operations work across all supported browsers
|
||||
* while gracefully handling permissions and API availability.
|
||||
*/
|
||||
import { writeTextToClipboard } from './clipboard'
|
||||
|
||||
describe('Clipboard Utilities', () => {
|
||||
describe('writeTextToClipboard', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test modern Clipboard API usage
|
||||
* When navigator.clipboard is available, should use the modern API
|
||||
*/
|
||||
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')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test fallback to legacy execCommand method
|
||||
* When Clipboard API is unavailable, should use document.execCommand('copy')
|
||||
* This involves creating a temporary textarea element
|
||||
*/
|
||||
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()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test error handling when execCommand returns false
|
||||
* execCommand returns false when the operation fails
|
||||
*/
|
||||
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()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test error handling when execCommand throws an exception
|
||||
* Should propagate the error to the caller
|
||||
*/
|
||||
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')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test proper cleanup of temporary DOM elements
|
||||
* The temporary textarea should be removed after copying
|
||||
*/
|
||||
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()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test copying empty strings
|
||||
* Should handle edge case of empty clipboard content
|
||||
*/
|
||||
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('')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test copying text with special characters
|
||||
* Should preserve newlines, tabs, quotes, unicode, and emojis
|
||||
*/
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
35
dify/web/utils/clipboard.ts
Normal file
35
dify/web/utils/clipboard.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export async function writeTextToClipboard(text: string): Promise<void> {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText)
|
||||
return navigator.clipboard.writeText(text)
|
||||
|
||||
return fallbackCopyTextToClipboard(text)
|
||||
}
|
||||
|
||||
async function fallbackCopyTextToClipboard(text: string): Promise<void> {
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
textArea.style.position = 'fixed' // Avoid scrolling to bottom
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
try {
|
||||
const successful = document.execCommand('copy')
|
||||
if (successful)
|
||||
return Promise.resolve()
|
||||
|
||||
return Promise.reject(new Error('document.execCommand failed'))
|
||||
}
|
||||
catch (err) {
|
||||
return Promise.reject(convertAnyToError(err))
|
||||
}
|
||||
finally {
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
}
|
||||
|
||||
function convertAnyToError(err: any): Error {
|
||||
if (err instanceof Error)
|
||||
return err
|
||||
|
||||
return new Error(`Caught: ${String(err)}`)
|
||||
}
|
||||
230
dify/web/utils/completion-params.spec.ts
Normal file
230
dify/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')
|
||||
})
|
||||
})
|
||||
})
|
||||
89
dify/web/utils/completion-params.ts
Normal file
89
dify/web/utils/completion-params.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { FormValue, ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
|
||||
export const mergeValidCompletionParams = (
|
||||
oldParams: FormValue | undefined,
|
||||
rules: ModelParameterRule[],
|
||||
isAdvancedMode: boolean = false,
|
||||
): { params: FormValue; removedDetails: Record<string, string> } => {
|
||||
if (!oldParams || Object.keys(oldParams).length === 0)
|
||||
return { params: {}, removedDetails: {} }
|
||||
|
||||
const ruleMap: Record<string, ModelParameterRule> = {}
|
||||
rules.forEach((r) => {
|
||||
ruleMap[r.name] = r
|
||||
})
|
||||
|
||||
const nextParams: FormValue = {}
|
||||
const removedDetails: Record<string, string> = {}
|
||||
|
||||
Object.entries(oldParams).forEach(([key, value]) => {
|
||||
if (key === 'stop' && isAdvancedMode) {
|
||||
// keep stop in advanced mode
|
||||
nextParams[key] = value
|
||||
return
|
||||
}
|
||||
const rule = ruleMap[key]
|
||||
if (!rule) {
|
||||
removedDetails[key] = 'unsupported'
|
||||
return
|
||||
}
|
||||
|
||||
switch (rule.type) {
|
||||
case 'int':
|
||||
case 'float': {
|
||||
if (typeof value !== 'number') {
|
||||
removedDetails[key] = 'invalid type'
|
||||
return
|
||||
}
|
||||
const min = rule.min ?? Number.NEGATIVE_INFINITY
|
||||
const max = rule.max ?? Number.POSITIVE_INFINITY
|
||||
if (value < min || value > max) {
|
||||
removedDetails[key] = `out of range (${min}-${max})`
|
||||
return
|
||||
}
|
||||
nextParams[key] = value
|
||||
return
|
||||
}
|
||||
case 'boolean': {
|
||||
if (typeof value !== 'boolean') {
|
||||
removedDetails[key] = 'invalid type'
|
||||
return
|
||||
}
|
||||
nextParams[key] = value
|
||||
return
|
||||
}
|
||||
case 'string':
|
||||
case 'text': {
|
||||
if (typeof value !== 'string') {
|
||||
removedDetails[key] = 'invalid type'
|
||||
return
|
||||
}
|
||||
if (Array.isArray(rule.options) && rule.options.length) {
|
||||
if (!(rule.options as string[]).includes(value)) {
|
||||
removedDetails[key] = 'unsupported option'
|
||||
return
|
||||
}
|
||||
}
|
||||
nextParams[key] = value
|
||||
return
|
||||
}
|
||||
default: {
|
||||
removedDetails[key] = `unsupported rule type: ${(rule as any)?.type ?? 'unknown'}`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { params: nextParams, removedDetails }
|
||||
}
|
||||
|
||||
export const fetchAndMergeValidCompletionParams = async (
|
||||
provider: string,
|
||||
modelId: string,
|
||||
oldParams: FormValue | undefined,
|
||||
isAdvancedMode: boolean = false,
|
||||
): Promise<{ params: FormValue; removedDetails: Record<string, string> }> => {
|
||||
const { fetchModelParameterRules } = await import('@/service/common')
|
||||
const url = `/workspaces/current/model-providers/${provider}/models/parameter-rules?model=${modelId}`
|
||||
const { data: parameterRules } = await fetchModelParameterRules(url)
|
||||
return mergeValidCompletionParams(oldParams, parameterRules ?? [], isAdvancedMode)
|
||||
}
|
||||
253
dify/web/utils/context.spec.ts
Normal file
253
dify/web/utils/context.spec.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Test suite for React context creation utilities
|
||||
*
|
||||
* This module provides helper functions to create React contexts with better type safety
|
||||
* and automatic error handling when context is used outside of its provider.
|
||||
*
|
||||
* Two variants are provided:
|
||||
* - createCtx: Standard React context using useContext/createContext
|
||||
* - createSelectorCtx: Context with selector support using use-context-selector library
|
||||
*/
|
||||
import React from 'react'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { createCtx, createSelectorCtx } from './context'
|
||||
|
||||
describe('Context Utilities', () => {
|
||||
describe('createCtx', () => {
|
||||
/**
|
||||
* Test that createCtx creates a valid context with provider and hook
|
||||
* The function should return a tuple with [Provider, useContextValue, Context]
|
||||
* plus named properties for easier access
|
||||
*/
|
||||
it('should create context with provider and hook', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const [Provider, useTestContext, Context] = createCtx<TestContextValue>({
|
||||
name: 'Test',
|
||||
})
|
||||
|
||||
expect(Provider).toBeDefined()
|
||||
expect(useTestContext).toBeDefined()
|
||||
expect(Context).toBeDefined()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the context hook returns the provided value correctly
|
||||
* when used within the context provider
|
||||
*/
|
||||
it('should provide and consume context value', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const [Provider, useTestContext] = createCtx<TestContextValue>({
|
||||
name: 'Test',
|
||||
})
|
||||
|
||||
const testValue = { value: 'test-value' }
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(Provider, { value: testValue }, children)
|
||||
|
||||
const { result } = renderHook(() => useTestContext(), { wrapper })
|
||||
|
||||
expect(result.current).toEqual(testValue)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that accessing context outside of provider throws an error
|
||||
* This ensures developers are notified when they forget to wrap components
|
||||
*/
|
||||
it('should throw error when used outside provider', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const [, useTestContext] = createCtx<TestContextValue>({
|
||||
name: 'Test',
|
||||
})
|
||||
|
||||
// Suppress console.error for this test
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { /* suppress error */ })
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useTestContext())
|
||||
}).toThrow('No Test context found.')
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that context works with default values
|
||||
* When a default value is provided, it should be accessible without a provider
|
||||
*/
|
||||
it('should use default value when provided', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const defaultValue = { value: 'default' }
|
||||
const [, useTestContext] = createCtx<TestContextValue>({
|
||||
name: 'Test',
|
||||
defaultValue,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTestContext())
|
||||
|
||||
expect(result.current).toEqual(defaultValue)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the returned tuple has named properties for convenience
|
||||
* This allows destructuring or property access based on preference
|
||||
*/
|
||||
it('should expose named properties', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const result = createCtx<TestContextValue>({ name: 'Test' })
|
||||
|
||||
expect(result.provider).toBe(result[0])
|
||||
expect(result.useContextValue).toBe(result[1])
|
||||
expect(result.context).toBe(result[2])
|
||||
})
|
||||
|
||||
/**
|
||||
* Test context with complex data types
|
||||
* Ensures type safety is maintained with nested objects and arrays
|
||||
*/
|
||||
it('should handle complex context values', () => {
|
||||
type ComplexContext = {
|
||||
user: { id: string; name: string }
|
||||
settings: { theme: string; locale: string }
|
||||
actions: Array<() => void>
|
||||
}
|
||||
|
||||
const [Provider, useComplexContext] = createCtx<ComplexContext>({
|
||||
name: 'Complex',
|
||||
})
|
||||
|
||||
const complexValue: ComplexContext = {
|
||||
user: { id: '123', name: 'Test User' },
|
||||
settings: { theme: 'dark', locale: 'en-US' },
|
||||
actions: [
|
||||
() => { /* empty action 1 */ },
|
||||
() => { /* empty action 2 */ },
|
||||
],
|
||||
}
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(Provider, { value: complexValue }, children)
|
||||
|
||||
const { result } = renderHook(() => useComplexContext(), { wrapper })
|
||||
|
||||
expect(result.current).toEqual(complexValue)
|
||||
expect(result.current.user.id).toBe('123')
|
||||
expect(result.current.settings.theme).toBe('dark')
|
||||
expect(result.current.actions).toHaveLength(2)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that context updates propagate to consumers
|
||||
* When provider value changes, hooks should receive the new value
|
||||
*/
|
||||
it('should update when context value changes', () => {
|
||||
type TestContextValue = { count: number }
|
||||
const [Provider, useTestContext] = createCtx<TestContextValue>({
|
||||
name: 'Test',
|
||||
})
|
||||
|
||||
let value = { count: 0 }
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(Provider, { value }, children)
|
||||
|
||||
const { result, rerender } = renderHook(() => useTestContext(), { wrapper })
|
||||
|
||||
expect(result.current.count).toBe(0)
|
||||
|
||||
value = { count: 5 }
|
||||
rerender()
|
||||
|
||||
expect(result.current.count).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSelectorCtx', () => {
|
||||
/**
|
||||
* Test that createSelectorCtx creates a valid context with selector support
|
||||
* This variant uses use-context-selector for optimized re-renders
|
||||
*/
|
||||
it('should create selector context with provider and hook', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const [Provider, useTestContext, Context] = createSelectorCtx<TestContextValue>({
|
||||
name: 'SelectorTest',
|
||||
})
|
||||
|
||||
expect(Provider).toBeDefined()
|
||||
expect(useTestContext).toBeDefined()
|
||||
expect(Context).toBeDefined()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that selector context provides and consumes values correctly
|
||||
* The API should be identical to createCtx for basic usage
|
||||
*/
|
||||
it('should provide and consume context value with selector', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const [Provider, useTestContext] = createSelectorCtx<TestContextValue>({
|
||||
name: 'SelectorTest',
|
||||
})
|
||||
|
||||
const testValue = { value: 'selector-test' }
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(Provider, { value: testValue }, children)
|
||||
|
||||
const { result } = renderHook(() => useTestContext(), { wrapper })
|
||||
|
||||
expect(result.current).toEqual(testValue)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test error handling for selector context
|
||||
* Should throw error when used outside provider, same as createCtx
|
||||
*/
|
||||
it('should throw error when used outside provider', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const [, useTestContext] = createSelectorCtx<TestContextValue>({
|
||||
name: 'SelectorTest',
|
||||
})
|
||||
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { /* suppress error */ })
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useTestContext())
|
||||
}).toThrow('No SelectorTest context found.')
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that selector context works with default values
|
||||
*/
|
||||
it('should use default value when provided', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const defaultValue = { value: 'selector-default' }
|
||||
const [, useTestContext] = createSelectorCtx<TestContextValue>({
|
||||
name: 'SelectorTest',
|
||||
defaultValue,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTestContext())
|
||||
|
||||
expect(result.current).toEqual(defaultValue)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Context without name', () => {
|
||||
/**
|
||||
* Test that contexts can be created without a name
|
||||
* The error message should use a generic fallback
|
||||
*/
|
||||
it('should create context without name and show generic error', () => {
|
||||
type TestContextValue = { value: string }
|
||||
const [, useTestContext] = createCtx<TestContextValue>()
|
||||
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => { /* suppress error */ })
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useTestContext())
|
||||
}).toThrow('No related context found.')
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
45
dify/web/utils/context.ts
Normal file
45
dify/web/utils/context.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { type Context, type Provider, createContext, useContext } from 'react'
|
||||
import * as selector from 'use-context-selector'
|
||||
|
||||
const createCreateCtxFunction = (
|
||||
useContextImpl: typeof useContext,
|
||||
createContextImpl: typeof createContext) => {
|
||||
return function<T>({ name, defaultValue }: CreateCtxOptions<T> = {}): CreateCtxReturn<T> {
|
||||
const emptySymbol = Symbol(`empty ${name}`)
|
||||
// @ts-expect-error it's ok here
|
||||
const context = createContextImpl<T>(defaultValue ?? emptySymbol)
|
||||
const useContextValue = () => {
|
||||
const ctx = useContextImpl(context)
|
||||
if (ctx === emptySymbol)
|
||||
throw new Error(`No ${name ?? 'related'} context found.`)
|
||||
|
||||
return ctx
|
||||
}
|
||||
const result = [context.Provider, useContextValue, context] as CreateCtxReturn<T>
|
||||
result.context = context
|
||||
result.provider = context.Provider
|
||||
result.useContextValue = useContextValue
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
type CreateCtxOptions<T> = {
|
||||
defaultValue?: T
|
||||
name?: string
|
||||
}
|
||||
|
||||
type CreateCtxReturn<T> = [Provider<T>, () => T, Context<T>] & {
|
||||
context: Context<T>
|
||||
provider: Provider<T>
|
||||
useContextValue: () => T
|
||||
}
|
||||
|
||||
// example
|
||||
// const [AppProvider, useApp, AppContext] = createCtx<AppContextValue>()
|
||||
|
||||
export const createCtx = createCreateCtxFunction(useContext, createContext)
|
||||
|
||||
export const createSelectorCtx = createCreateCtxFunction(
|
||||
selector.useContext,
|
||||
selector.createContext as typeof createContext,
|
||||
)
|
||||
245
dify/web/utils/draft-07.json
Normal file
245
dify/web/utils/draft-07.json
Normal file
@@ -0,0 +1,245 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Core schema meta-schema",
|
||||
"definitions": {
|
||||
"schemaArray": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#"
|
||||
}
|
||||
},
|
||||
"nonNegativeInteger": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"nonNegativeIntegerDefault0": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/nonNegativeInteger"
|
||||
},
|
||||
{
|
||||
"default": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"simpleTypes": {
|
||||
"enum": [
|
||||
"array",
|
||||
"boolean",
|
||||
"integer",
|
||||
"null",
|
||||
"number",
|
||||
"object",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"stringArray": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"type": [
|
||||
"object",
|
||||
"boolean"
|
||||
],
|
||||
"properties": {
|
||||
"$id": {
|
||||
"type": "string",
|
||||
"format": "uri-reference"
|
||||
},
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"$ref": {
|
||||
"type": "string",
|
||||
"format": "uri-reference"
|
||||
},
|
||||
"$comment": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": true,
|
||||
"readOnly": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"writeOnly": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"examples": {
|
||||
"type": "array",
|
||||
"items": true
|
||||
},
|
||||
"multipleOf": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0
|
||||
},
|
||||
"maximum": {
|
||||
"type": "number"
|
||||
},
|
||||
"exclusiveMaximum": {
|
||||
"type": "number"
|
||||
},
|
||||
"minimum": {
|
||||
"type": "number"
|
||||
},
|
||||
"exclusiveMinimum": {
|
||||
"type": "number"
|
||||
},
|
||||
"maxLength": {
|
||||
"$ref": "#/definitions/nonNegativeInteger"
|
||||
},
|
||||
"minLength": {
|
||||
"$ref": "#/definitions/nonNegativeIntegerDefault0"
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"format": "regex"
|
||||
},
|
||||
"additionalItems": {
|
||||
"$ref": "#"
|
||||
},
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/schemaArray"
|
||||
}
|
||||
],
|
||||
"default": true
|
||||
},
|
||||
"maxItems": {
|
||||
"$ref": "#/definitions/nonNegativeInteger"
|
||||
},
|
||||
"minItems": {
|
||||
"$ref": "#/definitions/nonNegativeIntegerDefault0"
|
||||
},
|
||||
"uniqueItems": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"contains": {
|
||||
"$ref": "#"
|
||||
},
|
||||
"maxProperties": {
|
||||
"$ref": "#/definitions/nonNegativeInteger"
|
||||
},
|
||||
"minProperties": {
|
||||
"$ref": "#/definitions/nonNegativeIntegerDefault0"
|
||||
},
|
||||
"required": {
|
||||
"$ref": "#/definitions/stringArray"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#"
|
||||
},
|
||||
"definitions": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#"
|
||||
},
|
||||
"default": {}
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#"
|
||||
},
|
||||
"default": {}
|
||||
},
|
||||
"patternProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#"
|
||||
},
|
||||
"propertyNames": {
|
||||
"format": "regex"
|
||||
},
|
||||
"default": {}
|
||||
},
|
||||
"dependencies": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/stringArray"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"propertyNames": {
|
||||
"$ref": "#"
|
||||
},
|
||||
"const": true,
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"items": true,
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/simpleTypes"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/simpleTypes"
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"format": {
|
||||
"type": "string"
|
||||
},
|
||||
"contentMediaType": {
|
||||
"type": "string"
|
||||
},
|
||||
"contentEncoding": {
|
||||
"type": "string"
|
||||
},
|
||||
"if": {
|
||||
"$ref": "#"
|
||||
},
|
||||
"then": {
|
||||
"$ref": "#"
|
||||
},
|
||||
"else": {
|
||||
"$ref": "#"
|
||||
},
|
||||
"allOf": {
|
||||
"$ref": "#/definitions/schemaArray"
|
||||
},
|
||||
"anyOf": {
|
||||
"$ref": "#/definitions/schemaArray"
|
||||
},
|
||||
"oneOf": {
|
||||
"$ref": "#/definitions/schemaArray"
|
||||
},
|
||||
"not": {
|
||||
"$ref": "#"
|
||||
}
|
||||
},
|
||||
"default": true
|
||||
}
|
||||
77
dify/web/utils/emoji.spec.ts
Normal file
77
dify/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(['❤️', '💙'])
|
||||
})
|
||||
})
|
||||
})
|
||||
11
dify/web/utils/emoji.ts
Normal file
11
dify/web/utils/emoji.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { SearchIndex } from 'emoji-mart'
|
||||
import type { Emoji } from '@emoji-mart/data'
|
||||
|
||||
export async function searchEmoji(value: string) {
|
||||
const emojis: Emoji[] = await SearchIndex.search(value) || []
|
||||
|
||||
const results = emojis.map((emoji) => {
|
||||
return emoji.skins[0].native
|
||||
})
|
||||
return results
|
||||
}
|
||||
52
dify/web/utils/error-parser.ts
Normal file
52
dify/web/utils/error-parser.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Parse plugin error message from nested error structure
|
||||
* Extracts the real error message from PluginInvokeError JSON string
|
||||
*
|
||||
* @example
|
||||
* Input: { message: "req_id: xxx PluginInvokeError: {\"message\":\"Bad credentials\"}" }
|
||||
* Output: "Bad credentials"
|
||||
*
|
||||
* @param error - Error object (can be Response object or error with message property)
|
||||
* @returns Promise<string> or string - Parsed error message
|
||||
*/
|
||||
export const parsePluginErrorMessage = async (error: any): Promise<string> => {
|
||||
let rawMessage = ''
|
||||
|
||||
// Handle Response object from fetch/ky
|
||||
if (error instanceof Response) {
|
||||
try {
|
||||
const body = await error.clone().json()
|
||||
rawMessage = body?.message || error.statusText || 'Unknown error'
|
||||
}
|
||||
catch {
|
||||
rawMessage = error.statusText || 'Unknown error'
|
||||
}
|
||||
}
|
||||
else {
|
||||
rawMessage = error?.message || error?.toString() || 'Unknown error'
|
||||
}
|
||||
|
||||
console.log('rawMessage', rawMessage)
|
||||
|
||||
// Try to extract nested JSON from PluginInvokeError
|
||||
// Use greedy match .+ to capture the complete JSON object with nested braces
|
||||
const pluginErrorPattern = /PluginInvokeError:\s*(\{.+\})/
|
||||
const match = rawMessage.match(pluginErrorPattern)
|
||||
|
||||
if (match) {
|
||||
try {
|
||||
const errorData = JSON.parse(match[1])
|
||||
// Return the inner message if exists
|
||||
if (errorData.message)
|
||||
return errorData.message
|
||||
// Fallback to error_type if message not available
|
||||
if (errorData.error_type)
|
||||
return errorData.error_type
|
||||
}
|
||||
catch (parseError) {
|
||||
console.warn('Failed to parse plugin error JSON:', parseError)
|
||||
}
|
||||
}
|
||||
|
||||
return rawMessage
|
||||
}
|
||||
196
dify/web/utils/format.spec.ts
Normal file
196
dify/web/utils/format.spec.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { downloadFile, formatFileSize, formatNumber, formatNumberAbbreviated, formatTime } from './format'
|
||||
|
||||
describe('formatNumber', () => {
|
||||
test('should correctly format integers', () => {
|
||||
expect(formatNumber(1234567)).toBe('1,234,567')
|
||||
})
|
||||
test('should correctly format decimals', () => {
|
||||
expect(formatNumber(1234567.89)).toBe('1,234,567.89')
|
||||
})
|
||||
test('should correctly handle string input', () => {
|
||||
expect(formatNumber('1234567')).toBe('1,234,567')
|
||||
})
|
||||
test('should correctly handle zero', () => {
|
||||
expect(formatNumber(0)).toBe(0)
|
||||
})
|
||||
test('should correctly handle negative numbers', () => {
|
||||
expect(formatNumber(-1234567)).toBe('-1,234,567')
|
||||
})
|
||||
test('should correctly handle empty input', () => {
|
||||
expect(formatNumber('')).toBe('')
|
||||
})
|
||||
})
|
||||
describe('formatFileSize', () => {
|
||||
test('should return the input if it is falsy', () => {
|
||||
expect(formatFileSize(0)).toBe(0)
|
||||
})
|
||||
test('should format bytes correctly', () => {
|
||||
expect(formatFileSize(500)).toBe('500.00 bytes')
|
||||
})
|
||||
test('should format kilobytes correctly', () => {
|
||||
expect(formatFileSize(1500)).toBe('1.46 KB')
|
||||
})
|
||||
test('should format megabytes correctly', () => {
|
||||
expect(formatFileSize(1500000)).toBe('1.43 MB')
|
||||
})
|
||||
test('should format gigabytes correctly', () => {
|
||||
expect(formatFileSize(1500000000)).toBe('1.40 GB')
|
||||
})
|
||||
test('should format terabytes correctly', () => {
|
||||
expect(formatFileSize(1500000000000)).toBe('1.36 TB')
|
||||
})
|
||||
test('should format petabytes correctly', () => {
|
||||
expect(formatFileSize(1500000000000000)).toBe('1.33 PB')
|
||||
})
|
||||
})
|
||||
describe('formatTime', () => {
|
||||
test('should return the input if it is falsy', () => {
|
||||
expect(formatTime(0)).toBe(0)
|
||||
})
|
||||
test('should format seconds correctly', () => {
|
||||
expect(formatTime(30)).toBe('30.00 sec')
|
||||
})
|
||||
test('should format minutes correctly', () => {
|
||||
expect(formatTime(90)).toBe('1.50 min')
|
||||
})
|
||||
test('should format hours correctly', () => {
|
||||
expect(formatTime(3600)).toBe('1.00 h')
|
||||
})
|
||||
test('should handle large numbers', () => {
|
||||
expect(formatTime(7200)).toBe('2.00 h')
|
||||
})
|
||||
})
|
||||
describe('downloadFile', () => {
|
||||
test('should create a link and trigger a download correctly', () => {
|
||||
// Mock data
|
||||
const blob = new Blob(['test content'], { type: 'text/plain' })
|
||||
const fileName = 'test-file.txt'
|
||||
const mockUrl = 'blob:mockUrl'
|
||||
|
||||
// Mock URL.createObjectURL
|
||||
const createObjectURLMock = jest.fn().mockReturnValue(mockUrl)
|
||||
const revokeObjectURLMock = jest.fn()
|
||||
Object.defineProperty(window.URL, 'createObjectURL', { value: createObjectURLMock })
|
||||
Object.defineProperty(window.URL, 'revokeObjectURL', { value: revokeObjectURLMock })
|
||||
|
||||
// Mock createElement and appendChild
|
||||
const mockLink = {
|
||||
href: '',
|
||||
download: '',
|
||||
click: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
}
|
||||
const createElementMock = jest.spyOn(document, 'createElement').mockReturnValue(mockLink as any)
|
||||
const appendChildMock = jest.spyOn(document.body, 'appendChild').mockImplementation((node: Node) => {
|
||||
return node
|
||||
})
|
||||
|
||||
// Call the function
|
||||
downloadFile({ data: blob, fileName })
|
||||
|
||||
// Assertions
|
||||
expect(createObjectURLMock).toHaveBeenCalledWith(blob)
|
||||
expect(createElementMock).toHaveBeenCalledWith('a')
|
||||
expect(mockLink.href).toBe(mockUrl)
|
||||
expect(mockLink.download).toBe(fileName)
|
||||
expect(appendChildMock).toHaveBeenCalledWith(mockLink)
|
||||
expect(mockLink.click).toHaveBeenCalled()
|
||||
expect(mockLink.remove).toHaveBeenCalled()
|
||||
expect(revokeObjectURLMock).toHaveBeenCalledWith(mockUrl)
|
||||
|
||||
// Clean up mocks
|
||||
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('1B')
|
||||
})
|
||||
|
||||
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('1M')
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
153
dify/web/utils/format.ts
Normal file
153
dify/web/utils/format.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import 'dayjs/locale/de'
|
||||
import 'dayjs/locale/es'
|
||||
import 'dayjs/locale/fa'
|
||||
import 'dayjs/locale/fr'
|
||||
import 'dayjs/locale/hi'
|
||||
import 'dayjs/locale/id'
|
||||
import 'dayjs/locale/it'
|
||||
import 'dayjs/locale/ja'
|
||||
import 'dayjs/locale/ko'
|
||||
import 'dayjs/locale/pl'
|
||||
import 'dayjs/locale/pt-br'
|
||||
import 'dayjs/locale/ro'
|
||||
import 'dayjs/locale/ru'
|
||||
import 'dayjs/locale/sl'
|
||||
import 'dayjs/locale/th'
|
||||
import 'dayjs/locale/tr'
|
||||
import 'dayjs/locale/uk'
|
||||
import 'dayjs/locale/vi'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import 'dayjs/locale/zh-tw'
|
||||
|
||||
const localeMap: Record<Locale, string> = {
|
||||
'en-US': 'en',
|
||||
'zh-Hans': 'zh-cn',
|
||||
'zh-Hant': 'zh-tw',
|
||||
'pt-BR': 'pt-br',
|
||||
'es-ES': 'es',
|
||||
'fr-FR': 'fr',
|
||||
'de-DE': 'de',
|
||||
'ja-JP': 'ja',
|
||||
'ko-KR': 'ko',
|
||||
'ru-RU': 'ru',
|
||||
'it-IT': 'it',
|
||||
'th-TH': 'th',
|
||||
'id-ID': 'id',
|
||||
'uk-UA': 'uk',
|
||||
'vi-VN': 'vi',
|
||||
'ro-RO': 'ro',
|
||||
'pl-PL': 'pl',
|
||||
'hi-IN': 'hi',
|
||||
'tr-TR': 'tr',
|
||||
'fa-IR': 'fa',
|
||||
'sl-SI': 'sl',
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a number with comma separators.
|
||||
* @example formatNumber(1234567) will return '1,234,567'
|
||||
* @example formatNumber(1234567.89) will return '1,234,567.89'
|
||||
*/
|
||||
export const formatNumber = (num: number | string) => {
|
||||
if (!num)
|
||||
return num
|
||||
const parts = num.toString().split('.')
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
return parts.join('.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size into standard string format.
|
||||
* @param fileSize file size (Byte)
|
||||
* @example formatFileSize(1024) will return '1.00 KB'
|
||||
* @example formatFileSize(1024 * 1024) will return '1.00 MB'
|
||||
*/
|
||||
export const formatFileSize = (fileSize: number) => {
|
||||
if (!fileSize)
|
||||
return fileSize
|
||||
const units = ['', 'K', 'M', 'G', 'T', 'P']
|
||||
let index = 0
|
||||
while (fileSize >= 1024 && index < units.length) {
|
||||
fileSize = fileSize / 1024
|
||||
index++
|
||||
}
|
||||
if (index === 0)
|
||||
return `${fileSize.toFixed(2)} bytes`
|
||||
return `${fileSize.toFixed(2)} ${units[index]}B`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time into standard string format.
|
||||
* @example formatTime(60) will return '1.00 min'
|
||||
* @example formatTime(60 * 60) will return '1.00 h'
|
||||
*/
|
||||
export const formatTime = (seconds: number) => {
|
||||
if (!seconds)
|
||||
return seconds
|
||||
const units = ['sec', 'min', 'h']
|
||||
let index = 0
|
||||
while (seconds >= 60 && index < units.length) {
|
||||
seconds = seconds / 60
|
||||
index++
|
||||
}
|
||||
return `${seconds.toFixed(2)} ${units[index]}`
|
||||
}
|
||||
|
||||
export const downloadFile = ({ data, fileName }: { data: Blob; fileName: string }) => {
|
||||
const url = window.URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a number into a readable string using "k", "M", or "B" suffix.
|
||||
* @example
|
||||
* 950 => "950"
|
||||
* 1200 => "1.2k"
|
||||
* 1500000 => "1.5M"
|
||||
* 2000000000 => "2B"
|
||||
*
|
||||
* @param {number} num - The number to format
|
||||
* @returns {string} - The formatted number string
|
||||
*/
|
||||
export const formatNumberAbbreviated = (num: number) => {
|
||||
// If less than 1000, return as-is
|
||||
if (num < 1000) return num.toString()
|
||||
|
||||
// Define thresholds and suffixes
|
||||
const units = [
|
||||
{ value: 1e9, symbol: 'B' },
|
||||
{ value: 1e6, symbol: 'M' },
|
||||
{ value: 1e3, symbol: 'k' },
|
||||
]
|
||||
|
||||
for (let i = 0; i < units.length; i++) {
|
||||
if (num >= units[i].value) {
|
||||
const value = num / units[i].value
|
||||
let rounded = Math.round(value * 10) / 10
|
||||
let unitIndex = i
|
||||
|
||||
// If rounded value >= 1000, promote to next unit
|
||||
if (rounded >= 1000 && i > 0) {
|
||||
rounded = rounded / 1000
|
||||
unitIndex = i - 1
|
||||
}
|
||||
|
||||
const formatted = rounded.toFixed(1)
|
||||
return formatted.endsWith('.0')
|
||||
? `${Number.parseInt(formatted)}${units[unitIndex].symbol}`
|
||||
: `${formatted}${units[unitIndex].symbol}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const formatToLocalTime = (time: Dayjs, local: string, format: string) => {
|
||||
return time.locale(localeMap[local] ?? 'en').format(format)
|
||||
}
|
||||
162
dify/web/utils/get-icon.spec.ts
Normal file
162
dify/web/utils/get-icon.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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`)
|
||||
})
|
||||
|
||||
/**
|
||||
* Security tests: Path traversal attempts
|
||||
* These tests document current behavior and potential security concerns
|
||||
* Note: Current implementation does not sanitize path traversal sequences
|
||||
*/
|
||||
test('handles path traversal attempts', () => {
|
||||
const pluginId = '../../../etc/passwd'
|
||||
const result = getIconFromMarketPlace(pluginId)
|
||||
// Current implementation includes path traversal sequences in URL
|
||||
// This is a potential security concern that should be addressed
|
||||
expect(result).toContain('../')
|
||||
expect(result).toContain(pluginId)
|
||||
})
|
||||
|
||||
test('handles multiple path traversal attempts', () => {
|
||||
const pluginId = '../../../../etc/passwd'
|
||||
const result = getIconFromMarketPlace(pluginId)
|
||||
// Current implementation includes path traversal sequences in URL
|
||||
expect(result).toContain('../')
|
||||
expect(result).toContain(pluginId)
|
||||
})
|
||||
|
||||
test('passes through URL-encoded path traversal sequences', () => {
|
||||
const pluginId = '..%2F..%2Fetc%2Fpasswd'
|
||||
const result = getIconFromMarketPlace(pluginId)
|
||||
expect(result).toContain(pluginId)
|
||||
})
|
||||
|
||||
/**
|
||||
* Security tests: Null and undefined handling
|
||||
* These tests document current behavior with invalid input types
|
||||
* Note: Current implementation converts null/undefined to strings instead of throwing
|
||||
*/
|
||||
test('handles null plugin ID', () => {
|
||||
// Current implementation converts null to string "null"
|
||||
const result = getIconFromMarketPlace(null as any)
|
||||
expect(result).toContain('null')
|
||||
// This is a potential issue - should validate input type
|
||||
})
|
||||
|
||||
test('handles undefined plugin ID', () => {
|
||||
// Current implementation converts undefined to string "undefined"
|
||||
const result = getIconFromMarketPlace(undefined as any)
|
||||
expect(result).toContain('undefined')
|
||||
// This is a potential issue - should validate input type
|
||||
})
|
||||
|
||||
/**
|
||||
* Security tests: URL-sensitive characters
|
||||
* These tests verify that URL-sensitive characters are handled appropriately
|
||||
*/
|
||||
test('does not encode URL-sensitive characters', () => {
|
||||
const pluginId = 'plugin/with?special=chars#hash'
|
||||
const result = getIconFromMarketPlace(pluginId)
|
||||
// Note: Current implementation doesn't encode, but test documents the behavior
|
||||
expect(result).toContain(pluginId)
|
||||
expect(result).toContain('?')
|
||||
expect(result).toContain('#')
|
||||
expect(result).toContain('=')
|
||||
})
|
||||
|
||||
test('handles URL characters like & and %', () => {
|
||||
const pluginId = 'plugin&with%encoding'
|
||||
const result = getIconFromMarketPlace(pluginId)
|
||||
expect(result).toContain(pluginId)
|
||||
})
|
||||
|
||||
/**
|
||||
* Edge case tests: Extreme inputs
|
||||
* These tests verify behavior with unusual but valid inputs
|
||||
*/
|
||||
test('handles very long plugin ID', () => {
|
||||
const pluginId = 'a'.repeat(10000)
|
||||
const result = getIconFromMarketPlace(pluginId)
|
||||
expect(result).toContain(pluginId)
|
||||
expect(result.length).toBeGreaterThan(10000)
|
||||
})
|
||||
|
||||
test('handles Unicode characters', () => {
|
||||
const pluginId = '插件-🚀-测试-日本語'
|
||||
const result = getIconFromMarketPlace(pluginId)
|
||||
expect(result).toContain(pluginId)
|
||||
})
|
||||
|
||||
test('handles control characters', () => {
|
||||
const pluginId = 'plugin\nwith\ttabs\r\nand\0null'
|
||||
const result = getIconFromMarketPlace(pluginId)
|
||||
expect(result).toContain(pluginId)
|
||||
})
|
||||
|
||||
/**
|
||||
* Security tests: XSS attempts
|
||||
* These tests verify that XSS attempts are handled appropriately
|
||||
*/
|
||||
test('handles XSS attempts with script tags', () => {
|
||||
const pluginId = '<script>alert("xss")</script>'
|
||||
const result = getIconFromMarketPlace(pluginId)
|
||||
expect(result).toContain(pluginId)
|
||||
// Note: Current implementation doesn't sanitize, but test documents the behavior
|
||||
})
|
||||
|
||||
test('handles XSS attempts with event handlers', () => {
|
||||
const pluginId = 'plugin"onerror="alert(1)"'
|
||||
const result = getIconFromMarketPlace(pluginId)
|
||||
expect(result).toContain(pluginId)
|
||||
})
|
||||
|
||||
test('handles XSS attempts with encoded script tags', () => {
|
||||
const pluginId = '%3Cscript%3Ealert%28%22xss%22%29%3C%2Fscript%3E'
|
||||
const result = getIconFromMarketPlace(pluginId)
|
||||
expect(result).toContain(pluginId)
|
||||
})
|
||||
})
|
||||
})
|
||||
5
dify/web/utils/get-icon.ts
Normal file
5
dify/web/utils/get-icon.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
|
||||
export const getIconFromMarketPlace = (plugin_id: string) => {
|
||||
return `${MARKETPLACE_API_PREFIX}/plugins/${plugin_id}/icon`
|
||||
}
|
||||
600
dify/web/utils/index.spec.ts
Normal file
600
dify/web/utils/index.spec.ts
Normal file
@@ -0,0 +1,600 @@
|
||||
import {
|
||||
asyncRunSafe,
|
||||
canFindTool,
|
||||
correctModelProvider,
|
||||
correctToolProvider,
|
||||
fetchWithRetry,
|
||||
getPurifyHref,
|
||||
getTextWidthWithCanvas,
|
||||
randomString,
|
||||
removeSpecificQueryParam,
|
||||
sleep,
|
||||
} from './index'
|
||||
|
||||
describe('sleep', () => {
|
||||
it('should wait for the specified time', async () => {
|
||||
const timeVariance = 10
|
||||
const sleepTime = 100
|
||||
const start = Date.now()
|
||||
await sleep(sleepTime)
|
||||
const elapsed = Date.now() - start
|
||||
expect(elapsed).toBeGreaterThanOrEqual(sleepTime - timeVariance)
|
||||
})
|
||||
})
|
||||
|
||||
describe('asyncRunSafe', () => {
|
||||
it('should return [null, result] when promise resolves', async () => {
|
||||
const result = await asyncRunSafe(Promise.resolve('success'))
|
||||
expect(result).toEqual([null, 'success'])
|
||||
})
|
||||
|
||||
it('should return [error] when promise rejects', async () => {
|
||||
const error = new Error('test error')
|
||||
const result = await asyncRunSafe(Promise.reject(error))
|
||||
expect(result).toEqual([error])
|
||||
})
|
||||
|
||||
it('should return [Error] when promise rejects with undefined', async () => {
|
||||
// eslint-disable-next-line prefer-promise-reject-errors
|
||||
const result = await asyncRunSafe(Promise.reject())
|
||||
expect(result[0]).toBeInstanceOf(Error)
|
||||
expect(result[0]?.message).toBe('unknown error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTextWidthWithCanvas', () => {
|
||||
let originalCreateElement: typeof document.createElement
|
||||
|
||||
beforeEach(() => {
|
||||
// Store original implementation
|
||||
originalCreateElement = document.createElement
|
||||
|
||||
// Mock canvas and context
|
||||
const measureTextMock = jest.fn().mockReturnValue({ width: 100 })
|
||||
const getContextMock = jest.fn().mockReturnValue({
|
||||
measureText: measureTextMock,
|
||||
font: '',
|
||||
})
|
||||
|
||||
document.createElement = jest.fn().mockReturnValue({
|
||||
getContext: getContextMock,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original implementation
|
||||
document.createElement = originalCreateElement
|
||||
})
|
||||
|
||||
it('should return the width of text', () => {
|
||||
const width = getTextWidthWithCanvas('test text')
|
||||
expect(width).toBe(100)
|
||||
})
|
||||
|
||||
it('should return 0 if context is not available', () => {
|
||||
// Override mock for this test
|
||||
document.createElement = jest.fn().mockReturnValue({
|
||||
getContext: () => null,
|
||||
})
|
||||
|
||||
const width = getTextWidthWithCanvas('test text')
|
||||
expect(width).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('randomString', () => {
|
||||
it('should generate string of specified length', () => {
|
||||
const result = randomString(10)
|
||||
expect(result.length).toBe(10)
|
||||
})
|
||||
|
||||
it('should only contain valid characters', () => {
|
||||
const result = randomString(100)
|
||||
const validChars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'
|
||||
for (const char of result)
|
||||
expect(validChars).toContain(char)
|
||||
})
|
||||
|
||||
it('should generate different strings on consecutive calls', () => {
|
||||
const result1 = randomString(20)
|
||||
const result2 = randomString(20)
|
||||
expect(result1).not.toEqual(result2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPurifyHref', () => {
|
||||
it('should return empty string for falsy input', () => {
|
||||
expect(getPurifyHref('')).toBe('')
|
||||
expect(getPurifyHref(undefined as any)).toBe('')
|
||||
})
|
||||
|
||||
it('should escape HTML characters', () => {
|
||||
expect(getPurifyHref('<script>alert("xss")</script>')).not.toContain('<script>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchWithRetry', () => {
|
||||
it('should return successfully on first try', async () => {
|
||||
const successData = { status: 'success' }
|
||||
const promise = Promise.resolve(successData)
|
||||
|
||||
const result = await fetchWithRetry(promise)
|
||||
|
||||
expect(result).toEqual([null, successData])
|
||||
})
|
||||
|
||||
// it('should retry and succeed on second attempt', async () => {
|
||||
// let attemptCount = 0
|
||||
// const mockFn = new Promise((resolve, reject) => {
|
||||
// attemptCount++
|
||||
// if (attemptCount === 1)
|
||||
// reject(new Error('First attempt failed'))
|
||||
// else
|
||||
// resolve('success')
|
||||
// })
|
||||
|
||||
// const result = await fetchWithRetry(mockFn)
|
||||
|
||||
// expect(result).toEqual([null, 'success'])
|
||||
// expect(attemptCount).toBe(2)
|
||||
// })
|
||||
|
||||
// it('should stop after max retries and return last error', async () => {
|
||||
// const testError = new Error('Test error')
|
||||
// const promise = Promise.reject(testError)
|
||||
|
||||
// const result = await fetchWithRetry(promise, 2)
|
||||
|
||||
// expect(result).toEqual([testError])
|
||||
// })
|
||||
|
||||
// it('should handle non-Error rejection with custom error', async () => {
|
||||
// const stringError = 'string error message'
|
||||
// const promise = Promise.reject(stringError)
|
||||
|
||||
// const result = await fetchWithRetry(promise, 0)
|
||||
|
||||
// expect(result[0]).toBeInstanceOf(Error)
|
||||
// expect(result[0]?.message).toBe('unknown error')
|
||||
// })
|
||||
|
||||
// it('should use default 3 retries when retries parameter is not provided', async () => {
|
||||
// let attempts = 0
|
||||
// const mockFn = () => new Promise((resolve, reject) => {
|
||||
// attempts++
|
||||
// reject(new Error(`Attempt ${attempts} failed`))
|
||||
// })
|
||||
|
||||
// await fetchWithRetry(mockFn())
|
||||
|
||||
// expect(attempts).toBe(4) // Initial attempt + 3 retries
|
||||
// })
|
||||
})
|
||||
|
||||
describe('correctModelProvider', () => {
|
||||
it('should return empty string for falsy input', () => {
|
||||
expect(correctModelProvider('')).toBe('')
|
||||
})
|
||||
|
||||
it('should return the provider if it already contains a slash', () => {
|
||||
expect(correctModelProvider('company/model')).toBe('company/model')
|
||||
})
|
||||
|
||||
it('should format google provider correctly', () => {
|
||||
expect(correctModelProvider('google')).toBe('langgenius/gemini/google')
|
||||
})
|
||||
|
||||
it('should format standard providers correctly', () => {
|
||||
expect(correctModelProvider('openai')).toBe('langgenius/openai/openai')
|
||||
})
|
||||
})
|
||||
|
||||
describe('correctToolProvider', () => {
|
||||
it('should return empty string for falsy input', () => {
|
||||
expect(correctToolProvider('')).toBe('')
|
||||
})
|
||||
|
||||
it('should return the provider if toolInCollectionList is true', () => {
|
||||
expect(correctToolProvider('any-provider', true)).toBe('any-provider')
|
||||
})
|
||||
|
||||
it('should return the provider if it already contains a slash', () => {
|
||||
expect(correctToolProvider('company/tool')).toBe('company/tool')
|
||||
})
|
||||
|
||||
it('should format special tool providers correctly', () => {
|
||||
expect(correctToolProvider('stepfun')).toBe('langgenius/stepfun_tool/stepfun')
|
||||
expect(correctToolProvider('jina')).toBe('langgenius/jina_tool/jina')
|
||||
})
|
||||
|
||||
it('should format standard tool providers correctly', () => {
|
||||
expect(correctToolProvider('standard')).toBe('langgenius/standard/standard')
|
||||
})
|
||||
})
|
||||
|
||||
describe('canFindTool', () => {
|
||||
it('should match when IDs are identical', () => {
|
||||
expect(canFindTool('tool-id', 'tool-id')).toBe(true)
|
||||
})
|
||||
|
||||
it('should match when provider ID is formatted with standard pattern', () => {
|
||||
expect(canFindTool('langgenius/tool-id/tool-id', 'tool-id')).toBe(true)
|
||||
})
|
||||
|
||||
it('should match when provider ID is formatted with tool pattern', () => {
|
||||
expect(canFindTool('langgenius/tool-id_tool/tool-id', 'tool-id')).toBe(true)
|
||||
})
|
||||
|
||||
it('should not match when IDs are completely different', () => {
|
||||
expect(canFindTool('provider-a', 'tool-b')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeSpecificQueryParam', () => {
|
||||
let originalLocation: Location
|
||||
let originalReplaceState: typeof window.history.replaceState
|
||||
|
||||
beforeEach(() => {
|
||||
originalLocation = window.location
|
||||
originalReplaceState = window.history.replaceState
|
||||
|
||||
const mockUrl = new URL('https://example.com?param1=value1¶m2=value2¶m3=value3')
|
||||
|
||||
// Mock window.location using defineProperty to handle URL properly
|
||||
delete (window as any).location
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: {
|
||||
...originalLocation,
|
||||
href: mockUrl.href,
|
||||
search: mockUrl.search,
|
||||
toString: () => mockUrl.toString(),
|
||||
},
|
||||
})
|
||||
|
||||
window.history.replaceState = jest.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
window.history.replaceState = originalReplaceState
|
||||
})
|
||||
|
||||
it('should remove a single query parameter', () => {
|
||||
removeSpecificQueryParam('param2')
|
||||
expect(window.history.replaceState).toHaveBeenCalledTimes(1)
|
||||
const replaceStateCall = (window.history.replaceState as jest.Mock).mock.calls[0]
|
||||
expect(replaceStateCall[0]).toBe(null)
|
||||
expect(replaceStateCall[1]).toBe('')
|
||||
expect(replaceStateCall[2]).toMatch(/param1=value1/)
|
||||
expect(replaceStateCall[2]).toMatch(/param3=value3/)
|
||||
expect(replaceStateCall[2]).not.toMatch(/param2=value2/)
|
||||
})
|
||||
|
||||
it('should remove multiple query parameters', () => {
|
||||
removeSpecificQueryParam(['param1', 'param3'])
|
||||
expect(window.history.replaceState).toHaveBeenCalledTimes(1)
|
||||
const replaceStateCall = (window.history.replaceState as jest.Mock).mock.calls[0]
|
||||
expect(replaceStateCall[2]).toMatch(/param2=value2/)
|
||||
expect(replaceStateCall[2]).not.toMatch(/param1=value1/)
|
||||
expect(replaceStateCall[2]).not.toMatch(/param3=value3/)
|
||||
})
|
||||
|
||||
it('should handle non-existent parameters gracefully', () => {
|
||||
removeSpecificQueryParam('nonexistent')
|
||||
|
||||
expect(window.history.replaceState).toHaveBeenCalledTimes(1)
|
||||
const replaceStateCall = (window.history.replaceState as jest.Mock).mock.calls[0]
|
||||
expect(replaceStateCall[2]).toMatch(/param1=value1/)
|
||||
expect(replaceStateCall[2]).toMatch(/param2=value2/)
|
||||
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')
|
||||
})
|
||||
})
|
||||
101
dify/web/utils/index.ts
Normal file
101
dify/web/utils/index.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { escape } from 'lodash-es'
|
||||
|
||||
export const sleep = (ms: number) => {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export async function asyncRunSafe<T = any>(fn: Promise<T>): Promise<[Error] | [null, T]> {
|
||||
try {
|
||||
return [null, await fn]
|
||||
}
|
||||
catch (e: any) {
|
||||
return [e || new Error('unknown error')]
|
||||
}
|
||||
}
|
||||
|
||||
export const getTextWidthWithCanvas = (text: string, font?: string) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.font = font ?? '12px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'
|
||||
return Number(ctx.measureText(text).width.toFixed(2))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'
|
||||
|
||||
export function randomString(length: number) {
|
||||
let result = ''
|
||||
for (let i = length; i > 0; --i) result += chars[Math.floor(Math.random() * chars.length)]
|
||||
return result
|
||||
}
|
||||
|
||||
export const getPurifyHref = (href: string) => {
|
||||
if (!href)
|
||||
return ''
|
||||
|
||||
return escape(href)
|
||||
}
|
||||
|
||||
export async function fetchWithRetry<T = any>(fn: Promise<T>, retries = 3): Promise<[Error] | [null, T]> {
|
||||
const [error, res] = await asyncRunSafe(fn)
|
||||
if (error) {
|
||||
if (retries > 0) {
|
||||
const res = await fetchWithRetry(fn, retries - 1)
|
||||
return res
|
||||
}
|
||||
else {
|
||||
if (error instanceof Error)
|
||||
return [error]
|
||||
return [new Error('unknown error')]
|
||||
}
|
||||
}
|
||||
else {
|
||||
return [null, res]
|
||||
}
|
||||
}
|
||||
|
||||
export const correctModelProvider = (provider: string) => {
|
||||
if (!provider)
|
||||
return ''
|
||||
|
||||
if (provider.includes('/'))
|
||||
return provider
|
||||
|
||||
if (['google'].includes(provider))
|
||||
return 'langgenius/gemini/google'
|
||||
|
||||
return `langgenius/${provider}/${provider}`
|
||||
}
|
||||
|
||||
export const correctToolProvider = (provider: string, toolInCollectionList?: boolean) => {
|
||||
if (!provider)
|
||||
return ''
|
||||
|
||||
if (toolInCollectionList)
|
||||
return provider
|
||||
|
||||
if (provider.includes('/'))
|
||||
return provider
|
||||
|
||||
if (['stepfun', 'jina', 'siliconflow', 'gitee_ai'].includes(provider))
|
||||
return `langgenius/${provider}_tool/${provider}`
|
||||
|
||||
return `langgenius/${provider}/${provider}`
|
||||
}
|
||||
|
||||
export const canFindTool = (providerId: string, oldToolId?: string) => {
|
||||
return providerId === oldToolId
|
||||
|| providerId === `langgenius/${oldToolId}/${oldToolId}`
|
||||
|| providerId === `langgenius/${oldToolId}_tool/${oldToolId}`
|
||||
}
|
||||
|
||||
export const removeSpecificQueryParam = (key: string | string[]) => {
|
||||
const url = new URL(window.location.href)
|
||||
if (Array.isArray(key))
|
||||
key.forEach(k => url.searchParams.delete(k))
|
||||
else
|
||||
url.searchParams.delete(key)
|
||||
window.history.replaceState(null, '', url.toString())
|
||||
}
|
||||
88
dify/web/utils/mcp.spec.ts
Normal file
88
dify/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)
|
||||
})
|
||||
})
|
||||
})
|
||||
22
dify/web/utils/mcp.ts
Normal file
22
dify/web/utils/mcp.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* MCP (Model Context Protocol) utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Determines if the MCP icon should be used based on the icon source
|
||||
* @param src - The icon source, can be a string URL or an object with content and background
|
||||
* @returns true if the MCP icon should be used (when it's an emoji object with 🔗 content)
|
||||
*/
|
||||
export const shouldUseMcpIcon = (src: any): boolean => {
|
||||
return typeof src === 'object' && src?.content === '🔗'
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an app icon should use the MCP icon
|
||||
* @param iconType - The type of icon ('emoji' | 'image')
|
||||
* @param icon - The icon content (emoji or file ID)
|
||||
* @returns true if the MCP icon should be used
|
||||
*/
|
||||
export const shouldUseMcpIconForAppIcon = (iconType: string, icon: string): boolean => {
|
||||
return iconType === 'emoji' && icon === '🔗'
|
||||
}
|
||||
819
dify/web/utils/model-config.spec.ts
Normal file
819
dify/web/utils/model-config.spec.ts
Normal file
@@ -0,0 +1,819 @@
|
||||
/**
|
||||
* Test suite for model configuration transformation utilities
|
||||
*
|
||||
* This module handles the conversion between two different representations of user input forms:
|
||||
* 1. UserInputFormItem: The form structure used in the UI
|
||||
* 2. PromptVariable: The variable structure used in prompts and model configuration
|
||||
*
|
||||
* Key functions:
|
||||
* - userInputsFormToPromptVariables: Converts UI form items to prompt variables
|
||||
* - promptVariablesToUserInputsForm: Converts prompt variables back to form items
|
||||
* - formatBooleanInputs: Ensures boolean inputs are properly typed
|
||||
*/
|
||||
import {
|
||||
formatBooleanInputs,
|
||||
promptVariablesToUserInputsForm,
|
||||
userInputsFormToPromptVariables,
|
||||
} from './model-config'
|
||||
import type { UserInputFormItem } from '@/types/app'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
|
||||
describe('Model Config Utilities', () => {
|
||||
describe('userInputsFormToPromptVariables', () => {
|
||||
/**
|
||||
* Test handling of null or undefined input
|
||||
* Should return empty array when no inputs provided
|
||||
*/
|
||||
it('should return empty array for null input', () => {
|
||||
const result = userInputsFormToPromptVariables(null)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of text-input (string) type
|
||||
* Text inputs are the most common form field type
|
||||
*/
|
||||
it('should convert text-input to string prompt variable', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
'text-input': {
|
||||
label: 'User Name',
|
||||
variable: 'user_name',
|
||||
required: true,
|
||||
max_length: 100,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({
|
||||
key: 'user_name',
|
||||
name: 'User Name',
|
||||
required: true,
|
||||
type: 'string',
|
||||
max_length: 100,
|
||||
options: [],
|
||||
is_context_var: false,
|
||||
hide: false,
|
||||
default: '',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of paragraph type
|
||||
* Paragraphs are multi-line text inputs
|
||||
*/
|
||||
it('should convert paragraph to paragraph prompt variable', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
paragraph: {
|
||||
label: 'Description',
|
||||
variable: 'description',
|
||||
required: false,
|
||||
max_length: 500,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
key: 'description',
|
||||
name: 'Description',
|
||||
required: false,
|
||||
type: 'paragraph',
|
||||
max_length: 500,
|
||||
options: [],
|
||||
is_context_var: false,
|
||||
hide: false,
|
||||
default: '',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of number type
|
||||
* Number inputs should preserve numeric constraints
|
||||
*/
|
||||
it('should convert number input to number prompt variable', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
number: {
|
||||
label: 'Age',
|
||||
variable: 'age',
|
||||
required: true,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
} as any,
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
key: 'age',
|
||||
name: 'Age',
|
||||
required: true,
|
||||
type: 'number',
|
||||
options: [],
|
||||
hide: false,
|
||||
default: '',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of checkbox (boolean) type
|
||||
* Checkboxes are converted to 'checkbox' type in prompt variables
|
||||
*/
|
||||
it('should convert checkbox to checkbox prompt variable', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
checkbox: {
|
||||
label: 'Accept Terms',
|
||||
variable: 'accept_terms',
|
||||
required: true,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
} as any,
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
key: 'accept_terms',
|
||||
name: 'Accept Terms',
|
||||
required: true,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
hide: false,
|
||||
default: '',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of select (dropdown) type
|
||||
* Select inputs include options array
|
||||
*/
|
||||
it('should convert select input to select prompt variable', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
select: {
|
||||
label: 'Country',
|
||||
variable: 'country',
|
||||
required: true,
|
||||
options: ['USA', 'Canada', 'Mexico'],
|
||||
default: 'USA',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
key: 'country',
|
||||
name: 'Country',
|
||||
required: true,
|
||||
type: 'select',
|
||||
options: ['USA', 'Canada', 'Mexico'],
|
||||
is_context_var: false,
|
||||
hide: false,
|
||||
default: 'USA',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of file upload type
|
||||
* File inputs include configuration for allowed types and upload methods
|
||||
*/
|
||||
it('should convert file input to file prompt variable', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
file: {
|
||||
label: 'Profile Picture',
|
||||
variable: 'profile_pic',
|
||||
required: false,
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.jpg', '.png'],
|
||||
allowed_file_upload_methods: ['local_file', 'remote_url'],
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
} as any,
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
key: 'profile_pic',
|
||||
name: 'Profile Picture',
|
||||
required: false,
|
||||
type: 'file',
|
||||
config: {
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.jpg', '.png'],
|
||||
allowed_file_upload_methods: ['local_file', 'remote_url'],
|
||||
number_limits: 1,
|
||||
},
|
||||
hide: false,
|
||||
default: '',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of file-list type
|
||||
* File lists allow multiple file uploads with a max_length constraint
|
||||
*/
|
||||
it('should convert file-list input to file-list prompt variable', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
'file-list': {
|
||||
label: 'Documents',
|
||||
variable: 'documents',
|
||||
required: true,
|
||||
allowed_file_types: ['document'],
|
||||
allowed_file_extensions: ['.pdf', '.docx'],
|
||||
allowed_file_upload_methods: ['local_file'],
|
||||
max_length: 5,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
} as any,
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
key: 'documents',
|
||||
name: 'Documents',
|
||||
required: true,
|
||||
type: 'file-list',
|
||||
config: {
|
||||
allowed_file_types: ['document'],
|
||||
allowed_file_extensions: ['.pdf', '.docx'],
|
||||
allowed_file_upload_methods: ['local_file'],
|
||||
number_limits: 5,
|
||||
},
|
||||
hide: false,
|
||||
default: '',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of external_data_tool type
|
||||
* External data tools have custom configuration and icons
|
||||
*/
|
||||
it('should convert external_data_tool to prompt variable', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
external_data_tool: {
|
||||
label: 'API Data',
|
||||
variable: 'api_data',
|
||||
type: 'api',
|
||||
enabled: true,
|
||||
required: false,
|
||||
config: { endpoint: 'https://api.example.com' },
|
||||
icon: 'api-icon',
|
||||
icon_background: '#FF5733',
|
||||
hide: false,
|
||||
},
|
||||
} as any,
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
key: 'api_data',
|
||||
name: 'API Data',
|
||||
required: false,
|
||||
type: 'api',
|
||||
enabled: true,
|
||||
config: { endpoint: 'https://api.example.com' },
|
||||
icon: 'api-icon',
|
||||
icon_background: '#FF5733',
|
||||
is_context_var: false,
|
||||
hide: false,
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test handling of dataset_query_variable
|
||||
* When a variable matches the dataset_query_variable, is_context_var should be true
|
||||
*/
|
||||
it('should mark variable as context var when matching dataset_query_variable', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
'text-input': {
|
||||
label: 'Query',
|
||||
variable: 'query',
|
||||
required: true,
|
||||
max_length: 200,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs, 'query')
|
||||
|
||||
expect(result[0].is_context_var).toBe(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of multiple mixed input types
|
||||
* Should handle an array with different input types correctly
|
||||
*/
|
||||
it('should convert multiple mixed input types', () => {
|
||||
const userInputs: UserInputFormItem[] = [
|
||||
{
|
||||
'text-input': {
|
||||
label: 'Name',
|
||||
variable: 'name',
|
||||
required: true,
|
||||
max_length: 50,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
number: {
|
||||
label: 'Age',
|
||||
variable: 'age',
|
||||
required: false,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
select: {
|
||||
label: 'Gender',
|
||||
variable: 'gender',
|
||||
required: true,
|
||||
options: ['Male', 'Female', 'Other'],
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = userInputsFormToPromptVariables(userInputs)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].type).toBe('string')
|
||||
expect(result[1].type).toBe('number')
|
||||
expect(result[2].type).toBe('select')
|
||||
})
|
||||
})
|
||||
|
||||
describe('promptVariablesToUserInputsForm', () => {
|
||||
/**
|
||||
* Test conversion of string prompt variable back to text-input
|
||||
*/
|
||||
it('should convert string prompt variable to text-input', () => {
|
||||
const promptVariables: PromptVariable[] = [
|
||||
{
|
||||
key: 'user_name',
|
||||
name: 'User Name',
|
||||
required: true,
|
||||
type: 'string',
|
||||
max_length: 100,
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const result = promptVariablesToUserInputsForm(promptVariables)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({
|
||||
'text-input': {
|
||||
label: 'User Name',
|
||||
variable: 'user_name',
|
||||
required: true,
|
||||
max_length: 100,
|
||||
default: '',
|
||||
hide: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of paragraph prompt variable
|
||||
*/
|
||||
it('should convert paragraph prompt variable to paragraph input', () => {
|
||||
const promptVariables: PromptVariable[] = [
|
||||
{
|
||||
key: 'description',
|
||||
name: 'Description',
|
||||
required: false,
|
||||
type: 'paragraph',
|
||||
max_length: 500,
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const result = promptVariablesToUserInputsForm(promptVariables)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
paragraph: {
|
||||
label: 'Description',
|
||||
variable: 'description',
|
||||
required: false,
|
||||
max_length: 500,
|
||||
default: '',
|
||||
hide: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of number prompt variable
|
||||
*/
|
||||
it('should convert number prompt variable to number input', () => {
|
||||
const promptVariables: PromptVariable[] = [
|
||||
{
|
||||
key: 'age',
|
||||
name: 'Age',
|
||||
required: true,
|
||||
type: 'number',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const result = promptVariablesToUserInputsForm(promptVariables)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
number: {
|
||||
label: 'Age',
|
||||
variable: 'age',
|
||||
required: true,
|
||||
default: '',
|
||||
hide: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of checkbox prompt variable
|
||||
*/
|
||||
it('should convert checkbox prompt variable to checkbox input', () => {
|
||||
const promptVariables: PromptVariable[] = [
|
||||
{
|
||||
key: 'accept_terms',
|
||||
name: 'Accept Terms',
|
||||
required: true,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const result = promptVariablesToUserInputsForm(promptVariables)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
checkbox: {
|
||||
label: 'Accept Terms',
|
||||
variable: 'accept_terms',
|
||||
required: true,
|
||||
default: '',
|
||||
hide: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of select prompt variable
|
||||
*/
|
||||
it('should convert select prompt variable to select input', () => {
|
||||
const promptVariables: PromptVariable[] = [
|
||||
{
|
||||
key: 'country',
|
||||
name: 'Country',
|
||||
required: true,
|
||||
type: 'select',
|
||||
options: ['USA', 'Canada', 'Mexico'],
|
||||
default: 'USA',
|
||||
},
|
||||
]
|
||||
|
||||
const result = promptVariablesToUserInputsForm(promptVariables)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
select: {
|
||||
label: 'Country',
|
||||
variable: 'country',
|
||||
required: true,
|
||||
options: ['USA', 'Canada', 'Mexico'],
|
||||
default: 'USA',
|
||||
hide: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test filtering of invalid prompt variables
|
||||
* Variables without key or name should be filtered out
|
||||
*/
|
||||
it('should filter out variables with empty key or name', () => {
|
||||
const promptVariables: PromptVariable[] = [
|
||||
{
|
||||
key: '',
|
||||
name: 'Empty Key',
|
||||
required: true,
|
||||
type: 'string',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'valid',
|
||||
name: '',
|
||||
required: true,
|
||||
type: 'string',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: ' ',
|
||||
name: 'Whitespace Key',
|
||||
required: true,
|
||||
type: 'string',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'valid_key',
|
||||
name: 'Valid Name',
|
||||
required: true,
|
||||
type: 'string',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const result = promptVariablesToUserInputsForm(promptVariables)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect((result[0] as any)['text-input']?.variable).toBe('valid_key')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of external data tool prompt variable
|
||||
*/
|
||||
it('should convert external data tool prompt variable', () => {
|
||||
const promptVariables: PromptVariable[] = [
|
||||
{
|
||||
key: 'api_data',
|
||||
name: 'API Data',
|
||||
required: false,
|
||||
type: 'api',
|
||||
enabled: true,
|
||||
config: { endpoint: 'https://api.example.com' },
|
||||
icon: 'api-icon',
|
||||
icon_background: '#FF5733',
|
||||
},
|
||||
]
|
||||
|
||||
const result = promptVariablesToUserInputsForm(promptVariables)
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
external_data_tool: {
|
||||
label: 'API Data',
|
||||
variable: 'api_data',
|
||||
enabled: true,
|
||||
type: 'api',
|
||||
config: { endpoint: 'https://api.example.com' },
|
||||
required: false,
|
||||
icon: 'api-icon',
|
||||
icon_background: '#FF5733',
|
||||
hide: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that required defaults to true when not explicitly set to false
|
||||
*/
|
||||
it('should default required to true when not false', () => {
|
||||
const promptVariables: PromptVariable[] = [
|
||||
{
|
||||
key: 'test1',
|
||||
name: 'Test 1',
|
||||
required: undefined,
|
||||
type: 'string',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'test2',
|
||||
name: 'Test 2',
|
||||
required: false,
|
||||
type: 'string',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const result = promptVariablesToUserInputsForm(promptVariables)
|
||||
|
||||
expect((result[0] as any)['text-input']?.required).toBe(true)
|
||||
expect((result[1] as any)['text-input']?.required).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatBooleanInputs', () => {
|
||||
/**
|
||||
* Test that null or undefined inputs are handled gracefully
|
||||
*/
|
||||
it('should return inputs unchanged when useInputs is null', () => {
|
||||
const inputs = { key1: 'value1', key2: 'value2' }
|
||||
const result = formatBooleanInputs(null, inputs)
|
||||
expect(result).toEqual(inputs)
|
||||
})
|
||||
|
||||
it('should return inputs unchanged when useInputs is undefined', () => {
|
||||
const inputs = { key1: 'value1', key2: 'value2' }
|
||||
const result = formatBooleanInputs(undefined, inputs)
|
||||
expect(result).toEqual(inputs)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test conversion of boolean input values to actual boolean type
|
||||
* This is important for proper type handling in the backend
|
||||
* Note: checkbox inputs are converted to type 'checkbox' by userInputsFormToPromptVariables
|
||||
*/
|
||||
it('should convert boolean inputs to boolean type', () => {
|
||||
const useInputs: PromptVariable[] = [
|
||||
{
|
||||
key: 'accept_terms',
|
||||
name: 'Accept Terms',
|
||||
required: true,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'subscribe',
|
||||
name: 'Subscribe',
|
||||
required: false,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const inputs = {
|
||||
accept_terms: 'true',
|
||||
subscribe: '',
|
||||
other_field: 'value',
|
||||
}
|
||||
|
||||
const result = formatBooleanInputs(useInputs, inputs)
|
||||
|
||||
expect(result).toEqual({
|
||||
accept_terms: true,
|
||||
subscribe: false,
|
||||
other_field: 'value',
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that non-boolean inputs are not affected
|
||||
*/
|
||||
it('should not modify non-boolean inputs', () => {
|
||||
const useInputs: PromptVariable[] = [
|
||||
{
|
||||
key: 'name',
|
||||
name: 'Name',
|
||||
required: true,
|
||||
type: 'string',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'age',
|
||||
name: 'Age',
|
||||
required: true,
|
||||
type: 'number',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const inputs = {
|
||||
name: 'John Doe',
|
||||
age: 30,
|
||||
}
|
||||
|
||||
const result = formatBooleanInputs(useInputs, inputs)
|
||||
|
||||
expect(result).toEqual(inputs)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test handling of truthy and falsy values for boolean conversion
|
||||
* Note: checkbox inputs are converted to type 'checkbox' by userInputsFormToPromptVariables
|
||||
*/
|
||||
it('should handle various truthy and falsy values', () => {
|
||||
const useInputs: PromptVariable[] = [
|
||||
{
|
||||
key: 'bool1',
|
||||
name: 'Bool 1',
|
||||
required: true,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'bool2',
|
||||
name: 'Bool 2',
|
||||
required: true,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'bool3',
|
||||
name: 'Bool 3',
|
||||
required: true,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
key: 'bool4',
|
||||
name: 'Bool 4',
|
||||
required: true,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const inputs = {
|
||||
bool1: 1,
|
||||
bool2: 0,
|
||||
bool3: 'yes',
|
||||
bool4: null as any,
|
||||
}
|
||||
|
||||
const result = formatBooleanInputs(useInputs, inputs)
|
||||
|
||||
expect(result?.bool1).toBe(true)
|
||||
expect(result?.bool2).toBe(false)
|
||||
expect(result?.bool3).toBe(true)
|
||||
expect(result?.bool4).toBe(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the function creates a new object and doesn't mutate the original
|
||||
* Note: checkbox inputs are converted to type 'checkbox' by userInputsFormToPromptVariables
|
||||
*/
|
||||
it('should not mutate original inputs object', () => {
|
||||
const useInputs: PromptVariable[] = [
|
||||
{
|
||||
key: 'flag',
|
||||
name: 'Flag',
|
||||
required: true,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
const inputs = { flag: 'true', other: 'value' }
|
||||
const originalInputs = { ...inputs }
|
||||
|
||||
formatBooleanInputs(useInputs, inputs)
|
||||
|
||||
expect(inputs).toEqual(originalInputs)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Round-trip conversion', () => {
|
||||
/**
|
||||
* Test that converting from UserInputForm to PromptVariable and back
|
||||
* preserves the essential data (though some fields may have defaults applied)
|
||||
*/
|
||||
it('should preserve data through round-trip conversion', () => {
|
||||
const originalUserInputs: UserInputFormItem[] = [
|
||||
{
|
||||
'text-input': {
|
||||
label: 'Name',
|
||||
variable: 'name',
|
||||
required: true,
|
||||
max_length: 50,
|
||||
default: '',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
select: {
|
||||
label: 'Type',
|
||||
variable: 'type',
|
||||
required: false,
|
||||
options: ['A', 'B', 'C'],
|
||||
default: 'A',
|
||||
hide: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const promptVars = userInputsFormToPromptVariables(originalUserInputs)
|
||||
const backToUserInputs = promptVariablesToUserInputsForm(promptVars)
|
||||
|
||||
expect(backToUserInputs).toHaveLength(2)
|
||||
expect((backToUserInputs[0] as any)['text-input']?.variable).toBe('name')
|
||||
expect((backToUserInputs[1] as any).select?.variable).toBe('type')
|
||||
expect((backToUserInputs[1] as any).select?.options).toEqual(['A', 'B', 'C'])
|
||||
})
|
||||
})
|
||||
})
|
||||
210
dify/web/utils/model-config.ts
Normal file
210
dify/web/utils/model-config.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type { UserInputFormItem } from '@/types/app'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
|
||||
export const userInputsFormToPromptVariables = (useInputs: UserInputFormItem[] | null, dataset_query_variable?: string) => {
|
||||
if (!useInputs)
|
||||
return []
|
||||
const promptVariables: PromptVariable[] = []
|
||||
useInputs.forEach((item: any) => {
|
||||
const isParagraph = !!item.paragraph
|
||||
|
||||
const [type, content] = (() => {
|
||||
if (isParagraph)
|
||||
return ['paragraph', item.paragraph]
|
||||
|
||||
if (item['text-input'])
|
||||
return ['string', item['text-input']]
|
||||
|
||||
if (item.number)
|
||||
return ['number', item.number]
|
||||
|
||||
if (item.checkbox)
|
||||
return ['boolean', item.checkbox]
|
||||
|
||||
if (item.file)
|
||||
return ['file', item.file]
|
||||
|
||||
if (item['file-list'])
|
||||
return ['file-list', item['file-list']]
|
||||
|
||||
if (item.external_data_tool)
|
||||
return [item.external_data_tool.type, item.external_data_tool]
|
||||
|
||||
if (item.json_object)
|
||||
return ['json_object', item.json_object]
|
||||
|
||||
return ['select', item.select || {}]
|
||||
})()
|
||||
const is_context_var = dataset_query_variable === content?.variable
|
||||
|
||||
if (type === 'string' || type === 'paragraph') {
|
||||
promptVariables.push({
|
||||
key: content.variable,
|
||||
name: content.label,
|
||||
required: content.required,
|
||||
type,
|
||||
max_length: content.max_length,
|
||||
options: [],
|
||||
is_context_var,
|
||||
hide: content.hide,
|
||||
default: content.default,
|
||||
})
|
||||
}
|
||||
else if (type === 'number') {
|
||||
promptVariables.push({
|
||||
key: content.variable,
|
||||
name: content.label,
|
||||
required: content.required,
|
||||
type,
|
||||
options: [],
|
||||
hide: content.hide,
|
||||
default: content.default,
|
||||
})
|
||||
}
|
||||
else if (type === 'boolean') {
|
||||
promptVariables.push({
|
||||
key: content.variable,
|
||||
name: content.label,
|
||||
required: content.required,
|
||||
type: 'checkbox',
|
||||
options: [],
|
||||
hide: content.hide,
|
||||
default: content.default,
|
||||
})
|
||||
}
|
||||
else if (type === 'select') {
|
||||
promptVariables.push({
|
||||
key: content.variable,
|
||||
name: content.label,
|
||||
required: content.required,
|
||||
type: 'select',
|
||||
options: content.options,
|
||||
is_context_var,
|
||||
hide: content.hide,
|
||||
default: content.default,
|
||||
})
|
||||
}
|
||||
else if (type === 'file') {
|
||||
promptVariables.push({
|
||||
key: content.variable,
|
||||
name: content.label,
|
||||
required: content.required,
|
||||
type,
|
||||
config: {
|
||||
allowed_file_types: content.allowed_file_types,
|
||||
allowed_file_extensions: content.allowed_file_extensions,
|
||||
allowed_file_upload_methods: content.allowed_file_upload_methods,
|
||||
number_limits: 1,
|
||||
},
|
||||
hide: content.hide,
|
||||
default: content.default,
|
||||
})
|
||||
}
|
||||
else if (type === 'file-list') {
|
||||
promptVariables.push({
|
||||
key: content.variable,
|
||||
name: content.label,
|
||||
required: content.required,
|
||||
type,
|
||||
config: {
|
||||
allowed_file_types: content.allowed_file_types,
|
||||
allowed_file_extensions: content.allowed_file_extensions,
|
||||
allowed_file_upload_methods: content.allowed_file_upload_methods,
|
||||
number_limits: content.max_length,
|
||||
},
|
||||
hide: content.hide,
|
||||
default: content.default,
|
||||
})
|
||||
}
|
||||
else {
|
||||
promptVariables.push({
|
||||
key: content.variable,
|
||||
name: content.label,
|
||||
required: content.required,
|
||||
type: content.type,
|
||||
enabled: content.enabled,
|
||||
config: content.config,
|
||||
icon: content.icon,
|
||||
icon_background: content.icon_background,
|
||||
is_context_var,
|
||||
hide: content.hide,
|
||||
})
|
||||
}
|
||||
})
|
||||
return promptVariables
|
||||
}
|
||||
|
||||
export const promptVariablesToUserInputsForm = (promptVariables: PromptVariable[]) => {
|
||||
const userInputs: UserInputFormItem[] = []
|
||||
promptVariables.filter(({ key, name }) => {
|
||||
return key && key.trim() && name && name.trim()
|
||||
}).forEach((item: any) => {
|
||||
if (item.type === 'string' || item.type === 'paragraph') {
|
||||
userInputs.push({
|
||||
[item.type === 'string' ? 'text-input' : 'paragraph']: {
|
||||
label: item.name,
|
||||
variable: item.key,
|
||||
required: item.required !== false, // default true
|
||||
max_length: item.max_length,
|
||||
default: '',
|
||||
hide: item.hide,
|
||||
},
|
||||
} as any)
|
||||
return
|
||||
}
|
||||
if (item.type === 'number' || item.type === 'checkbox') {
|
||||
userInputs.push({
|
||||
[item.type]: {
|
||||
label: item.name,
|
||||
variable: item.key,
|
||||
required: item.required !== false, // default true
|
||||
default: '',
|
||||
hide: item.hide,
|
||||
},
|
||||
} as any)
|
||||
}
|
||||
else if (item.type === 'select') {
|
||||
userInputs.push({
|
||||
select: {
|
||||
label: item.name,
|
||||
variable: item.key,
|
||||
required: item.required !== false, // default true
|
||||
options: item.options,
|
||||
default: item.default ?? '',
|
||||
hide: item.hide,
|
||||
},
|
||||
} as any)
|
||||
}
|
||||
else {
|
||||
userInputs.push({
|
||||
external_data_tool: {
|
||||
label: item.name,
|
||||
variable: item.key,
|
||||
enabled: item.enabled,
|
||||
type: item.type,
|
||||
config: item.config,
|
||||
required: item.required,
|
||||
icon: item.icon,
|
||||
icon_background: item.icon_background,
|
||||
hide: item.hide,
|
||||
},
|
||||
} as any)
|
||||
}
|
||||
})
|
||||
|
||||
return userInputs
|
||||
}
|
||||
|
||||
export const formatBooleanInputs = (useInputs?: PromptVariable[] | null, inputs?: Record<string, string | number | object | boolean> | null) => {
|
||||
if(!useInputs)
|
||||
return inputs
|
||||
const res = { ...inputs }
|
||||
useInputs.forEach((item) => {
|
||||
const isBooleanInput = item.type === 'checkbox'
|
||||
if (isBooleanInput) {
|
||||
// Convert boolean inputs to boolean type
|
||||
res[item.key] = !!res[item.key]
|
||||
}
|
||||
})
|
||||
return res
|
||||
}
|
||||
297
dify/web/utils/navigation.spec.ts
Normal file
297
dify/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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
189
dify/web/utils/navigation.ts
Normal file
189
dify/web/utils/navigation.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Navigation Utilities
|
||||
*
|
||||
* Provides helper functions for consistent navigation behavior throughout the application,
|
||||
* specifically for preserving query parameters when navigating between related pages.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a navigation path that preserves current URL query parameters
|
||||
*
|
||||
* @param basePath - The base path to navigate to (e.g., '/datasets/123/documents')
|
||||
* @param preserveParams - Whether to preserve current query parameters (default: true)
|
||||
* @returns The complete navigation path with preserved query parameters
|
||||
*
|
||||
* @example
|
||||
* // Current URL: /datasets/123/documents/456?page=3&limit=10&keyword=test
|
||||
* const backPath = createNavigationPath('/datasets/123/documents')
|
||||
* // Returns: '/datasets/123/documents?page=3&limit=10&keyword=test'
|
||||
*
|
||||
* @example
|
||||
* // Navigate without preserving params
|
||||
* const cleanPath = createNavigationPath('/datasets/123/documents', false)
|
||||
* // Returns: '/datasets/123/documents'
|
||||
*/
|
||||
export function createNavigationPath(basePath: string, preserveParams: boolean = true): string {
|
||||
if (!preserveParams)
|
||||
return basePath
|
||||
|
||||
try {
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const queryString = searchParams.toString()
|
||||
const separator = queryString ? '?' : ''
|
||||
return `${basePath}${separator}${queryString}`
|
||||
}
|
||||
catch (error) {
|
||||
// Fallback to base path if there's any error accessing location
|
||||
console.warn('Failed to preserve query parameters:', error)
|
||||
return basePath
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a back navigation function that preserves query parameters
|
||||
*
|
||||
* @param router - Next.js router instance
|
||||
* @param basePath - The base path to navigate back to
|
||||
* @param preserveParams - Whether to preserve current query parameters (default: true)
|
||||
* @returns A function that navigates back with preserved parameters
|
||||
*
|
||||
* @example
|
||||
* const router = useRouter()
|
||||
* const backToPrev = createBackNavigation(router, `/datasets/${datasetId}/documents`)
|
||||
*
|
||||
* // Later, when user clicks back:
|
||||
* backToPrev()
|
||||
*/
|
||||
export function createBackNavigation(
|
||||
router: { push: (path: string) => void },
|
||||
basePath: string,
|
||||
preserveParams: boolean = true,
|
||||
): () => void {
|
||||
return () => {
|
||||
const navigationPath = createNavigationPath(basePath, preserveParams)
|
||||
router.push(navigationPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts specific query parameters from current URL
|
||||
*
|
||||
* @param paramNames - Array of parameter names to extract
|
||||
* @returns Object with extracted parameters
|
||||
*
|
||||
* @example
|
||||
* // Current URL: /page?page=3&limit=10&keyword=test&other=value
|
||||
* const params = extractQueryParams(['page', 'limit', 'keyword'])
|
||||
* // Returns: { page: '3', limit: '10', keyword: 'test' }
|
||||
*/
|
||||
export function extractQueryParams(paramNames: string[]): Record<string, string> {
|
||||
try {
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const extracted: Record<string, string> = {}
|
||||
|
||||
paramNames.forEach((name) => {
|
||||
const value = searchParams.get(name)
|
||||
if (value !== null)
|
||||
extracted[name] = value
|
||||
})
|
||||
|
||||
return extracted
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Failed to extract query parameters:', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a navigation path with specific query parameters
|
||||
*
|
||||
* @param basePath - The base path
|
||||
* @param params - Object of query parameters to include
|
||||
* @returns Navigation path with specified parameters
|
||||
*
|
||||
* @example
|
||||
* const path = createNavigationPathWithParams('/datasets/123/documents', {
|
||||
* page: '1',
|
||||
* limit: '25',
|
||||
* keyword: 'search term'
|
||||
* })
|
||||
* // Returns: '/datasets/123/documents?page=1&limit=25&keyword=search+term'
|
||||
*/
|
||||
export function createNavigationPathWithParams(
|
||||
basePath: string,
|
||||
params: Record<string, string | number>,
|
||||
): string {
|
||||
try {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '')
|
||||
searchParams.set(key, String(value))
|
||||
})
|
||||
|
||||
const queryString = searchParams.toString()
|
||||
const separator = queryString ? '?' : ''
|
||||
return `${basePath}${separator}${queryString}`
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('Failed to create navigation path with params:', error)
|
||||
return basePath
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges current query parameters with new ones
|
||||
*
|
||||
* @param newParams - New parameters to add or override
|
||||
* @param preserveExisting - Whether to preserve existing parameters (default: true)
|
||||
* @returns URLSearchParams object with merged parameters
|
||||
*
|
||||
* @example
|
||||
* // Current URL: /page?page=3&limit=10
|
||||
* const merged = mergeQueryParams({ keyword: 'test', page: '1' })
|
||||
* // Results in: page=1&limit=10&keyword=test (page overridden, limit preserved, keyword added)
|
||||
*/
|
||||
export function mergeQueryParams(
|
||||
newParams: Record<string, string | number | null | undefined>,
|
||||
preserveExisting: boolean = true,
|
||||
): URLSearchParams {
|
||||
const searchParams = preserveExisting
|
||||
? new URLSearchParams(window.location.search)
|
||||
: new URLSearchParams()
|
||||
|
||||
Object.entries(newParams).forEach(([key, value]) => {
|
||||
if (value === null || value === undefined)
|
||||
searchParams.delete(key)
|
||||
else if (value !== '')
|
||||
searchParams.set(key, String(value))
|
||||
})
|
||||
|
||||
return searchParams
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation utilities for common dataset/document patterns
|
||||
*/
|
||||
export const datasetNavigation = {
|
||||
/**
|
||||
* Creates navigation back to dataset documents list with preserved state
|
||||
*/
|
||||
backToDocuments: (router: { push: (path: string) => void }, datasetId: string) => {
|
||||
return createBackNavigation(router, `/datasets/${datasetId}/documents`)
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates navigation to document detail
|
||||
*/
|
||||
toDocumentDetail: (router: { push: (path: string) => void }, datasetId: string, documentId: string) => {
|
||||
return () => router.push(`/datasets/${datasetId}/documents/${documentId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates navigation to document settings
|
||||
*/
|
||||
toDocumentSettings: (router: { push: (path: string) => void }, datasetId: string, documentId: string) => {
|
||||
return () => router.push(`/datasets/${datasetId}/documents/${documentId}/settings`)
|
||||
},
|
||||
}
|
||||
95
dify/web/utils/permission.spec.ts
Normal file
95
dify/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)
|
||||
})
|
||||
})
|
||||
})
|
||||
18
dify/web/utils/permission.ts
Normal file
18
dify/web/utils/permission.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { DatasetPermission } from '@/models/datasets'
|
||||
|
||||
type DatasetConfig = {
|
||||
createdBy: string
|
||||
partialMemberList: string[]
|
||||
permission: DatasetPermission
|
||||
}
|
||||
|
||||
export const hasEditPermissionForDataset = (userId: string, datasetConfig: DatasetConfig) => {
|
||||
const { createdBy, partialMemberList, permission } = datasetConfig
|
||||
if (permission === DatasetPermission.onlyMe)
|
||||
return userId === createdBy
|
||||
if (permission === DatasetPermission.allTeamMembers)
|
||||
return true
|
||||
if (permission === DatasetPermission.partialMembers)
|
||||
return partialMemberList.includes(userId)
|
||||
return false
|
||||
}
|
||||
26
dify/web/utils/plugin-version-feature.spec.ts
Normal file
26
dify/web/utils/plugin-version-feature.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { isSupportMCP } from './plugin-version-feature'
|
||||
|
||||
describe('plugin-version-feature', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('isSupportMCP', () => {
|
||||
it('should call isEqualOrLaterThanVersion with the correct parameters', () => {
|
||||
expect(isSupportMCP('0.0.3')).toBe(true)
|
||||
expect(isSupportMCP('1.0.0')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when version is equal to the supported MCP version', () => {
|
||||
const mockVersion = '0.0.2'
|
||||
const result = isSupportMCP(mockVersion)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when version is less than the supported MCP version', () => {
|
||||
const mockVersion = '0.0.1'
|
||||
const result = isSupportMCP(mockVersion)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
10
dify/web/utils/plugin-version-feature.ts
Normal file
10
dify/web/utils/plugin-version-feature.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { isEqualOrLaterThanVersion } from './semver'
|
||||
|
||||
const SUPPORT_MCP_VERSION = '0.0.2'
|
||||
|
||||
export const isSupportMCP = (version?: string): boolean => {
|
||||
if (!version)
|
||||
return false
|
||||
|
||||
return isEqualOrLaterThanVersion(version, SUPPORT_MCP_VERSION)
|
||||
}
|
||||
75
dify/web/utils/semver.spec.ts
Normal file
75
dify/web/utils/semver.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { compareVersion, getLatestVersion, isEqualOrLaterThanVersion } from './semver'
|
||||
|
||||
describe('semver utilities', () => {
|
||||
describe('getLatestVersion', () => {
|
||||
it('should return the latest version from a list of versions', () => {
|
||||
expect(getLatestVersion(['1.0.0', '1.1.0', '1.0.1'])).toBe('1.1.0')
|
||||
expect(getLatestVersion(['2.0.0', '1.9.9', '1.10.0'])).toBe('2.0.0')
|
||||
expect(getLatestVersion(['1.0.0-alpha', '1.0.0-beta', '1.0.0'])).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('should handle patch versions correctly', () => {
|
||||
expect(getLatestVersion(['1.0.1', '1.0.2', '1.0.0'])).toBe('1.0.2')
|
||||
expect(getLatestVersion(['1.0.10', '1.0.9', '1.0.11'])).toBe('1.0.11')
|
||||
})
|
||||
|
||||
it('should handle mixed version formats', () => {
|
||||
expect(getLatestVersion(['v1.0.0', '1.1.0', 'v1.2.0'])).toBe('v1.2.0')
|
||||
expect(getLatestVersion(['1.0.0-rc.1', '1.0.0', '1.0.0-beta'])).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('should return the only version if only one version is provided', () => {
|
||||
expect(getLatestVersion(['1.0.0'])).toBe('1.0.0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('compareVersion', () => {
|
||||
it('should return 1 when first version is greater', () => {
|
||||
expect(compareVersion('1.1.0', '1.0.0')).toBe(1)
|
||||
expect(compareVersion('2.0.0', '1.9.9')).toBe(1)
|
||||
expect(compareVersion('1.0.1', '1.0.0')).toBe(1)
|
||||
})
|
||||
|
||||
it('should return -1 when first version is less', () => {
|
||||
expect(compareVersion('1.0.0', '1.1.0')).toBe(-1)
|
||||
expect(compareVersion('1.9.9', '2.0.0')).toBe(-1)
|
||||
expect(compareVersion('1.0.0', '1.0.1')).toBe(-1)
|
||||
})
|
||||
|
||||
it('should return 0 when versions are equal', () => {
|
||||
expect(compareVersion('1.0.0', '1.0.0')).toBe(0)
|
||||
expect(compareVersion('2.1.3', '2.1.3')).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle pre-release versions correctly', () => {
|
||||
expect(compareVersion('1.0.0-beta', '1.0.0-alpha')).toBe(1)
|
||||
expect(compareVersion('1.0.0', '1.0.0-beta')).toBe(1)
|
||||
expect(compareVersion('1.0.0-alpha', '1.0.0-beta')).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEqualOrLaterThanVersion', () => {
|
||||
it('should return true when baseVersion is greater than targetVersion', () => {
|
||||
expect(isEqualOrLaterThanVersion('1.1.0', '1.0.0')).toBe(true)
|
||||
expect(isEqualOrLaterThanVersion('2.0.0', '1.9.9')).toBe(true)
|
||||
expect(isEqualOrLaterThanVersion('1.0.1', '1.0.0')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when baseVersion is equal to targetVersion', () => {
|
||||
expect(isEqualOrLaterThanVersion('1.0.0', '1.0.0')).toBe(true)
|
||||
expect(isEqualOrLaterThanVersion('2.1.3', '2.1.3')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when baseVersion is less than targetVersion', () => {
|
||||
expect(isEqualOrLaterThanVersion('1.0.0', '1.1.0')).toBe(false)
|
||||
expect(isEqualOrLaterThanVersion('1.9.9', '2.0.0')).toBe(false)
|
||||
expect(isEqualOrLaterThanVersion('1.0.0', '1.0.1')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle pre-release versions correctly', () => {
|
||||
expect(isEqualOrLaterThanVersion('1.0.0', '1.0.0-beta')).toBe(true)
|
||||
expect(isEqualOrLaterThanVersion('1.0.0-beta', '1.0.0-alpha')).toBe(true)
|
||||
expect(isEqualOrLaterThanVersion('1.0.0-alpha', '1.0.0')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
13
dify/web/utils/semver.ts
Normal file
13
dify/web/utils/semver.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import semver from 'semver'
|
||||
|
||||
export const getLatestVersion = (versionList: string[]) => {
|
||||
return semver.rsort(versionList)[0]
|
||||
}
|
||||
|
||||
export const compareVersion = (v1: string, v2: string) => {
|
||||
return semver.compare(v1, v2)
|
||||
}
|
||||
|
||||
export const isEqualOrLaterThanVersion = (baseVersion: string, targetVersion: string) => {
|
||||
return semver.gte(baseVersion, targetVersion)
|
||||
}
|
||||
100
dify/web/utils/time.spec.ts
Normal file
100
dify/web/utils/time.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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' })
|
||||
// Account for timezone differences: UTC-5 to UTC+8 can result in 2024-01-14 or 2024-01-15
|
||||
expect(result).toMatch(/^2024-01-(14|15)$/)
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
19
dify/web/utils/time.ts
Normal file
19
dify/web/utils/time.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import dayjs, { type ConfigType } from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
|
||||
dayjs.extend(utc)
|
||||
|
||||
export const isAfter = (date: ConfigType, compare: ConfigType) => {
|
||||
return dayjs(date).isAfter(dayjs(compare))
|
||||
}
|
||||
|
||||
export const formatTime = ({ date, dateFormat }: { date: ConfigType; dateFormat: string }) => {
|
||||
return dayjs(date).format(dateFormat)
|
||||
}
|
||||
|
||||
export const getDaysUntilEndOfMonth = (date: ConfigType = dayjs()) => {
|
||||
const current = dayjs(date).startOf('day')
|
||||
const endOfMonth = dayjs(date).endOf('month').startOf('day')
|
||||
const diff = endOfMonth.diff(current, 'day')
|
||||
return Math.max(diff, 0)
|
||||
}
|
||||
1274
dify/web/utils/timezone.json
Normal file
1274
dify/web/utils/timezone.json
Normal file
File diff suppressed because it is too large
Load Diff
7
dify/web/utils/timezone.ts
Normal file
7
dify/web/utils/timezone.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import tz from './timezone.json'
|
||||
|
||||
type Item = {
|
||||
value: number | string
|
||||
name: string
|
||||
}
|
||||
export const timezones: Item[] = tz
|
||||
79
dify/web/utils/tool-call.spec.ts
Normal file
79
dify/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)
|
||||
})
|
||||
})
|
||||
})
|
||||
6
dify/web/utils/tool-call.ts
Normal file
6
dify/web/utils/tool-call.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
|
||||
export const supportFunctionCall = (features: ModelFeatureEnum[] = []): boolean => {
|
||||
if (!features || !features.length) return false
|
||||
return features.some(feature => [ModelFeatureEnum.toolCall, ModelFeatureEnum.multiToolCall, ModelFeatureEnum.streamToolCall].includes(feature))
|
||||
}
|
||||
49
dify/web/utils/urlValidation.spec.ts
Normal file
49
dify/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')
|
||||
})
|
||||
})
|
||||
})
|
||||
64
dify/web/utils/urlValidation.ts
Normal file
64
dify/web/utils/urlValidation.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Validates that a URL is safe for redirection.
|
||||
* Only allows HTTP and HTTPS protocols to prevent XSS attacks.
|
||||
*
|
||||
* @param url - The URL string to validate
|
||||
* @throws Error if the URL has an unsafe protocol
|
||||
*/
|
||||
export function validateRedirectUrl(url: string): void {
|
||||
try {
|
||||
const parsedUrl = new URL(url)
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:')
|
||||
throw new Error('Authorization URL must be HTTP or HTTPS')
|
||||
}
|
||||
catch (error) {
|
||||
if (
|
||||
error instanceof Error
|
||||
&& error.message === 'Authorization URL must be HTTP or HTTPS'
|
||||
)
|
||||
throw error
|
||||
// If URL parsing fails, it's also invalid
|
||||
throw new Error(`Invalid URL: ${url}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if URL is a private/local network address or cloud debug URL
|
||||
* @param url - The URL string to check
|
||||
* @returns true if the URL is a private/local address or cloud debug URL
|
||||
*/
|
||||
export function isPrivateOrLocalAddress(url: string): boolean {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const hostname = urlObj.hostname.toLowerCase()
|
||||
|
||||
// Check for localhost
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1')
|
||||
return true
|
||||
|
||||
// Check for private IP ranges
|
||||
const ipv4Regex = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/
|
||||
const ipv4Match = hostname.match(ipv4Regex)
|
||||
if (ipv4Match) {
|
||||
const [, a, b] = ipv4Match.map(Number)
|
||||
// 10.0.0.0/8
|
||||
if (a === 10)
|
||||
return true
|
||||
// 172.16.0.0/12
|
||||
if (a === 172 && b >= 16 && b <= 31)
|
||||
return true
|
||||
// 192.168.0.0/16
|
||||
if (a === 192 && b === 168)
|
||||
return true
|
||||
// 169.254.0.0/16 (link-local)
|
||||
if (a === 169 && b === 254)
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for .local domains
|
||||
return hostname.endsWith('.local')
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
139
dify/web/utils/validators.spec.ts
Normal file
139
dify/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')
|
||||
})
|
||||
})
|
||||
})
|
||||
27
dify/web/utils/validators.ts
Normal file
27
dify/web/utils/validators.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Schema } from 'jsonschema'
|
||||
import { Validator } from 'jsonschema'
|
||||
import draft07Schema from './draft-07.json'
|
||||
|
||||
const validator = new Validator()
|
||||
|
||||
export const draft07Validator = (schema: any) => {
|
||||
return validator.validate(schema, draft07Schema as unknown as Schema)
|
||||
}
|
||||
|
||||
export const forbidBooleanProperties = (schema: any, path: string[] = []): string[] => {
|
||||
let errors: string[] = []
|
||||
|
||||
if (schema && typeof schema === 'object' && schema.properties) {
|
||||
for (const [key, val] of Object.entries(schema.properties)) {
|
||||
if (typeof val === 'boolean') {
|
||||
errors.push(
|
||||
`Error: Property '${[...path, key].join('.')}' must not be a boolean schema`,
|
||||
)
|
||||
}
|
||||
else if (typeof val === 'object') {
|
||||
errors = errors.concat(forbidBooleanProperties(val, [...path, key]))
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
236
dify/web/utils/var.spec.ts
Normal file
236
dify/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')
|
||||
})
|
||||
})
|
||||
})
|
||||
147
dify/web/utils/var.ts
Normal file
147
dify/web/utils/var.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { MARKETPLACE_URL_PREFIX, MAX_VAR_KEY_LENGTH, VAR_ITEM_TEMPLATE, VAR_ITEM_TEMPLATE_IN_WORKFLOW, getMaxVarNameLength } from '@/config'
|
||||
import {
|
||||
CONTEXT_PLACEHOLDER_TEXT,
|
||||
HISTORY_PLACEHOLDER_TEXT,
|
||||
PRE_PROMPT_PLACEHOLDER_TEXT,
|
||||
QUERY_PLACEHOLDER_TEXT,
|
||||
} from '@/app/components/base/prompt-editor/constants'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
|
||||
const otherAllowedRegex = /^\w+$/
|
||||
|
||||
export const getNewVar = (key: string, type: string) => {
|
||||
const { ...rest } = VAR_ITEM_TEMPLATE
|
||||
if (type !== 'string') {
|
||||
return {
|
||||
...rest,
|
||||
type: type || 'string',
|
||||
key,
|
||||
name: key.slice(0, getMaxVarNameLength(key)),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...VAR_ITEM_TEMPLATE,
|
||||
type: type || 'string',
|
||||
key,
|
||||
name: key.slice(0, getMaxVarNameLength(key)),
|
||||
}
|
||||
}
|
||||
|
||||
export const getNewVarInWorkflow = (key: string, type = InputVarType.textInput): InputVar => {
|
||||
const { max_length: _maxLength, ...rest } = VAR_ITEM_TEMPLATE_IN_WORKFLOW
|
||||
if (type !== InputVarType.textInput) {
|
||||
return {
|
||||
...rest,
|
||||
type,
|
||||
variable: key,
|
||||
label: key.slice(0, getMaxVarNameLength(key)),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...VAR_ITEM_TEMPLATE_IN_WORKFLOW,
|
||||
type,
|
||||
variable: key,
|
||||
label: key.slice(0, getMaxVarNameLength(key)),
|
||||
placeholder: '',
|
||||
default: '',
|
||||
hint: '',
|
||||
}
|
||||
}
|
||||
|
||||
export const checkKey = (key: string, canBeEmpty?: boolean, _keys?: string[]) => {
|
||||
if (key.length === 0 && !canBeEmpty)
|
||||
return 'canNoBeEmpty'
|
||||
|
||||
if (canBeEmpty && key === '')
|
||||
return true
|
||||
|
||||
if (key.length > MAX_VAR_KEY_LENGTH)
|
||||
return 'tooLong'
|
||||
|
||||
if (otherAllowedRegex.test(key)) {
|
||||
if (/\d/.test(key[0]))
|
||||
return 'notStartWithNumber'
|
||||
|
||||
return true
|
||||
}
|
||||
return 'notValid'
|
||||
}
|
||||
|
||||
export const checkKeys = (keys: string[], canBeEmpty?: boolean) => {
|
||||
let isValid = true
|
||||
let errorKey = ''
|
||||
let errorMessageKey = ''
|
||||
keys.forEach((key) => {
|
||||
if (!isValid)
|
||||
return
|
||||
|
||||
const res = checkKey(key, canBeEmpty)
|
||||
if (res !== true) {
|
||||
isValid = false
|
||||
errorKey = key
|
||||
errorMessageKey = res
|
||||
}
|
||||
})
|
||||
return { isValid, errorKey, errorMessageKey }
|
||||
}
|
||||
|
||||
export const hasDuplicateStr = (strArr: string[]) => {
|
||||
const strObj: Record<string, number> = {}
|
||||
strArr.forEach((str) => {
|
||||
if (strObj[str])
|
||||
strObj[str] += 1
|
||||
else
|
||||
strObj[str] = 1
|
||||
})
|
||||
return !!Object.keys(strObj).find(key => strObj[key] > 1)
|
||||
}
|
||||
|
||||
const varRegex = /\{\{([a-zA-Z_]\w*)\}\}/g
|
||||
export const getVars = (value: string) => {
|
||||
if (!value)
|
||||
return []
|
||||
|
||||
const keys = value.match(varRegex)?.filter((item) => {
|
||||
return ![CONTEXT_PLACEHOLDER_TEXT, HISTORY_PLACEHOLDER_TEXT, QUERY_PLACEHOLDER_TEXT, PRE_PROMPT_PLACEHOLDER_TEXT].includes(item)
|
||||
}).map((item) => {
|
||||
return item.replace('{{', '').replace('}}', '')
|
||||
}).filter(key => key.length <= MAX_VAR_KEY_LENGTH) || []
|
||||
const keyObj: Record<string, boolean> = {}
|
||||
// remove duplicate keys
|
||||
const res: string[] = []
|
||||
keys.forEach((key) => {
|
||||
if (keyObj[key])
|
||||
return
|
||||
|
||||
keyObj[key] = true
|
||||
res.push(key)
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
// Set the value of basePath
|
||||
// example: /dify
|
||||
export const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''
|
||||
|
||||
export function getMarketplaceUrl(path: string, params?: Record<string, string | undefined>) {
|
||||
const searchParams = new URLSearchParams({ source: encodeURIComponent(window.location.origin) })
|
||||
if (params) {
|
||||
Object.keys(params).forEach((key) => {
|
||||
const value = params[key]
|
||||
if (value !== undefined && value !== null)
|
||||
searchParams.append(key, value)
|
||||
})
|
||||
}
|
||||
return `${MARKETPLACE_URL_PREFIX}${path}?${searchParams.toString()}`
|
||||
}
|
||||
|
||||
export const replaceSpaceWithUnderscoreInVarNameInput = (input: HTMLInputElement) => {
|
||||
const start = input.selectionStart
|
||||
const end = input.selectionEnd
|
||||
|
||||
input.value = input.value.replaceAll(' ', '_')
|
||||
|
||||
if (start !== null && end !== null)
|
||||
input.setSelectionRange(start, end)
|
||||
}
|
||||
173
dify/web/utils/zod.spec.ts
Normal file
173
dify/web/utils/zod.spec.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { ZodError, z } from 'zod'
|
||||
|
||||
describe('Zod Features', () => {
|
||||
it('should support string', () => {
|
||||
const stringSchema = z.string()
|
||||
const numberLikeStringSchema = z.coerce.string() // 12 would be converted to '12'
|
||||
const stringSchemaWithError = z.string({
|
||||
required_error: 'Name is required',
|
||||
invalid_type_error: 'Invalid name type, expected string',
|
||||
})
|
||||
|
||||
const urlSchema = z.string().url()
|
||||
const uuidSchema = z.string().uuid()
|
||||
|
||||
expect(stringSchema.parse('hello')).toBe('hello')
|
||||
expect(() => stringSchema.parse(12)).toThrow()
|
||||
expect(numberLikeStringSchema.parse('12')).toBe('12')
|
||||
expect(numberLikeStringSchema.parse(12)).toBe('12')
|
||||
expect(() => stringSchemaWithError.parse(undefined)).toThrow('Name is required')
|
||||
expect(() => stringSchemaWithError.parse(12)).toThrow('Invalid name type, expected string')
|
||||
|
||||
expect(urlSchema.parse('https://dify.ai')).toBe('https://dify.ai')
|
||||
expect(uuidSchema.parse('123e4567-e89b-12d3-a456-426614174000')).toBe('123e4567-e89b-12d3-a456-426614174000')
|
||||
})
|
||||
|
||||
it('should support enum', () => {
|
||||
enum JobStatus {
|
||||
waiting = 'waiting',
|
||||
processing = 'processing',
|
||||
completed = 'completed',
|
||||
}
|
||||
expect(z.nativeEnum(JobStatus).parse(JobStatus.waiting)).toBe(JobStatus.waiting)
|
||||
expect(z.nativeEnum(JobStatus).parse('completed')).toBe('completed')
|
||||
expect(() => z.nativeEnum(JobStatus).parse('invalid')).toThrow()
|
||||
})
|
||||
|
||||
it('should support number', () => {
|
||||
const numberSchema = z.number()
|
||||
const numberWithMin = z.number().gt(0) // alias min
|
||||
const numberWithMinEqual = z.number().gte(0)
|
||||
const numberWithMax = z.number().lt(100) // alias max
|
||||
|
||||
expect(numberSchema.parse(123)).toBe(123)
|
||||
expect(numberWithMin.parse(50)).toBe(50)
|
||||
expect(numberWithMinEqual.parse(0)).toBe(0)
|
||||
expect(() => numberWithMin.parse(-1)).toThrow()
|
||||
expect(numberWithMax.parse(50)).toBe(50)
|
||||
expect(() => numberWithMax.parse(101)).toThrow()
|
||||
})
|
||||
|
||||
it('should support boolean', () => {
|
||||
const booleanSchema = z.boolean()
|
||||
expect(booleanSchema.parse(true)).toBe(true)
|
||||
expect(booleanSchema.parse(false)).toBe(false)
|
||||
expect(() => booleanSchema.parse('true')).toThrow()
|
||||
})
|
||||
|
||||
it('should support date', () => {
|
||||
const dateSchema = z.date()
|
||||
expect(dateSchema.parse(new Date('2023-01-01'))).toEqual(new Date('2023-01-01'))
|
||||
})
|
||||
|
||||
it('should support object', () => {
|
||||
const userSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
name: z.string(),
|
||||
email: z.string().email(),
|
||||
age: z.number().min(0).max(120).optional(),
|
||||
})
|
||||
|
||||
type User = z.infer<typeof userSchema>
|
||||
|
||||
const validUser: User = {
|
||||
id: 1,
|
||||
name: 'John',
|
||||
email: 'john@example.com',
|
||||
age: 30,
|
||||
}
|
||||
|
||||
expect(userSchema.parse(validUser)).toEqual(validUser)
|
||||
})
|
||||
|
||||
it('should support object optional field', () => {
|
||||
const userSchema = z.object({
|
||||
name: z.string(),
|
||||
optionalField: z.optional(z.string()),
|
||||
})
|
||||
type User = z.infer<typeof userSchema>
|
||||
|
||||
const user: User = {
|
||||
name: 'John',
|
||||
}
|
||||
const userWithOptionalField: User = {
|
||||
name: 'John',
|
||||
optionalField: 'optional',
|
||||
}
|
||||
expect(userSchema.safeParse(user).success).toEqual(true)
|
||||
expect(userSchema.safeParse(userWithOptionalField).success).toEqual(true)
|
||||
})
|
||||
|
||||
it('should support object intersection', () => {
|
||||
const Person = z.object({
|
||||
name: z.string(),
|
||||
})
|
||||
|
||||
const Employee = z.object({
|
||||
role: z.string(),
|
||||
})
|
||||
|
||||
const EmployedPerson = z.intersection(Person, Employee)
|
||||
const validEmployedPerson = {
|
||||
name: 'John',
|
||||
role: 'Developer',
|
||||
}
|
||||
expect(EmployedPerson.parse(validEmployedPerson)).toEqual(validEmployedPerson)
|
||||
})
|
||||
|
||||
it('should support record', () => {
|
||||
const recordSchema = z.record(z.string(), z.number())
|
||||
const validRecord = {
|
||||
a: 1,
|
||||
b: 2,
|
||||
}
|
||||
expect(recordSchema.parse(validRecord)).toEqual(validRecord)
|
||||
})
|
||||
|
||||
it('should support array', () => {
|
||||
const numbersSchema = z.array(z.number())
|
||||
const stringArraySchema = z.string().array()
|
||||
|
||||
expect(numbersSchema.parse([1, 2, 3])).toEqual([1, 2, 3])
|
||||
expect(stringArraySchema.parse(['a', 'b', 'c'])).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('should support promise', () => {
|
||||
const promiseSchema = z.promise(z.string())
|
||||
const validPromise = Promise.resolve('success')
|
||||
|
||||
expect(promiseSchema.parse(validPromise)).resolves.toBe('success')
|
||||
})
|
||||
|
||||
it('should support unions', () => {
|
||||
const unionSchema = z.union([z.string(), z.number()])
|
||||
|
||||
expect(unionSchema.parse('success')).toBe('success')
|
||||
expect(unionSchema.parse(404)).toBe(404)
|
||||
})
|
||||
|
||||
it('should support functions', () => {
|
||||
const functionSchema = z.function().args(z.string(), z.number(), z.optional(z.string())).returns(z.number())
|
||||
const validFunction = (name: string, age: number, _optional?: string): number => {
|
||||
return age
|
||||
}
|
||||
expect(functionSchema.safeParse(validFunction).success).toEqual(true)
|
||||
})
|
||||
|
||||
it('should support undefined, null, any, and void', () => {
|
||||
const undefinedSchema = z.undefined()
|
||||
const nullSchema = z.null()
|
||||
const anySchema = z.any()
|
||||
|
||||
expect(undefinedSchema.parse(undefined)).toBeUndefined()
|
||||
expect(nullSchema.parse(null)).toBeNull()
|
||||
expect(anySchema.parse('anything')).toBe('anything')
|
||||
expect(anySchema.parse(3)).toBe(3)
|
||||
})
|
||||
|
||||
it('should safeParse would not throw', () => {
|
||||
expect(z.string().safeParse('abc').success).toBe(true)
|
||||
expect(z.string().safeParse(123).success).toBe(false)
|
||||
expect(z.string().safeParse(123).error).toBeInstanceOf(ZodError)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user