dify
This commit is contained in:
862
dify/web/__tests__/check-i18n.test.ts
Normal file
862
dify/web/__tests__/check-i18n.test.ts
Normal file
@@ -0,0 +1,862 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
// Mock functions to simulate the check-i18n functionality
|
||||
const vm = require('node:vm')
|
||||
const transpile = require('typescript').transpile
|
||||
|
||||
describe('check-i18n script functionality', () => {
|
||||
const testDir = path.join(__dirname, '../i18n-test')
|
||||
const testEnDir = path.join(testDir, 'en-US')
|
||||
const testZhDir = path.join(testDir, 'zh-Hans')
|
||||
|
||||
// Helper function that replicates the getKeysFromLanguage logic
|
||||
async function getKeysFromLanguage(language: string, testPath = testDir): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const folderPath = path.resolve(testPath, language)
|
||||
const allKeys: string[] = []
|
||||
|
||||
if (!fs.existsSync(folderPath)) {
|
||||
resolve([])
|
||||
return
|
||||
}
|
||||
|
||||
fs.readdir(folderPath, (err, files) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
|
||||
const translationFiles = files.filter(file => /\.(ts|js)$/.test(file))
|
||||
|
||||
translationFiles.forEach((file) => {
|
||||
const filePath = path.join(folderPath, file)
|
||||
const fileName = file.replace(/\.[^/.]+$/, '')
|
||||
const camelCaseFileName = fileName.replace(/[-_](.)/g, (_, c) =>
|
||||
c.toUpperCase(),
|
||||
)
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8')
|
||||
const moduleExports = {}
|
||||
const context = {
|
||||
exports: moduleExports,
|
||||
module: { exports: moduleExports },
|
||||
require,
|
||||
console,
|
||||
__filename: filePath,
|
||||
__dirname: folderPath,
|
||||
}
|
||||
|
||||
vm.runInNewContext(transpile(content), context)
|
||||
const translationObj = (context.module.exports as any).default || context.module.exports
|
||||
|
||||
if (!translationObj || typeof translationObj !== 'object')
|
||||
throw new Error(`Error parsing file: ${filePath}`)
|
||||
|
||||
const nestedKeys: string[] = []
|
||||
const iterateKeys = (obj: any, prefix = '') => {
|
||||
for (const key in obj) {
|
||||
const nestedKey = prefix ? `${prefix}.${key}` : key
|
||||
if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
|
||||
// This is an object (but not array), recurse into it but don't add it as a key
|
||||
iterateKeys(obj[key], nestedKey)
|
||||
}
|
||||
else {
|
||||
// This is a leaf node (string, number, boolean, array, etc.), add it as a key
|
||||
nestedKeys.push(nestedKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
iterateKeys(translationObj)
|
||||
|
||||
const fileKeys = nestedKeys.map(key => `${camelCaseFileName}.${key}`)
|
||||
allKeys.push(...fileKeys)
|
||||
}
|
||||
catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
resolve(allKeys)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Clean up and create test directories
|
||||
if (fs.existsSync(testDir))
|
||||
fs.rmSync(testDir, { recursive: true })
|
||||
|
||||
fs.mkdirSync(testDir, { recursive: true })
|
||||
fs.mkdirSync(testEnDir, { recursive: true })
|
||||
fs.mkdirSync(testZhDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test files
|
||||
if (fs.existsSync(testDir))
|
||||
fs.rmSync(testDir, { recursive: true })
|
||||
})
|
||||
|
||||
describe('Key extraction logic', () => {
|
||||
it('should extract only leaf node keys, not intermediate objects', async () => {
|
||||
const testContent = `const translation = {
|
||||
simple: 'Simple Value',
|
||||
nested: {
|
||||
level1: 'Level 1 Value',
|
||||
deep: {
|
||||
level2: 'Level 2 Value'
|
||||
}
|
||||
},
|
||||
array: ['not extracted'],
|
||||
number: 42,
|
||||
boolean: true
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
fs.writeFileSync(path.join(testEnDir, 'test.ts'), testContent)
|
||||
|
||||
const keys = await getKeysFromLanguage('en-US')
|
||||
|
||||
expect(keys).toEqual([
|
||||
'test.simple',
|
||||
'test.nested.level1',
|
||||
'test.nested.deep.level2',
|
||||
'test.array',
|
||||
'test.number',
|
||||
'test.boolean',
|
||||
])
|
||||
|
||||
// Should not include intermediate object keys
|
||||
expect(keys).not.toContain('test.nested')
|
||||
expect(keys).not.toContain('test.nested.deep')
|
||||
})
|
||||
|
||||
it('should handle camelCase file name conversion correctly', async () => {
|
||||
const testContent = `const translation = {
|
||||
key: 'value'
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
fs.writeFileSync(path.join(testEnDir, 'app-debug.ts'), testContent)
|
||||
fs.writeFileSync(path.join(testEnDir, 'user_profile.ts'), testContent)
|
||||
|
||||
const keys = await getKeysFromLanguage('en-US')
|
||||
|
||||
expect(keys).toContain('appDebug.key')
|
||||
expect(keys).toContain('userProfile.key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Missing keys detection', () => {
|
||||
it('should detect missing keys in target language', async () => {
|
||||
const enContent = `const translation = {
|
||||
common: {
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete'
|
||||
},
|
||||
app: {
|
||||
title: 'My App',
|
||||
version: '1.0'
|
||||
}
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
const zhContent = `const translation = {
|
||||
common: {
|
||||
save: '保存',
|
||||
cancel: '取消'
|
||||
// missing 'delete'
|
||||
},
|
||||
app: {
|
||||
title: '我的应用'
|
||||
// missing 'version'
|
||||
}
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
fs.writeFileSync(path.join(testEnDir, 'test.ts'), enContent)
|
||||
fs.writeFileSync(path.join(testZhDir, 'test.ts'), zhContent)
|
||||
|
||||
const enKeys = await getKeysFromLanguage('en-US')
|
||||
const zhKeys = await getKeysFromLanguage('zh-Hans')
|
||||
|
||||
const missingKeys = enKeys.filter(key => !zhKeys.includes(key))
|
||||
|
||||
expect(missingKeys).toContain('test.common.delete')
|
||||
expect(missingKeys).toContain('test.app.version')
|
||||
expect(missingKeys).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Extra keys detection', () => {
|
||||
it('should detect extra keys in target language', async () => {
|
||||
const enContent = `const translation = {
|
||||
common: {
|
||||
save: 'Save',
|
||||
cancel: 'Cancel'
|
||||
}
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
const zhContent = `const translation = {
|
||||
common: {
|
||||
save: '保存',
|
||||
cancel: '取消',
|
||||
delete: '删除', // extra key
|
||||
extra: '额外的' // another extra key
|
||||
},
|
||||
newSection: {
|
||||
someKey: '某个值' // extra section
|
||||
}
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
fs.writeFileSync(path.join(testEnDir, 'test.ts'), enContent)
|
||||
fs.writeFileSync(path.join(testZhDir, 'test.ts'), zhContent)
|
||||
|
||||
const enKeys = await getKeysFromLanguage('en-US')
|
||||
const zhKeys = await getKeysFromLanguage('zh-Hans')
|
||||
|
||||
const extraKeys = zhKeys.filter(key => !enKeys.includes(key))
|
||||
|
||||
expect(extraKeys).toContain('test.common.delete')
|
||||
expect(extraKeys).toContain('test.common.extra')
|
||||
expect(extraKeys).toContain('test.newSection.someKey')
|
||||
expect(extraKeys).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('File filtering logic', () => {
|
||||
it('should filter keys by specific file correctly', async () => {
|
||||
// Create multiple files
|
||||
const file1Content = `const translation = {
|
||||
button: 'Button',
|
||||
text: 'Text'
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
const file2Content = `const translation = {
|
||||
title: 'Title',
|
||||
description: 'Description'
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
fs.writeFileSync(path.join(testEnDir, 'components.ts'), file1Content)
|
||||
fs.writeFileSync(path.join(testEnDir, 'pages.ts'), file2Content)
|
||||
fs.writeFileSync(path.join(testZhDir, 'components.ts'), file1Content)
|
||||
fs.writeFileSync(path.join(testZhDir, 'pages.ts'), file2Content)
|
||||
|
||||
const allEnKeys = await getKeysFromLanguage('en-US')
|
||||
|
||||
// Test file filtering logic
|
||||
const targetFile = 'components'
|
||||
const filteredEnKeys = allEnKeys.filter(key =>
|
||||
key.startsWith(targetFile.replace(/[-_](.)/g, (_, c) => c.toUpperCase())),
|
||||
)
|
||||
|
||||
expect(allEnKeys).toHaveLength(4) // 2 keys from each file
|
||||
expect(filteredEnKeys).toHaveLength(2) // only components keys
|
||||
expect(filteredEnKeys).toContain('components.button')
|
||||
expect(filteredEnKeys).toContain('components.text')
|
||||
expect(filteredEnKeys).not.toContain('pages.title')
|
||||
expect(filteredEnKeys).not.toContain('pages.description')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Complex nested structure handling', () => {
|
||||
it('should handle deeply nested objects correctly', async () => {
|
||||
const complexContent = `const translation = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
level4: {
|
||||
deepValue: 'Deep Value'
|
||||
},
|
||||
anotherValue: 'Another Value'
|
||||
},
|
||||
simpleValue: 'Simple Value'
|
||||
},
|
||||
directValue: 'Direct Value'
|
||||
},
|
||||
rootValue: 'Root Value'
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
fs.writeFileSync(path.join(testEnDir, 'complex.ts'), complexContent)
|
||||
|
||||
const keys = await getKeysFromLanguage('en-US')
|
||||
|
||||
expect(keys).toContain('complex.level1.level2.level3.level4.deepValue')
|
||||
expect(keys).toContain('complex.level1.level2.level3.anotherValue')
|
||||
expect(keys).toContain('complex.level1.level2.simpleValue')
|
||||
expect(keys).toContain('complex.level1.directValue')
|
||||
expect(keys).toContain('complex.rootValue')
|
||||
|
||||
// Should not include intermediate objects
|
||||
expect(keys).not.toContain('complex.level1')
|
||||
expect(keys).not.toContain('complex.level1.level2')
|
||||
expect(keys).not.toContain('complex.level1.level2.level3')
|
||||
expect(keys).not.toContain('complex.level1.level2.level3.level4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty objects', async () => {
|
||||
const emptyContent = `const translation = {
|
||||
empty: {},
|
||||
withValue: 'value'
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
fs.writeFileSync(path.join(testEnDir, 'empty.ts'), emptyContent)
|
||||
|
||||
const keys = await getKeysFromLanguage('en-US')
|
||||
|
||||
expect(keys).toContain('empty.withValue')
|
||||
expect(keys).not.toContain('empty.empty')
|
||||
})
|
||||
|
||||
it('should handle special characters in keys', async () => {
|
||||
const specialContent = `const translation = {
|
||||
'key-with-dash': 'value1',
|
||||
'key_with_underscore': 'value2',
|
||||
'key.with.dots': 'value3',
|
||||
normalKey: 'value4'
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
fs.writeFileSync(path.join(testEnDir, 'special.ts'), specialContent)
|
||||
|
||||
const keys = await getKeysFromLanguage('en-US')
|
||||
|
||||
expect(keys).toContain('special.key-with-dash')
|
||||
expect(keys).toContain('special.key_with_underscore')
|
||||
expect(keys).toContain('special.key.with.dots')
|
||||
expect(keys).toContain('special.normalKey')
|
||||
})
|
||||
|
||||
it('should handle different value types', async () => {
|
||||
const typesContent = `const translation = {
|
||||
stringValue: 'string',
|
||||
numberValue: 42,
|
||||
booleanValue: true,
|
||||
nullValue: null,
|
||||
undefinedValue: undefined,
|
||||
arrayValue: ['array', 'values'],
|
||||
objectValue: {
|
||||
nested: 'nested value'
|
||||
}
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
fs.writeFileSync(path.join(testEnDir, 'types.ts'), typesContent)
|
||||
|
||||
const keys = await getKeysFromLanguage('en-US')
|
||||
|
||||
expect(keys).toContain('types.stringValue')
|
||||
expect(keys).toContain('types.numberValue')
|
||||
expect(keys).toContain('types.booleanValue')
|
||||
expect(keys).toContain('types.nullValue')
|
||||
expect(keys).toContain('types.undefinedValue')
|
||||
expect(keys).toContain('types.arrayValue')
|
||||
expect(keys).toContain('types.objectValue.nested')
|
||||
expect(keys).not.toContain('types.objectValue')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Real-world scenario tests', () => {
|
||||
it('should handle app-debug structure like real files', async () => {
|
||||
const appDebugEn = `const translation = {
|
||||
pageTitle: {
|
||||
line1: 'Prompt',
|
||||
line2: 'Engineering'
|
||||
},
|
||||
operation: {
|
||||
applyConfig: 'Publish',
|
||||
resetConfig: 'Reset',
|
||||
debugConfig: 'Debug'
|
||||
},
|
||||
generate: {
|
||||
instruction: 'Instructions',
|
||||
generate: 'Generate',
|
||||
resTitle: 'Generated Prompt',
|
||||
noDataLine1: 'Describe your use case on the left,',
|
||||
noDataLine2: 'the orchestration preview will show here.'
|
||||
}
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
const appDebugZh = `const translation = {
|
||||
pageTitle: {
|
||||
line1: '提示词',
|
||||
line2: '编排'
|
||||
},
|
||||
operation: {
|
||||
applyConfig: '发布',
|
||||
resetConfig: '重置',
|
||||
debugConfig: '调试'
|
||||
},
|
||||
generate: {
|
||||
instruction: '指令',
|
||||
generate: '生成',
|
||||
resTitle: '生成的提示词',
|
||||
noData: '在左侧描述您的用例,编排预览将在此处显示。' // This is extra
|
||||
}
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
fs.writeFileSync(path.join(testEnDir, 'app-debug.ts'), appDebugEn)
|
||||
fs.writeFileSync(path.join(testZhDir, 'app-debug.ts'), appDebugZh)
|
||||
|
||||
const enKeys = await getKeysFromLanguage('en-US')
|
||||
const zhKeys = await getKeysFromLanguage('zh-Hans')
|
||||
|
||||
const missingKeys = enKeys.filter(key => !zhKeys.includes(key))
|
||||
const extraKeys = zhKeys.filter(key => !enKeys.includes(key))
|
||||
|
||||
expect(missingKeys).toContain('appDebug.generate.noDataLine1')
|
||||
expect(missingKeys).toContain('appDebug.generate.noDataLine2')
|
||||
expect(extraKeys).toContain('appDebug.generate.noData')
|
||||
|
||||
expect(missingKeys).toHaveLength(2)
|
||||
expect(extraKeys).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle time structure with operation nested keys', async () => {
|
||||
const timeEn = `const translation = {
|
||||
months: {
|
||||
January: 'January',
|
||||
February: 'February'
|
||||
},
|
||||
operation: {
|
||||
now: 'Now',
|
||||
ok: 'OK',
|
||||
cancel: 'Cancel',
|
||||
pickDate: 'Pick Date'
|
||||
},
|
||||
title: {
|
||||
pickTime: 'Pick Time'
|
||||
},
|
||||
defaultPlaceholder: 'Pick a time...'
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
const timeZh = `const translation = {
|
||||
months: {
|
||||
January: '一月',
|
||||
February: '二月'
|
||||
},
|
||||
operation: {
|
||||
now: '此刻',
|
||||
ok: '确定',
|
||||
cancel: '取消',
|
||||
pickDate: '选择日期'
|
||||
},
|
||||
title: {
|
||||
pickTime: '选择时间'
|
||||
},
|
||||
pickDate: '选择日期', // This is extra - duplicates operation.pickDate
|
||||
defaultPlaceholder: '请选择时间...'
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
fs.writeFileSync(path.join(testEnDir, 'time.ts'), timeEn)
|
||||
fs.writeFileSync(path.join(testZhDir, 'time.ts'), timeZh)
|
||||
|
||||
const enKeys = await getKeysFromLanguage('en-US')
|
||||
const zhKeys = await getKeysFromLanguage('zh-Hans')
|
||||
|
||||
const missingKeys = enKeys.filter(key => !zhKeys.includes(key))
|
||||
const extraKeys = zhKeys.filter(key => !enKeys.includes(key))
|
||||
|
||||
expect(missingKeys).toHaveLength(0) // No missing keys
|
||||
expect(extraKeys).toContain('time.pickDate') // Extra root-level pickDate
|
||||
expect(extraKeys).toHaveLength(1)
|
||||
|
||||
// Should have both keys available
|
||||
expect(zhKeys).toContain('time.operation.pickDate') // Correct nested key
|
||||
expect(zhKeys).toContain('time.pickDate') // Extra duplicate key
|
||||
})
|
||||
})
|
||||
|
||||
describe('Statistics calculation', () => {
|
||||
it('should calculate correct difference statistics', async () => {
|
||||
const enContent = `const translation = {
|
||||
key1: 'value1',
|
||||
key2: 'value2',
|
||||
key3: 'value3'
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
const zhContentMissing = `const translation = {
|
||||
key1: 'value1',
|
||||
key2: 'value2'
|
||||
// missing key3
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
const zhContentExtra = `const translation = {
|
||||
key1: 'value1',
|
||||
key2: 'value2',
|
||||
key3: 'value3',
|
||||
key4: 'extra',
|
||||
key5: 'extra2'
|
||||
}
|
||||
|
||||
export default translation
|
||||
`
|
||||
|
||||
fs.writeFileSync(path.join(testEnDir, 'stats.ts'), enContent)
|
||||
|
||||
// Test missing keys scenario
|
||||
fs.writeFileSync(path.join(testZhDir, 'stats.ts'), zhContentMissing)
|
||||
|
||||
const enKeys = await getKeysFromLanguage('en-US')
|
||||
const zhKeysMissing = await getKeysFromLanguage('zh-Hans')
|
||||
|
||||
expect(enKeys.length - zhKeysMissing.length).toBe(1) // +1 means 1 missing key
|
||||
|
||||
// Test extra keys scenario
|
||||
fs.writeFileSync(path.join(testZhDir, 'stats.ts'), zhContentExtra)
|
||||
|
||||
const zhKeysExtra = await getKeysFromLanguage('zh-Hans')
|
||||
|
||||
expect(enKeys.length - zhKeysExtra.length).toBe(-2) // -2 means 2 extra keys
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auto-remove multiline key-value pairs', () => {
|
||||
// Helper function to simulate removeExtraKeysFromFile logic
|
||||
function removeExtraKeysFromFile(content: string, keysToRemove: string[]): string {
|
||||
const lines = content.split('\n')
|
||||
const linesToRemove: number[] = []
|
||||
|
||||
for (const keyToRemove of keysToRemove) {
|
||||
let targetLineIndex = -1
|
||||
const linesToRemoveForKey: number[] = []
|
||||
|
||||
// Find the key line (simplified for single-level keys in test)
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
const keyPattern = new RegExp(`^\\s*${keyToRemove}\\s*:`)
|
||||
if (keyPattern.test(line)) {
|
||||
targetLineIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (targetLineIndex !== -1) {
|
||||
linesToRemoveForKey.push(targetLineIndex)
|
||||
|
||||
// Check if this is a multiline key-value pair
|
||||
const keyLine = lines[targetLineIndex]
|
||||
const trimmedKeyLine = keyLine.trim()
|
||||
|
||||
// If key line ends with ":" (not complete value), it's likely multiline
|
||||
if (trimmedKeyLine.endsWith(':') && !trimmedKeyLine.includes('{') && !trimmedKeyLine.match(/:\s*['"`]/)) {
|
||||
// Find the value lines that belong to this key
|
||||
let currentLine = targetLineIndex + 1
|
||||
let foundValue = false
|
||||
|
||||
while (currentLine < lines.length) {
|
||||
const line = lines[currentLine]
|
||||
const trimmed = line.trim()
|
||||
|
||||
// Skip empty lines
|
||||
if (trimmed === '') {
|
||||
currentLine++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this line starts a new key (indicates end of current value)
|
||||
if (trimmed.match(/^\w+\s*:/))
|
||||
break
|
||||
|
||||
// Check if this line is part of the value
|
||||
if (trimmed.startsWith('\'') || trimmed.startsWith('"') || trimmed.startsWith('`') || foundValue) {
|
||||
linesToRemoveForKey.push(currentLine)
|
||||
foundValue = true
|
||||
|
||||
// Check if this line ends the value (ends with quote and comma/no comma)
|
||||
if ((trimmed.endsWith('\',') || trimmed.endsWith('",') || trimmed.endsWith('`,')
|
||||
|| trimmed.endsWith('\'') || trimmed.endsWith('"') || trimmed.endsWith('`'))
|
||||
&& !trimmed.startsWith('//'))
|
||||
break
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
|
||||
currentLine++
|
||||
}
|
||||
}
|
||||
|
||||
linesToRemove.push(...linesToRemoveForKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and sort in reverse order
|
||||
const uniqueLinesToRemove = [...new Set(linesToRemove)].sort((a, b) => b - a)
|
||||
|
||||
for (const lineIndex of uniqueLinesToRemove)
|
||||
lines.splice(lineIndex, 1)
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
it('should remove single-line key-value pairs correctly', () => {
|
||||
const content = `const translation = {
|
||||
keepThis: 'This should stay',
|
||||
removeThis: 'This should be removed',
|
||||
alsoKeep: 'This should also stay',
|
||||
}
|
||||
|
||||
export default translation`
|
||||
|
||||
const result = removeExtraKeysFromFile(content, ['removeThis'])
|
||||
|
||||
expect(result).toContain('keepThis: \'This should stay\'')
|
||||
expect(result).toContain('alsoKeep: \'This should also stay\'')
|
||||
expect(result).not.toContain('removeThis: \'This should be removed\'')
|
||||
})
|
||||
|
||||
it('should remove multiline key-value pairs completely', () => {
|
||||
const content = `const translation = {
|
||||
keepThis: 'This should stay',
|
||||
removeMultiline:
|
||||
'This is a multiline value that should be removed completely',
|
||||
alsoKeep: 'This should also stay',
|
||||
}
|
||||
|
||||
export default translation`
|
||||
|
||||
const result = removeExtraKeysFromFile(content, ['removeMultiline'])
|
||||
|
||||
expect(result).toContain('keepThis: \'This should stay\'')
|
||||
expect(result).toContain('alsoKeep: \'This should also stay\'')
|
||||
expect(result).not.toContain('removeMultiline:')
|
||||
expect(result).not.toContain('This is a multiline value that should be removed completely')
|
||||
})
|
||||
|
||||
it('should handle mixed single-line and multiline removals', () => {
|
||||
const content = `const translation = {
|
||||
keepThis: 'Keep this',
|
||||
removeSingle: 'Remove this single line',
|
||||
removeMultiline:
|
||||
'Remove this multiline value',
|
||||
anotherMultiline:
|
||||
'Another multiline that spans multiple lines',
|
||||
keepAnother: 'Keep this too',
|
||||
}
|
||||
|
||||
export default translation`
|
||||
|
||||
const result = removeExtraKeysFromFile(content, ['removeSingle', 'removeMultiline', 'anotherMultiline'])
|
||||
|
||||
expect(result).toContain('keepThis: \'Keep this\'')
|
||||
expect(result).toContain('keepAnother: \'Keep this too\'')
|
||||
expect(result).not.toContain('removeSingle:')
|
||||
expect(result).not.toContain('removeMultiline:')
|
||||
expect(result).not.toContain('anotherMultiline:')
|
||||
expect(result).not.toContain('Remove this single line')
|
||||
expect(result).not.toContain('Remove this multiline value')
|
||||
expect(result).not.toContain('Another multiline that spans multiple lines')
|
||||
})
|
||||
|
||||
it('should properly detect multiline vs single-line patterns', () => {
|
||||
const multilineContent = `const translation = {
|
||||
singleLine: 'This is single line',
|
||||
multilineKey:
|
||||
'This is multiline',
|
||||
keyWithColon: 'Value with: colon inside',
|
||||
objectKey: {
|
||||
nested: 'value'
|
||||
},
|
||||
}
|
||||
|
||||
export default translation`
|
||||
|
||||
// Test that single line with colon in value is not treated as multiline
|
||||
const result1 = removeExtraKeysFromFile(multilineContent, ['keyWithColon'])
|
||||
expect(result1).not.toContain('keyWithColon:')
|
||||
expect(result1).not.toContain('Value with: colon inside')
|
||||
|
||||
// Test that true multiline is handled correctly
|
||||
const result2 = removeExtraKeysFromFile(multilineContent, ['multilineKey'])
|
||||
expect(result2).not.toContain('multilineKey:')
|
||||
expect(result2).not.toContain('This is multiline')
|
||||
|
||||
// Test that object key removal works (note: this is a simplified test)
|
||||
// In real scenario, object removal would be more complex
|
||||
const result3 = removeExtraKeysFromFile(multilineContent, ['objectKey'])
|
||||
expect(result3).not.toContain('objectKey: {')
|
||||
// Note: Our simplified test function doesn't handle nested object removal perfectly
|
||||
// This is acceptable as it's testing the main multiline string removal functionality
|
||||
})
|
||||
|
||||
it('should handle real-world Polish translation structure', () => {
|
||||
const polishContent = `const translation = {
|
||||
createApp: 'UTWÓRZ APLIKACJĘ',
|
||||
newApp: {
|
||||
captionAppType: 'Jaki typ aplikacji chcesz stworzyć?',
|
||||
chatbotDescription:
|
||||
'Zbuduj aplikację opartą na czacie. Ta aplikacja używa formatu pytań i odpowiedzi.',
|
||||
agentDescription:
|
||||
'Zbuduj inteligentnego agenta, który może autonomicznie wybierać narzędzia.',
|
||||
basic: 'Podstawowy',
|
||||
},
|
||||
}
|
||||
|
||||
export default translation`
|
||||
|
||||
const result = removeExtraKeysFromFile(polishContent, ['captionAppType', 'chatbotDescription', 'agentDescription'])
|
||||
|
||||
expect(result).toContain('createApp: \'UTWÓRZ APLIKACJĘ\'')
|
||||
expect(result).toContain('basic: \'Podstawowy\'')
|
||||
expect(result).not.toContain('captionAppType:')
|
||||
expect(result).not.toContain('chatbotDescription:')
|
||||
expect(result).not.toContain('agentDescription:')
|
||||
expect(result).not.toContain('Jaki typ aplikacji')
|
||||
expect(result).not.toContain('Zbuduj aplikację opartą na czacie')
|
||||
expect(result).not.toContain('Zbuduj inteligentnego agenta')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance and Scalability', () => {
|
||||
it('should handle large translation files efficiently', async () => {
|
||||
// Create a large translation file with 1000 keys
|
||||
const largeContent = `const translation = {
|
||||
${Array.from({ length: 1000 }, (_, i) => ` key${i}: 'value${i}',`).join('\n')}
|
||||
}
|
||||
|
||||
export default translation`
|
||||
|
||||
fs.writeFileSync(path.join(testEnDir, 'large.ts'), largeContent)
|
||||
|
||||
const startTime = Date.now()
|
||||
const keys = await getKeysFromLanguage('en-US')
|
||||
const endTime = Date.now()
|
||||
|
||||
expect(keys.length).toBe(1000)
|
||||
expect(endTime - startTime).toBeLessThan(1000) // Should complete in under 1 second
|
||||
})
|
||||
|
||||
it('should handle multiple translation files concurrently', async () => {
|
||||
// Create multiple files
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const content = `const translation = {
|
||||
key${i}: 'value${i}',
|
||||
nested${i}: {
|
||||
subkey: 'subvalue'
|
||||
}
|
||||
}
|
||||
|
||||
export default translation`
|
||||
fs.writeFileSync(path.join(testEnDir, `file${i}.ts`), content)
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
const keys = await getKeysFromLanguage('en-US')
|
||||
const endTime = Date.now()
|
||||
|
||||
expect(keys.length).toBe(20) // 10 files * 2 keys each
|
||||
expect(endTime - startTime).toBeLessThan(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unicode and Internationalization', () => {
|
||||
it('should handle Unicode characters in keys and values', async () => {
|
||||
const unicodeContent = `const translation = {
|
||||
'中文键': '中文值',
|
||||
'العربية': 'قيمة',
|
||||
'emoji_😀': 'value with emoji 🎉',
|
||||
'mixed_中文_English': 'mixed value'
|
||||
}
|
||||
|
||||
export default translation`
|
||||
|
||||
fs.writeFileSync(path.join(testEnDir, 'unicode.ts'), unicodeContent)
|
||||
|
||||
const keys = await getKeysFromLanguage('en-US')
|
||||
|
||||
expect(keys).toContain('unicode.中文键')
|
||||
expect(keys).toContain('unicode.العربية')
|
||||
expect(keys).toContain('unicode.emoji_😀')
|
||||
expect(keys).toContain('unicode.mixed_中文_English')
|
||||
})
|
||||
|
||||
it('should handle RTL language files', async () => {
|
||||
const rtlContent = `const translation = {
|
||||
مرحبا: 'Hello',
|
||||
العالم: 'World',
|
||||
nested: {
|
||||
مفتاح: 'key'
|
||||
}
|
||||
}
|
||||
|
||||
export default translation`
|
||||
|
||||
fs.writeFileSync(path.join(testEnDir, 'rtl.ts'), rtlContent)
|
||||
|
||||
const keys = await getKeysFromLanguage('en-US')
|
||||
|
||||
expect(keys).toContain('rtl.مرحبا')
|
||||
expect(keys).toContain('rtl.العالم')
|
||||
expect(keys).toContain('rtl.nested.مفتاح')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Recovery', () => {
|
||||
it('should handle syntax errors in translation files gracefully', async () => {
|
||||
const invalidContent = `const translation = {
|
||||
validKey: 'valid value',
|
||||
invalidKey: 'missing quote,
|
||||
anotherKey: 'another value'
|
||||
}
|
||||
|
||||
export default translation`
|
||||
|
||||
fs.writeFileSync(path.join(testEnDir, 'invalid.ts'), invalidContent)
|
||||
|
||||
await expect(getKeysFromLanguage('en-US')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
97
dify/web/__tests__/description-validation.test.tsx
Normal file
97
dify/web/__tests__/description-validation.test.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Description Validation Test
|
||||
*
|
||||
* Tests for the 400-character description validation across App and Dataset
|
||||
* creation and editing workflows to ensure consistent validation behavior.
|
||||
*/
|
||||
|
||||
describe('Description Validation Logic', () => {
|
||||
// Simulate backend validation function
|
||||
const validateDescriptionLength = (description?: string | null) => {
|
||||
if (description && description.length > 400)
|
||||
throw new Error('Description cannot exceed 400 characters.')
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
describe('Backend Validation Function', () => {
|
||||
test('allows description within 400 characters', () => {
|
||||
const validDescription = 'x'.repeat(400)
|
||||
expect(() => validateDescriptionLength(validDescription)).not.toThrow()
|
||||
expect(validateDescriptionLength(validDescription)).toBe(validDescription)
|
||||
})
|
||||
|
||||
test('allows empty description', () => {
|
||||
expect(() => validateDescriptionLength('')).not.toThrow()
|
||||
expect(() => validateDescriptionLength(null)).not.toThrow()
|
||||
expect(() => validateDescriptionLength(undefined)).not.toThrow()
|
||||
})
|
||||
|
||||
test('rejects description exceeding 400 characters', () => {
|
||||
const invalidDescription = 'x'.repeat(401)
|
||||
expect(() => validateDescriptionLength(invalidDescription)).toThrow(
|
||||
'Description cannot exceed 400 characters.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Backend Validation Consistency', () => {
|
||||
test('App and Dataset have consistent validation limits', () => {
|
||||
const maxLength = 400
|
||||
const validDescription = 'x'.repeat(maxLength)
|
||||
const invalidDescription = 'x'.repeat(maxLength + 1)
|
||||
|
||||
// Both should accept exactly 400 characters
|
||||
expect(validDescription.length).toBe(400)
|
||||
expect(() => validateDescriptionLength(validDescription)).not.toThrow()
|
||||
|
||||
// Both should reject 401 characters
|
||||
expect(invalidDescription.length).toBe(401)
|
||||
expect(() => validateDescriptionLength(invalidDescription)).toThrow()
|
||||
})
|
||||
|
||||
test('validation error messages are consistent', () => {
|
||||
const expectedErrorMessage = 'Description cannot exceed 400 characters.'
|
||||
|
||||
// This would be the error message from both App and Dataset backend validation
|
||||
expect(expectedErrorMessage).toBe('Description cannot exceed 400 characters.')
|
||||
|
||||
const invalidDescription = 'x'.repeat(401)
|
||||
try {
|
||||
validateDescriptionLength(invalidDescription)
|
||||
}
|
||||
catch (error) {
|
||||
expect((error as Error).message).toBe(expectedErrorMessage)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Character Length Edge Cases', () => {
|
||||
const testCases = [
|
||||
{ length: 0, shouldPass: true, description: 'empty description' },
|
||||
{ length: 1, shouldPass: true, description: '1 character' },
|
||||
{ length: 399, shouldPass: true, description: '399 characters' },
|
||||
{ length: 400, shouldPass: true, description: '400 characters (boundary)' },
|
||||
{ length: 401, shouldPass: false, description: '401 characters (over limit)' },
|
||||
{ length: 500, shouldPass: false, description: '500 characters' },
|
||||
{ length: 1000, shouldPass: false, description: '1000 characters' },
|
||||
]
|
||||
|
||||
testCases.forEach(({ length, shouldPass, description }) => {
|
||||
test(`handles ${description} correctly`, () => {
|
||||
const testDescription = length > 0 ? 'x'.repeat(length) : ''
|
||||
expect(testDescription.length).toBe(length)
|
||||
|
||||
if (shouldPass) {
|
||||
expect(() => validateDescriptionLength(testDescription)).not.toThrow()
|
||||
expect(validateDescriptionLength(testDescription)).toBe(testDescription)
|
||||
}
|
||||
else {
|
||||
expect(() => validateDescriptionLength(testDescription)).toThrow(
|
||||
'Description cannot exceed 400 characters.',
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
305
dify/web/__tests__/document-detail-navigation-fix.test.tsx
Normal file
305
dify/web/__tests__/document-detail-navigation-fix.test.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Document Detail Navigation Fix Verification Test
|
||||
*
|
||||
* This test specifically validates that the backToPrev function in the document detail
|
||||
* component correctly preserves pagination and filter states.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useDocumentDetail, useDocumentMetadata } from '@/service/knowledge/use-document'
|
||||
|
||||
// Mock Next.js router
|
||||
const mockPush = jest.fn()
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: jest.fn(() => ({
|
||||
push: mockPush,
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock the document service hooks
|
||||
jest.mock('@/service/knowledge/use-document', () => ({
|
||||
useDocumentDetail: jest.fn(),
|
||||
useDocumentMetadata: jest.fn(),
|
||||
useInvalidDocumentList: jest.fn(() => jest.fn()),
|
||||
}))
|
||||
|
||||
// Mock other dependencies
|
||||
jest.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContext: jest.fn(() => [null]),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/use-base', () => ({
|
||||
useInvalid: jest.fn(() => jest.fn()),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/knowledge/use-segment', () => ({
|
||||
useSegmentListKey: jest.fn(),
|
||||
useChildSegmentListKey: jest.fn(),
|
||||
}))
|
||||
|
||||
// Create a minimal version of the DocumentDetail component that includes our fix
|
||||
const DocumentDetailWithFix = ({ datasetId, documentId }: { datasetId: string; documentId: string }) => {
|
||||
const router = useRouter()
|
||||
|
||||
// This is the FIXED implementation from detail/index.tsx
|
||||
const backToPrev = () => {
|
||||
// Preserve pagination and filter states when navigating back
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const queryString = searchParams.toString()
|
||||
const separator = queryString ? '?' : ''
|
||||
const backPath = `/datasets/${datasetId}/documents${separator}${queryString}`
|
||||
router.push(backPath)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="document-detail-fixed">
|
||||
<button type="button" data-testid="back-button-fixed" onClick={backToPrev}>
|
||||
Back to Documents
|
||||
</button>
|
||||
<div data-testid="document-info">
|
||||
Dataset: {datasetId}, Document: {documentId}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Document Detail Navigation Fix Verification', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
// Mock successful API responses
|
||||
;(useDocumentDetail as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
id: 'doc-123',
|
||||
name: 'Test Document',
|
||||
display_status: 'available',
|
||||
enabled: true,
|
||||
archived: false,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
;(useDocumentMetadata as jest.Mock).mockReturnValue({
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Query Parameter Preservation', () => {
|
||||
test('preserves pagination state (page 3, limit 25)', () => {
|
||||
// Simulate user coming from page 3 with 25 items per page
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
search: '?page=3&limit=25',
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
|
||||
render(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
|
||||
|
||||
// User clicks back button
|
||||
fireEvent.click(screen.getByTestId('back-button-fixed'))
|
||||
|
||||
// Should preserve the pagination state
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents?page=3&limit=25')
|
||||
|
||||
console.log('✅ Pagination state preserved: page=3&limit=25')
|
||||
})
|
||||
|
||||
test('preserves search keyword and filters', () => {
|
||||
// Simulate user with search and filters applied
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
search: '?page=2&limit=10&keyword=API%20documentation&status=active',
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
|
||||
render(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('back-button-fixed'))
|
||||
|
||||
// Should preserve all query parameters
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents?page=2&limit=10&keyword=API+documentation&status=active')
|
||||
|
||||
console.log('✅ Search and filters preserved')
|
||||
})
|
||||
|
||||
test('handles complex query parameters with special characters', () => {
|
||||
// Test with complex query string including encoded characters
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
search: '?page=1&limit=50&keyword=test%20%26%20debug&sort=name&order=desc&filter=%7B%22type%22%3A%22pdf%22%7D',
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
|
||||
render(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('back-button-fixed'))
|
||||
|
||||
// URLSearchParams will normalize the encoding, but preserve all parameters
|
||||
const expectedCall = mockPush.mock.calls[0][0]
|
||||
expect(expectedCall).toMatch(/^\/datasets\/dataset-123\/documents\?/)
|
||||
expect(expectedCall).toMatch(/page=1/)
|
||||
expect(expectedCall).toMatch(/limit=50/)
|
||||
expect(expectedCall).toMatch(/keyword=test/)
|
||||
expect(expectedCall).toMatch(/sort=name/)
|
||||
expect(expectedCall).toMatch(/order=desc/)
|
||||
|
||||
console.log('✅ Complex query parameters handled:', expectedCall)
|
||||
})
|
||||
|
||||
test('handles empty query parameters gracefully', () => {
|
||||
// No query parameters in URL
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
search: '',
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
|
||||
render(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('back-button-fixed'))
|
||||
|
||||
// Should navigate to clean documents URL
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents')
|
||||
|
||||
console.log('✅ Empty parameters handled gracefully')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Different Dataset IDs', () => {
|
||||
test('works with different dataset identifiers', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
search: '?page=5&limit=10',
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
|
||||
// Test with different dataset ID format
|
||||
render(<DocumentDetailWithFix datasetId="ds-prod-2024-001" documentId="doc-456" />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('back-button-fixed'))
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/ds-prod-2024-001/documents?page=5&limit=10')
|
||||
|
||||
console.log('✅ Works with different dataset ID formats')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Real User Scenarios', () => {
|
||||
test('scenario: user searches, goes to page 3, views document, clicks back', () => {
|
||||
// User searched for "API" and navigated to page 3
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
search: '?keyword=API&page=3&limit=10',
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
|
||||
render(<DocumentDetailWithFix datasetId="main-dataset" documentId="api-doc-123" />)
|
||||
|
||||
// User decides to go back to continue browsing
|
||||
fireEvent.click(screen.getByTestId('back-button-fixed'))
|
||||
|
||||
// Should return to page 3 of API search results
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/main-dataset/documents?keyword=API&page=3&limit=10')
|
||||
|
||||
console.log('✅ Real user scenario: search + pagination preserved')
|
||||
})
|
||||
|
||||
test('scenario: user applies multiple filters, goes to document, returns', () => {
|
||||
// User has applied multiple filters and is on page 2
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
search: '?page=2&limit=25&status=active&type=pdf&sort=created_at&order=desc',
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
|
||||
render(<DocumentDetailWithFix datasetId="filtered-dataset" documentId="filtered-doc" />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('back-button-fixed'))
|
||||
|
||||
// All filters should be preserved
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/filtered-dataset/documents?page=2&limit=25&status=active&type=pdf&sort=created_at&order=desc')
|
||||
|
||||
console.log('✅ Complex filtering scenario preserved')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling and Edge Cases', () => {
|
||||
test('handles malformed query parameters gracefully', () => {
|
||||
// Test with potentially problematic query string
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
search: '?page=invalid&limit=&keyword=test&=emptykey&malformed',
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
|
||||
render(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
|
||||
|
||||
// Should not throw errors
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByTestId('back-button-fixed'))
|
||||
}).not.toThrow()
|
||||
|
||||
// Should still attempt navigation (URLSearchParams will clean up the parameters)
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
const navigationPath = mockPush.mock.calls[0][0]
|
||||
expect(navigationPath).toMatch(/^\/datasets\/dataset-123\/documents/)
|
||||
|
||||
console.log('✅ Malformed parameters handled gracefully:', navigationPath)
|
||||
})
|
||||
|
||||
test('handles very long query strings', () => {
|
||||
// Test with a very long query string
|
||||
const longKeyword = 'a'.repeat(1000)
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
search: `?page=1&keyword=${longKeyword}`,
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
|
||||
render(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByTestId('back-button-fixed'))
|
||||
}).not.toThrow()
|
||||
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
|
||||
console.log('✅ Long query strings handled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance Verification', () => {
|
||||
test('navigation function executes quickly', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
search: '?page=1&limit=10&keyword=test',
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
|
||||
render(<DocumentDetailWithFix datasetId="dataset-123" documentId="doc-456" />)
|
||||
|
||||
const startTime = performance.now()
|
||||
fireEvent.click(screen.getByTestId('back-button-fixed'))
|
||||
const endTime = performance.now()
|
||||
|
||||
const executionTime = endTime - startTime
|
||||
|
||||
// Should execute in less than 10ms
|
||||
expect(executionTime).toBeLessThan(10)
|
||||
|
||||
console.log(`⚡ Navigation execution time: ${executionTime.toFixed(2)}ms`)
|
||||
})
|
||||
})
|
||||
})
|
||||
83
dify/web/__tests__/document-list-sorting.test.tsx
Normal file
83
dify/web/__tests__/document-list-sorting.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Document List Sorting Tests
|
||||
*/
|
||||
|
||||
describe('Document List Sorting', () => {
|
||||
const mockDocuments = [
|
||||
{ id: '1', name: 'Beta.pdf', word_count: 500, hit_count: 10, created_at: 1699123456 },
|
||||
{ id: '2', name: 'Alpha.txt', word_count: 200, hit_count: 25, created_at: 1699123400 },
|
||||
{ id: '3', name: 'Gamma.docx', word_count: 800, hit_count: 5, created_at: 1699123500 },
|
||||
]
|
||||
|
||||
const sortDocuments = (docs: any[], field: string, order: 'asc' | 'desc') => {
|
||||
return [...docs].sort((a, b) => {
|
||||
let aValue: any
|
||||
let bValue: any
|
||||
|
||||
switch (field) {
|
||||
case 'name':
|
||||
aValue = a.name?.toLowerCase() || ''
|
||||
bValue = b.name?.toLowerCase() || ''
|
||||
break
|
||||
case 'word_count':
|
||||
aValue = a.word_count || 0
|
||||
bValue = b.word_count || 0
|
||||
break
|
||||
case 'hit_count':
|
||||
aValue = a.hit_count || 0
|
||||
bValue = b.hit_count || 0
|
||||
break
|
||||
case 'created_at':
|
||||
aValue = a.created_at
|
||||
bValue = b.created_at
|
||||
break
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
if (field === 'name') {
|
||||
const result = aValue.localeCompare(bValue)
|
||||
return order === 'asc' ? result : -result
|
||||
}
|
||||
else {
|
||||
const result = aValue - bValue
|
||||
return order === 'asc' ? result : -result
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('sorts by name descending (default for UI consistency)', () => {
|
||||
const sorted = sortDocuments(mockDocuments, 'name', 'desc')
|
||||
expect(sorted.map(doc => doc.name)).toEqual(['Gamma.docx', 'Beta.pdf', 'Alpha.txt'])
|
||||
})
|
||||
|
||||
test('sorts by name ascending (after toggle)', () => {
|
||||
const sorted = sortDocuments(mockDocuments, 'name', 'asc')
|
||||
expect(sorted.map(doc => doc.name)).toEqual(['Alpha.txt', 'Beta.pdf', 'Gamma.docx'])
|
||||
})
|
||||
|
||||
test('sorts by word_count descending', () => {
|
||||
const sorted = sortDocuments(mockDocuments, 'word_count', 'desc')
|
||||
expect(sorted.map(doc => doc.word_count)).toEqual([800, 500, 200])
|
||||
})
|
||||
|
||||
test('sorts by hit_count descending', () => {
|
||||
const sorted = sortDocuments(mockDocuments, 'hit_count', 'desc')
|
||||
expect(sorted.map(doc => doc.hit_count)).toEqual([25, 10, 5])
|
||||
})
|
||||
|
||||
test('sorts by created_at descending (newest first)', () => {
|
||||
const sorted = sortDocuments(mockDocuments, 'created_at', 'desc')
|
||||
expect(sorted.map(doc => doc.created_at)).toEqual([1699123500, 1699123456, 1699123400])
|
||||
})
|
||||
|
||||
test('handles empty values correctly', () => {
|
||||
const docsWithEmpty = [
|
||||
{ id: '1', name: 'Test', word_count: 100, hit_count: 5, created_at: 1699123456 },
|
||||
{ id: '2', name: 'Empty', word_count: 0, hit_count: 0, created_at: 1699123400 },
|
||||
]
|
||||
|
||||
const sorted = sortDocuments(docsWithEmpty, 'word_count', 'desc')
|
||||
expect(sorted.map(doc => doc.word_count)).toEqual([100, 0])
|
||||
})
|
||||
})
|
||||
132
dify/web/__tests__/embedded-user-id-auth.test.tsx
Normal file
132
dify/web/__tests__/embedded-user-id-auth.test.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
|
||||
import MailAndPasswordAuth from '@/app/(shareLayout)/webapp-signin/components/mail-and-password-auth'
|
||||
import CheckCode from '@/app/(shareLayout)/webapp-signin/check-code/page'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const replaceMock = jest.fn()
|
||||
const backMock = jest.fn()
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
usePathname: jest.fn(() => '/chatbot/test-app'),
|
||||
useRouter: jest.fn(() => ({
|
||||
replace: replaceMock,
|
||||
back: backMock,
|
||||
})),
|
||||
useSearchParams: jest.fn(),
|
||||
}))
|
||||
|
||||
const mockStoreState = {
|
||||
embeddedUserId: 'embedded-user-99',
|
||||
shareCode: 'test-app',
|
||||
}
|
||||
|
||||
const useWebAppStoreMock = jest.fn((selector?: (state: typeof mockStoreState) => any) => {
|
||||
return selector ? selector(mockStoreState) : mockStoreState
|
||||
})
|
||||
|
||||
jest.mock('@/context/web-app-context', () => ({
|
||||
useWebAppStore: (selector?: (state: typeof mockStoreState) => any) => useWebAppStoreMock(selector),
|
||||
}))
|
||||
|
||||
const webAppLoginMock = jest.fn()
|
||||
const webAppEmailLoginWithCodeMock = jest.fn()
|
||||
const sendWebAppEMailLoginCodeMock = jest.fn()
|
||||
|
||||
jest.mock('@/service/common', () => ({
|
||||
webAppLogin: (...args: any[]) => webAppLoginMock(...args),
|
||||
webAppEmailLoginWithCode: (...args: any[]) => webAppEmailLoginWithCodeMock(...args),
|
||||
sendWebAppEMailLoginCode: (...args: any[]) => sendWebAppEMailLoginCodeMock(...args),
|
||||
}))
|
||||
|
||||
const fetchAccessTokenMock = jest.fn()
|
||||
|
||||
jest.mock('@/service/share', () => ({
|
||||
fetchAccessToken: (...args: any[]) => fetchAccessTokenMock(...args),
|
||||
}))
|
||||
|
||||
const setWebAppAccessTokenMock = jest.fn()
|
||||
const setWebAppPassportMock = jest.fn()
|
||||
|
||||
jest.mock('@/service/webapp-auth', () => ({
|
||||
setWebAppAccessToken: (...args: any[]) => setWebAppAccessTokenMock(...args),
|
||||
setWebAppPassport: (...args: any[]) => setWebAppPassportMock(...args),
|
||||
webAppLogout: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/signin/countdown', () => () => <div data-testid="countdown" />)
|
||||
|
||||
jest.mock('@remixicon/react', () => ({
|
||||
RiMailSendFill: () => <div data-testid="mail-icon" />,
|
||||
RiArrowLeftLine: () => <div data-testid="arrow-icon" />,
|
||||
}))
|
||||
|
||||
const { useSearchParams } = jest.requireMock('next/navigation') as {
|
||||
useSearchParams: jest.Mock
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('embedded user id propagation in authentication flows', () => {
|
||||
it('passes embedded user id when logging in with email and password', async () => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('redirect_url', encodeURIComponent('/chatbot/test-app'))
|
||||
useSearchParams.mockReturnValue(params)
|
||||
|
||||
webAppLoginMock.mockResolvedValue({ result: 'success', data: { access_token: 'login-token' } })
|
||||
fetchAccessTokenMock.mockResolvedValue({ access_token: 'passport-token' })
|
||||
|
||||
render(<MailAndPasswordAuth isEmailSetup />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('login.email'), { target: { value: 'user@example.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/login\.password/), { target: { value: 'strong-password' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'login.signBtn' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchAccessTokenMock).toHaveBeenCalledWith({
|
||||
appCode: 'test-app',
|
||||
userId: 'embedded-user-99',
|
||||
})
|
||||
})
|
||||
expect(setWebAppAccessTokenMock).toHaveBeenCalledWith('login-token')
|
||||
expect(setWebAppPassportMock).toHaveBeenCalledWith('test-app', 'passport-token')
|
||||
expect(replaceMock).toHaveBeenCalledWith('/chatbot/test-app')
|
||||
})
|
||||
|
||||
it('passes embedded user id when verifying email code', async () => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('redirect_url', encodeURIComponent('/chatbot/test-app'))
|
||||
params.set('email', encodeURIComponent('user@example.com'))
|
||||
params.set('token', encodeURIComponent('token-abc'))
|
||||
useSearchParams.mockReturnValue(params)
|
||||
|
||||
webAppEmailLoginWithCodeMock.mockResolvedValue({ result: 'success', data: { access_token: 'code-token' } })
|
||||
fetchAccessTokenMock.mockResolvedValue({ access_token: 'passport-token' })
|
||||
|
||||
render(<CheckCode />)
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText('login.checkCode.verificationCodePlaceholder'),
|
||||
{ target: { value: '123456' } },
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'login.checkCode.verify' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchAccessTokenMock).toHaveBeenCalledWith({
|
||||
appCode: 'test-app',
|
||||
userId: 'embedded-user-99',
|
||||
})
|
||||
})
|
||||
expect(setWebAppAccessTokenMock).toHaveBeenCalledWith('code-token')
|
||||
expect(setWebAppPassportMock).toHaveBeenCalledWith('test-app', 'passport-token')
|
||||
expect(replaceMock).toHaveBeenCalledWith('/chatbot/test-app')
|
||||
})
|
||||
})
|
||||
155
dify/web/__tests__/embedded-user-id-store.test.tsx
Normal file
155
dify/web/__tests__/embedded-user-id-store.test.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
|
||||
import WebAppStoreProvider, { useWebAppStore } from '@/context/web-app-context'
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
usePathname: jest.fn(() => '/chatbot/sample-app'),
|
||||
useSearchParams: jest.fn(() => {
|
||||
const params = new URLSearchParams()
|
||||
return params
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/use-share', () => {
|
||||
const { AccessMode } = jest.requireActual('@/models/access-control')
|
||||
return {
|
||||
useGetWebAppAccessModeByCode: jest.fn(() => ({
|
||||
isLoading: false,
|
||||
data: { accessMode: AccessMode.PUBLIC },
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/app/components/base/chat/utils', () => ({
|
||||
getProcessedSystemVariablesFromUrlParams: jest.fn(),
|
||||
}))
|
||||
|
||||
const { getProcessedSystemVariablesFromUrlParams: mockGetProcessedSystemVariablesFromUrlParams }
|
||||
= jest.requireMock('@/app/components/base/chat/utils') as {
|
||||
getProcessedSystemVariablesFromUrlParams: jest.Mock
|
||||
}
|
||||
|
||||
jest.mock('@/context/global-public-context', () => {
|
||||
const mockGlobalStoreState = {
|
||||
isGlobalPending: false,
|
||||
setIsGlobalPending: jest.fn(),
|
||||
systemFeatures: {},
|
||||
setSystemFeatures: jest.fn(),
|
||||
}
|
||||
const useGlobalPublicStore = Object.assign(
|
||||
(selector?: (state: typeof mockGlobalStoreState) => any) =>
|
||||
selector ? selector(mockGlobalStoreState) : mockGlobalStoreState,
|
||||
{
|
||||
setState: (updater: any) => {
|
||||
if (typeof updater === 'function')
|
||||
Object.assign(mockGlobalStoreState, updater(mockGlobalStoreState) ?? {})
|
||||
|
||||
else
|
||||
Object.assign(mockGlobalStoreState, updater)
|
||||
},
|
||||
__mockState: mockGlobalStoreState,
|
||||
},
|
||||
)
|
||||
return {
|
||||
useGlobalPublicStore,
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
useGlobalPublicStore: useGlobalPublicStoreMock,
|
||||
} = jest.requireMock('@/context/global-public-context') as {
|
||||
useGlobalPublicStore: ((selector?: (state: any) => any) => any) & {
|
||||
setState: (updater: any) => void
|
||||
__mockState: {
|
||||
isGlobalPending: boolean
|
||||
setIsGlobalPending: jest.Mock
|
||||
systemFeatures: Record<string, unknown>
|
||||
setSystemFeatures: jest.Mock
|
||||
}
|
||||
}
|
||||
}
|
||||
const mockGlobalStoreState = useGlobalPublicStoreMock.__mockState
|
||||
|
||||
const TestConsumer = () => {
|
||||
const embeddedUserId = useWebAppStore(state => state.embeddedUserId)
|
||||
const embeddedConversationId = useWebAppStore(state => state.embeddedConversationId)
|
||||
return (
|
||||
<>
|
||||
<div data-testid="embedded-user-id">{embeddedUserId ?? 'null'}</div>
|
||||
<div data-testid="embedded-conversation-id">{embeddedConversationId ?? 'null'}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const initialWebAppStore = (() => {
|
||||
const snapshot = useWebAppStore.getState()
|
||||
return {
|
||||
shareCode: null as string | null,
|
||||
appInfo: null,
|
||||
appParams: null,
|
||||
webAppAccessMode: snapshot.webAppAccessMode,
|
||||
appMeta: null,
|
||||
userCanAccessApp: false,
|
||||
embeddedUserId: null,
|
||||
embeddedConversationId: null,
|
||||
updateShareCode: snapshot.updateShareCode,
|
||||
updateAppInfo: snapshot.updateAppInfo,
|
||||
updateAppParams: snapshot.updateAppParams,
|
||||
updateWebAppAccessMode: snapshot.updateWebAppAccessMode,
|
||||
updateWebAppMeta: snapshot.updateWebAppMeta,
|
||||
updateUserCanAccessApp: snapshot.updateUserCanAccessApp,
|
||||
updateEmbeddedUserId: snapshot.updateEmbeddedUserId,
|
||||
updateEmbeddedConversationId: snapshot.updateEmbeddedConversationId,
|
||||
}
|
||||
})()
|
||||
|
||||
beforeEach(() => {
|
||||
mockGlobalStoreState.isGlobalPending = false
|
||||
mockGetProcessedSystemVariablesFromUrlParams.mockReset()
|
||||
useWebAppStore.setState(initialWebAppStore, true)
|
||||
})
|
||||
|
||||
describe('WebAppStoreProvider embedded user id handling', () => {
|
||||
it('hydrates embedded user and conversation ids from system variables', async () => {
|
||||
mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({
|
||||
user_id: 'iframe-user-123',
|
||||
conversation_id: 'conversation-456',
|
||||
})
|
||||
|
||||
render(
|
||||
<WebAppStoreProvider>
|
||||
<TestConsumer />
|
||||
</WebAppStoreProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('embedded-user-id')).toHaveTextContent('iframe-user-123')
|
||||
expect(screen.getByTestId('embedded-conversation-id')).toHaveTextContent('conversation-456')
|
||||
})
|
||||
expect(useWebAppStore.getState().embeddedUserId).toBe('iframe-user-123')
|
||||
expect(useWebAppStore.getState().embeddedConversationId).toBe('conversation-456')
|
||||
})
|
||||
|
||||
it('clears embedded user id when system variable is absent', async () => {
|
||||
useWebAppStore.setState(state => ({
|
||||
...state,
|
||||
embeddedUserId: 'previous-user',
|
||||
embeddedConversationId: 'existing-conversation',
|
||||
}))
|
||||
mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({})
|
||||
|
||||
render(
|
||||
<WebAppStoreProvider>
|
||||
<TestConsumer />
|
||||
</WebAppStoreProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('embedded-user-id')).toHaveTextContent('null')
|
||||
expect(screen.getByTestId('embedded-conversation-id')).toHaveTextContent('null')
|
||||
})
|
||||
expect(useWebAppStore.getState().embeddedUserId).toBeNull()
|
||||
expect(useWebAppStore.getState().embeddedConversationId).toBeNull()
|
||||
})
|
||||
})
|
||||
333
dify/web/__tests__/goto-anything/command-selector.test.tsx
Normal file
333
dify/web/__tests__/goto-anything/command-selector.test.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import CommandSelector from '../../app/components/goto-anything/command-selector'
|
||||
import type { ActionItem } from '../../app/components/goto-anything/actions/types'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('cmdk', () => ({
|
||||
Command: {
|
||||
Group: ({ children, className }: any) => <div className={className}>{children}</div>,
|
||||
Item: ({ children, onSelect, value, className }: any) => (
|
||||
<div
|
||||
className={className}
|
||||
onClick={() => onSelect?.()}
|
||||
data-value={value}
|
||||
data-testid={`command-item-${value}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('CommandSelector', () => {
|
||||
const mockActions: Record<string, ActionItem> = {
|
||||
app: {
|
||||
key: '@app',
|
||||
shortcut: '@app',
|
||||
title: 'Search Applications',
|
||||
description: 'Search apps',
|
||||
search: jest.fn(),
|
||||
},
|
||||
knowledge: {
|
||||
key: '@knowledge',
|
||||
shortcut: '@kb',
|
||||
title: 'Search Knowledge',
|
||||
description: 'Search knowledge bases',
|
||||
search: jest.fn(),
|
||||
},
|
||||
plugin: {
|
||||
key: '@plugin',
|
||||
shortcut: '@plugin',
|
||||
title: 'Search Plugins',
|
||||
description: 'Search plugins',
|
||||
search: jest.fn(),
|
||||
},
|
||||
node: {
|
||||
key: '@node',
|
||||
shortcut: '@node',
|
||||
title: 'Search Nodes',
|
||||
description: 'Search workflow nodes',
|
||||
search: jest.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
const mockOnCommandSelect = jest.fn()
|
||||
const mockOnCommandValueChange = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render all actions when no filter is provided', () => {
|
||||
render(
|
||||
<CommandSelector
|
||||
actions={mockActions}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('command-item-@app')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('command-item-@node')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render empty filter as showing all actions', () => {
|
||||
render(
|
||||
<CommandSelector
|
||||
actions={mockActions}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
searchFilter=""
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('command-item-@app')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('command-item-@node')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Filtering Functionality', () => {
|
||||
it('should filter actions based on searchFilter - single match', () => {
|
||||
render(
|
||||
<CommandSelector
|
||||
actions={mockActions}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
searchFilter="k"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('command-item-@plugin')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('command-item-@node')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter actions with multiple matches', () => {
|
||||
render(
|
||||
<CommandSelector
|
||||
actions={mockActions}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
searchFilter="p"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('command-item-@app')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('command-item-@kb')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('command-item-@node')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be case-insensitive when filtering', () => {
|
||||
render(
|
||||
<CommandSelector
|
||||
actions={mockActions}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
searchFilter="APP"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('command-item-@app')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('command-item-@kb')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should match partial strings', () => {
|
||||
render(
|
||||
<CommandSelector
|
||||
actions={mockActions}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
searchFilter="od"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('command-item-@kb')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('command-item-@plugin')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('command-item-@node')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should show empty state when no matches found', () => {
|
||||
render(
|
||||
<CommandSelector
|
||||
actions={mockActions}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
searchFilter="xyz"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('command-item-@kb')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('command-item-@plugin')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('command-item-@node')).not.toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.gotoAnything.tryDifferentSearch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show empty state when filter is empty', () => {
|
||||
render(
|
||||
<CommandSelector
|
||||
actions={mockActions}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
searchFilter=""
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('app.gotoAnything.noMatchingCommands')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selection and Highlight Management', () => {
|
||||
it('should call onCommandValueChange when filter changes and first item differs', () => {
|
||||
const { rerender } = render(
|
||||
<CommandSelector
|
||||
actions={mockActions}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
searchFilter=""
|
||||
commandValue="@app"
|
||||
onCommandValueChange={mockOnCommandValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<CommandSelector
|
||||
actions={mockActions}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
searchFilter="k"
|
||||
commandValue="@app"
|
||||
onCommandValueChange={mockOnCommandValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockOnCommandValueChange).toHaveBeenCalledWith('@kb')
|
||||
})
|
||||
|
||||
it('should not call onCommandValueChange if current value still exists', () => {
|
||||
const { rerender } = render(
|
||||
<CommandSelector
|
||||
actions={mockActions}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
searchFilter=""
|
||||
commandValue="@app"
|
||||
onCommandValueChange={mockOnCommandValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<CommandSelector
|
||||
actions={mockActions}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
searchFilter="a"
|
||||
commandValue="@app"
|
||||
onCommandValueChange={mockOnCommandValueChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockOnCommandValueChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle onCommandSelect callback correctly', () => {
|
||||
render(
|
||||
<CommandSelector
|
||||
actions={mockActions}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
searchFilter="k"
|
||||
/>,
|
||||
)
|
||||
|
||||
const knowledgeItem = screen.getByTestId('command-item-@kb')
|
||||
fireEvent.click(knowledgeItem)
|
||||
|
||||
expect(mockOnCommandSelect).toHaveBeenCalledWith('@kb')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty actions object', () => {
|
||||
render(
|
||||
<CommandSelector
|
||||
actions={{}}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
searchFilter=""
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in filter', () => {
|
||||
render(
|
||||
<CommandSelector
|
||||
actions={mockActions}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
searchFilter="@"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('command-item-@app')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('command-item-@node')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined onCommandValueChange gracefully', () => {
|
||||
const { rerender } = render(
|
||||
<CommandSelector
|
||||
actions={mockActions}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
searchFilter=""
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(() => {
|
||||
rerender(
|
||||
<CommandSelector
|
||||
actions={mockActions}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
searchFilter="k"
|
||||
/>,
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Backward Compatibility', () => {
|
||||
it('should work without searchFilter prop (backward compatible)', () => {
|
||||
render(
|
||||
<CommandSelector
|
||||
actions={mockActions}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('command-item-@app')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('command-item-@node')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should work without commandValue and onCommandValueChange props', () => {
|
||||
render(
|
||||
<CommandSelector
|
||||
actions={mockActions}
|
||||
onCommandSelect={mockOnCommandSelect}
|
||||
searchFilter="k"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('command-item-@kb')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
235
dify/web/__tests__/goto-anything/match-action.test.ts
Normal file
235
dify/web/__tests__/goto-anything/match-action.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import type { ActionItem } from '../../app/components/goto-anything/actions/types'
|
||||
|
||||
// Mock the entire actions module to avoid import issues
|
||||
jest.mock('../../app/components/goto-anything/actions', () => ({
|
||||
matchAction: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('../../app/components/goto-anything/actions/commands/registry')
|
||||
|
||||
// Import after mocking to get mocked version
|
||||
import { matchAction } from '../../app/components/goto-anything/actions'
|
||||
import { slashCommandRegistry } from '../../app/components/goto-anything/actions/commands/registry'
|
||||
|
||||
// Implement the actual matchAction logic for testing
|
||||
const actualMatchAction = (query: string, actions: Record<string, ActionItem>) => {
|
||||
const result = Object.values(actions).find((action) => {
|
||||
// Special handling for slash commands
|
||||
if (action.key === '/') {
|
||||
// Get all registered commands from the registry
|
||||
const allCommands = slashCommandRegistry.getAllCommands()
|
||||
|
||||
// Check if query matches any registered command
|
||||
return allCommands.some((cmd) => {
|
||||
const cmdPattern = `/${cmd.name}`
|
||||
|
||||
// For direct mode commands, don't match (keep in command selector)
|
||||
if (cmd.mode === 'direct')
|
||||
return false
|
||||
|
||||
// For submenu mode commands, match when complete command is entered
|
||||
return query === cmdPattern || query.startsWith(`${cmdPattern} `)
|
||||
})
|
||||
}
|
||||
|
||||
const reg = new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`)
|
||||
return reg.test(query)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// Replace mock with actual implementation
|
||||
;(matchAction as jest.Mock).mockImplementation(actualMatchAction)
|
||||
|
||||
describe('matchAction Logic', () => {
|
||||
const mockActions: Record<string, ActionItem> = {
|
||||
app: {
|
||||
key: '@app',
|
||||
shortcut: '@a',
|
||||
title: 'Search Applications',
|
||||
description: 'Search apps',
|
||||
search: jest.fn(),
|
||||
},
|
||||
knowledge: {
|
||||
key: '@knowledge',
|
||||
shortcut: '@kb',
|
||||
title: 'Search Knowledge',
|
||||
description: 'Search knowledge bases',
|
||||
search: jest.fn(),
|
||||
},
|
||||
slash: {
|
||||
key: '/',
|
||||
shortcut: '/',
|
||||
title: 'Commands',
|
||||
description: 'Execute commands',
|
||||
search: jest.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([
|
||||
{ name: 'docs', mode: 'direct' },
|
||||
{ name: 'community', mode: 'direct' },
|
||||
{ name: 'feedback', mode: 'direct' },
|
||||
{ name: 'account', mode: 'direct' },
|
||||
{ name: 'theme', mode: 'submenu' },
|
||||
{ name: 'language', mode: 'submenu' },
|
||||
])
|
||||
})
|
||||
|
||||
describe('@ Actions Matching', () => {
|
||||
it('should match @app with key', () => {
|
||||
const result = matchAction('@app', mockActions)
|
||||
expect(result).toBe(mockActions.app)
|
||||
})
|
||||
|
||||
it('should match @app with shortcut', () => {
|
||||
const result = matchAction('@a', mockActions)
|
||||
expect(result).toBe(mockActions.app)
|
||||
})
|
||||
|
||||
it('should match @knowledge with key', () => {
|
||||
const result = matchAction('@knowledge', mockActions)
|
||||
expect(result).toBe(mockActions.knowledge)
|
||||
})
|
||||
|
||||
it('should match @knowledge with shortcut @kb', () => {
|
||||
const result = matchAction('@kb', mockActions)
|
||||
expect(result).toBe(mockActions.knowledge)
|
||||
})
|
||||
|
||||
it('should match with text after action', () => {
|
||||
const result = matchAction('@app search term', mockActions)
|
||||
expect(result).toBe(mockActions.app)
|
||||
})
|
||||
|
||||
it('should not match partial @ actions', () => {
|
||||
const result = matchAction('@ap', mockActions)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Slash Commands Matching', () => {
|
||||
describe('Direct Mode Commands', () => {
|
||||
it('should not match direct mode commands', () => {
|
||||
const result = matchAction('/docs', mockActions)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not match direct mode with arguments', () => {
|
||||
const result = matchAction('/docs something', mockActions)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not match any direct mode command', () => {
|
||||
expect(matchAction('/community', mockActions)).toBeUndefined()
|
||||
expect(matchAction('/feedback', mockActions)).toBeUndefined()
|
||||
expect(matchAction('/account', mockActions)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Submenu Mode Commands', () => {
|
||||
it('should match submenu mode commands exactly', () => {
|
||||
const result = matchAction('/theme', mockActions)
|
||||
expect(result).toBe(mockActions.slash)
|
||||
})
|
||||
|
||||
it('should match submenu mode with arguments', () => {
|
||||
const result = matchAction('/theme dark', mockActions)
|
||||
expect(result).toBe(mockActions.slash)
|
||||
})
|
||||
|
||||
it('should match all submenu commands', () => {
|
||||
expect(matchAction('/language', mockActions)).toBe(mockActions.slash)
|
||||
expect(matchAction('/language en', mockActions)).toBe(mockActions.slash)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Slash Without Command', () => {
|
||||
it('should not match single slash', () => {
|
||||
const result = matchAction('/', mockActions)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not match unregistered commands', () => {
|
||||
const result = matchAction('/unknown', mockActions)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty query', () => {
|
||||
const result = matchAction('', mockActions)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle whitespace only', () => {
|
||||
const result = matchAction(' ', mockActions)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle regular text without actions', () => {
|
||||
const result = matchAction('search something', mockActions)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle special characters', () => {
|
||||
const result = matchAction('#tag', mockActions)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle multiple @ or /', () => {
|
||||
expect(matchAction('@@app', mockActions)).toBeUndefined()
|
||||
expect(matchAction('//theme', mockActions)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mode-based Filtering', () => {
|
||||
it('should filter direct mode commands from matching', () => {
|
||||
;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([
|
||||
{ name: 'test', mode: 'direct' },
|
||||
])
|
||||
|
||||
const result = matchAction('/test', mockActions)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should allow submenu mode commands to match', () => {
|
||||
;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([
|
||||
{ name: 'test', mode: 'submenu' },
|
||||
])
|
||||
|
||||
const result = matchAction('/test', mockActions)
|
||||
expect(result).toBe(mockActions.slash)
|
||||
})
|
||||
|
||||
it('should treat undefined mode as submenu', () => {
|
||||
;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([
|
||||
{ name: 'test' }, // No mode specified
|
||||
])
|
||||
|
||||
const result = matchAction('/test', mockActions)
|
||||
expect(result).toBe(mockActions.slash)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registry Integration', () => {
|
||||
it('should call getAllCommands when matching slash', () => {
|
||||
matchAction('/theme', mockActions)
|
||||
expect(slashCommandRegistry.getAllCommands).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call getAllCommands for @ actions', () => {
|
||||
matchAction('@app', mockActions)
|
||||
expect(slashCommandRegistry.getAllCommands).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle empty command list', () => {
|
||||
;(slashCommandRegistry.getAllCommands as jest.Mock).mockReturnValue([])
|
||||
const result = matchAction('/anything', mockActions)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
134
dify/web/__tests__/goto-anything/scope-command-tags.test.tsx
Normal file
134
dify/web/__tests__/goto-anything/scope-command-tags.test.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Type alias for search mode
|
||||
type SearchMode = 'scopes' | 'commands' | null
|
||||
|
||||
// Mock component to test tag display logic
|
||||
const TagDisplay: React.FC<{ searchMode: SearchMode }> = ({ searchMode }) => {
|
||||
if (!searchMode) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs text-text-tertiary">
|
||||
<span>{searchMode === 'scopes' ? 'SCOPES' : 'COMMANDS'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Scope and Command Tags', () => {
|
||||
describe('Tag Display Logic', () => {
|
||||
it('should display SCOPES for @ actions', () => {
|
||||
render(<TagDisplay searchMode="scopes" />)
|
||||
expect(screen.getByText('SCOPES')).toBeInTheDocument()
|
||||
expect(screen.queryByText('COMMANDS')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display COMMANDS for / actions', () => {
|
||||
render(<TagDisplay searchMode="commands" />)
|
||||
expect(screen.getByText('COMMANDS')).toBeInTheDocument()
|
||||
expect(screen.queryByText('SCOPES')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not display any tag when searchMode is null', () => {
|
||||
const { container } = render(<TagDisplay searchMode={null} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search Mode Detection', () => {
|
||||
const getSearchMode = (query: string): SearchMode => {
|
||||
if (query.startsWith('@')) return 'scopes'
|
||||
if (query.startsWith('/')) return 'commands'
|
||||
return null
|
||||
}
|
||||
|
||||
it('should detect scopes mode for @ queries', () => {
|
||||
expect(getSearchMode('@app')).toBe('scopes')
|
||||
expect(getSearchMode('@knowledge')).toBe('scopes')
|
||||
expect(getSearchMode('@plugin')).toBe('scopes')
|
||||
expect(getSearchMode('@node')).toBe('scopes')
|
||||
})
|
||||
|
||||
it('should detect commands mode for / queries', () => {
|
||||
expect(getSearchMode('/theme')).toBe('commands')
|
||||
expect(getSearchMode('/language')).toBe('commands')
|
||||
expect(getSearchMode('/docs')).toBe('commands')
|
||||
})
|
||||
|
||||
it('should return null for regular queries', () => {
|
||||
expect(getSearchMode('')).toBe(null)
|
||||
expect(getSearchMode('search term')).toBe(null)
|
||||
expect(getSearchMode('app')).toBe(null)
|
||||
})
|
||||
|
||||
it('should handle queries with spaces', () => {
|
||||
expect(getSearchMode('@app search')).toBe('scopes')
|
||||
expect(getSearchMode('/theme dark')).toBe('commands')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tag Styling', () => {
|
||||
it('should apply correct styling classes', () => {
|
||||
const { container } = render(<TagDisplay searchMode="scopes" />)
|
||||
const tagContainer = container.querySelector('.flex.items-center.gap-1.text-xs.text-text-tertiary')
|
||||
expect(tagContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use hardcoded English text', () => {
|
||||
// Verify that tags are hardcoded and not using i18n
|
||||
render(<TagDisplay searchMode="scopes" />)
|
||||
const scopesText = screen.getByText('SCOPES')
|
||||
expect(scopesText.textContent).toBe('SCOPES')
|
||||
|
||||
render(<TagDisplay searchMode="commands" />)
|
||||
const commandsText = screen.getByText('COMMANDS')
|
||||
expect(commandsText.textContent).toBe('COMMANDS')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration with Search States', () => {
|
||||
const SearchComponent: React.FC<{ query: string }> = ({ query }) => {
|
||||
let searchMode: SearchMode = null
|
||||
|
||||
if (query.startsWith('@')) searchMode = 'scopes'
|
||||
else if (query.startsWith('/')) searchMode = 'commands'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input value={query} readOnly />
|
||||
<TagDisplay searchMode={searchMode} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
it('should update tag when switching between @ and /', () => {
|
||||
const { rerender } = render(<SearchComponent query="@app" />)
|
||||
expect(screen.getByText('SCOPES')).toBeInTheDocument()
|
||||
|
||||
rerender(<SearchComponent query="/theme" />)
|
||||
expect(screen.queryByText('SCOPES')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('COMMANDS')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide tag when clearing search', () => {
|
||||
const { rerender } = render(<SearchComponent query="@app" />)
|
||||
expect(screen.getByText('SCOPES')).toBeInTheDocument()
|
||||
|
||||
rerender(<SearchComponent query="" />)
|
||||
expect(screen.queryByText('SCOPES')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('COMMANDS')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should maintain correct tag during search refinement', () => {
|
||||
const { rerender } = render(<SearchComponent query="@" />)
|
||||
expect(screen.getByText('SCOPES')).toBeInTheDocument()
|
||||
|
||||
rerender(<SearchComponent query="@app" />)
|
||||
expect(screen.getByText('SCOPES')).toBeInTheDocument()
|
||||
|
||||
rerender(<SearchComponent query="@app test" />)
|
||||
expect(screen.getByText('SCOPES')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
197
dify/web/__tests__/goto-anything/search-error-handling.test.ts
Normal file
197
dify/web/__tests__/goto-anything/search-error-handling.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Test GotoAnything search error handling mechanisms
|
||||
*
|
||||
* Main validations:
|
||||
* 1. @plugin search error handling when API fails
|
||||
* 2. Regular search (without @prefix) error handling when API fails
|
||||
* 3. Verify consistent error handling across different search types
|
||||
* 4. Ensure errors don't propagate to UI layer causing "search failed"
|
||||
*/
|
||||
|
||||
import { Actions, searchAnything } from '@/app/components/goto-anything/actions'
|
||||
import { postMarketplace } from '@/service/base'
|
||||
import { fetchAppList } from '@/service/apps'
|
||||
import { fetchDatasets } from '@/service/datasets'
|
||||
|
||||
// Mock API functions
|
||||
jest.mock('@/service/base', () => ({
|
||||
postMarketplace: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/apps', () => ({
|
||||
fetchAppList: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/datasets', () => ({
|
||||
fetchDatasets: jest.fn(),
|
||||
}))
|
||||
|
||||
const mockPostMarketplace = postMarketplace as jest.MockedFunction<typeof postMarketplace>
|
||||
const mockFetchAppList = fetchAppList as jest.MockedFunction<typeof fetchAppList>
|
||||
const mockFetchDatasets = fetchDatasets as jest.MockedFunction<typeof fetchDatasets>
|
||||
|
||||
describe('GotoAnything Search Error Handling', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
// Suppress console.warn for clean test output
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {
|
||||
// Suppress console.warn for clean test output
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('@plugin search error handling', () => {
|
||||
it('should return empty array when API fails instead of throwing error', async () => {
|
||||
// Mock marketplace API failure (403 permission denied)
|
||||
mockPostMarketplace.mockRejectedValue(new Error('HTTP 403: Forbidden'))
|
||||
|
||||
const pluginAction = Actions.plugin
|
||||
|
||||
// Directly call plugin action's search method
|
||||
const result = await pluginAction.search('@plugin', 'test', 'en')
|
||||
|
||||
// Should return empty array instead of throwing error
|
||||
expect(result).toEqual([])
|
||||
expect(mockPostMarketplace).toHaveBeenCalledWith('/plugins/search/advanced', {
|
||||
body: {
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
query: 'test',
|
||||
type: 'plugin',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should return empty array when user has no plugin data', async () => {
|
||||
// Mock marketplace returning empty data
|
||||
mockPostMarketplace.mockResolvedValue({
|
||||
data: { plugins: [] },
|
||||
})
|
||||
|
||||
const pluginAction = Actions.plugin
|
||||
const result = await pluginAction.search('@plugin', '', 'en')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty array when API returns unexpected data structure', async () => {
|
||||
// Mock API returning unexpected data structure
|
||||
mockPostMarketplace.mockResolvedValue({
|
||||
data: null,
|
||||
})
|
||||
|
||||
const pluginAction = Actions.plugin
|
||||
const result = await pluginAction.search('@plugin', 'test', 'en')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Other search types error handling', () => {
|
||||
it('@app search should return empty array when API fails', async () => {
|
||||
// Mock app API failure
|
||||
mockFetchAppList.mockRejectedValue(new Error('API Error'))
|
||||
|
||||
const appAction = Actions.app
|
||||
const result = await appAction.search('@app', 'test', 'en')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('@knowledge search should return empty array when API fails', async () => {
|
||||
// Mock knowledge API failure
|
||||
mockFetchDatasets.mockRejectedValue(new Error('API Error'))
|
||||
|
||||
const knowledgeAction = Actions.knowledge
|
||||
const result = await knowledgeAction.search('@knowledge', 'test', 'en')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unified search entry error handling', () => {
|
||||
it('regular search (without @prefix) should return successful results even when partial APIs fail', async () => {
|
||||
// Set app and knowledge success, plugin failure
|
||||
mockFetchAppList.mockResolvedValue({ data: [], has_more: false, limit: 10, page: 1, total: 0 })
|
||||
mockFetchDatasets.mockResolvedValue({ data: [], has_more: false, limit: 10, page: 1, total: 0 })
|
||||
mockPostMarketplace.mockRejectedValue(new Error('Plugin API failed'))
|
||||
|
||||
const result = await searchAnything('en', 'test')
|
||||
|
||||
// Should return successful results even if plugin search fails
|
||||
expect(result).toEqual([])
|
||||
expect(console.warn).toHaveBeenCalledWith('Plugin search failed:', expect.any(Error))
|
||||
})
|
||||
|
||||
it('@plugin dedicated search should return empty array when API fails', async () => {
|
||||
// Mock plugin API failure
|
||||
mockPostMarketplace.mockRejectedValue(new Error('Plugin service unavailable'))
|
||||
|
||||
const pluginAction = Actions.plugin
|
||||
const result = await searchAnything('en', '@plugin test', pluginAction)
|
||||
|
||||
// Should return empty array instead of throwing error
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('@app dedicated search should return empty array when API fails', async () => {
|
||||
// Mock app API failure
|
||||
mockFetchAppList.mockRejectedValue(new Error('App service unavailable'))
|
||||
|
||||
const appAction = Actions.app
|
||||
const result = await searchAnything('en', '@app test', appAction)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error handling consistency validation', () => {
|
||||
it('all search types should return empty array when encountering errors', async () => {
|
||||
// Mock all APIs to fail
|
||||
mockPostMarketplace.mockRejectedValue(new Error('Plugin API failed'))
|
||||
mockFetchAppList.mockRejectedValue(new Error('App API failed'))
|
||||
mockFetchDatasets.mockRejectedValue(new Error('Dataset API failed'))
|
||||
|
||||
const actions = [
|
||||
{ name: '@plugin', action: Actions.plugin },
|
||||
{ name: '@app', action: Actions.app },
|
||||
{ name: '@knowledge', action: Actions.knowledge },
|
||||
]
|
||||
|
||||
for (const { name, action } of actions) {
|
||||
const result = await action.search(name, 'test', 'en')
|
||||
expect(result).toEqual([])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge case testing', () => {
|
||||
it('empty search term should be handled properly', async () => {
|
||||
mockPostMarketplace.mockResolvedValue({ data: { plugins: [] } })
|
||||
|
||||
const result = await searchAnything('en', '@plugin ', Actions.plugin)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('network timeout should be handled correctly', async () => {
|
||||
const timeoutError = new Error('Network timeout')
|
||||
timeoutError.name = 'TimeoutError'
|
||||
|
||||
mockPostMarketplace.mockRejectedValue(timeoutError)
|
||||
|
||||
const result = await searchAnything('en', '@plugin test', Actions.plugin)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('JSON parsing errors should be handled correctly', async () => {
|
||||
const parseError = new SyntaxError('Unexpected token in JSON')
|
||||
mockPostMarketplace.mockRejectedValue(parseError)
|
||||
|
||||
const result = await searchAnything('en', '@plugin test', Actions.plugin)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
212
dify/web/__tests__/goto-anything/slash-command-modes.test.tsx
Normal file
212
dify/web/__tests__/goto-anything/slash-command-modes.test.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { slashCommandRegistry } from '../../app/components/goto-anything/actions/commands/registry'
|
||||
import type { SlashCommandHandler } from '../../app/components/goto-anything/actions/commands/types'
|
||||
|
||||
// Mock the registry
|
||||
jest.mock('../../app/components/goto-anything/actions/commands/registry')
|
||||
|
||||
describe('Slash Command Dual-Mode System', () => {
|
||||
const mockDirectCommand: SlashCommandHandler = {
|
||||
name: 'docs',
|
||||
description: 'Open documentation',
|
||||
mode: 'direct',
|
||||
execute: jest.fn(),
|
||||
search: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'docs',
|
||||
title: 'Documentation',
|
||||
description: 'Open documentation',
|
||||
type: 'command' as const,
|
||||
data: { command: 'navigation.docs', args: {} },
|
||||
},
|
||||
]),
|
||||
register: jest.fn(),
|
||||
unregister: jest.fn(),
|
||||
}
|
||||
|
||||
const mockSubmenuCommand: SlashCommandHandler = {
|
||||
name: 'theme',
|
||||
description: 'Change theme',
|
||||
mode: 'submenu',
|
||||
search: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'theme-light',
|
||||
title: 'Light Theme',
|
||||
description: 'Switch to light theme',
|
||||
type: 'command' as const,
|
||||
data: { command: 'theme.set', args: { theme: 'light' } },
|
||||
},
|
||||
{
|
||||
id: 'theme-dark',
|
||||
title: 'Dark Theme',
|
||||
description: 'Switch to dark theme',
|
||||
type: 'command' as const,
|
||||
data: { command: 'theme.set', args: { theme: 'dark' } },
|
||||
},
|
||||
]),
|
||||
register: jest.fn(),
|
||||
unregister: jest.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(slashCommandRegistry as any).findCommand = jest.fn((name: string) => {
|
||||
if (name === 'docs') return mockDirectCommand
|
||||
if (name === 'theme') return mockSubmenuCommand
|
||||
return null
|
||||
})
|
||||
;(slashCommandRegistry as any).getAllCommands = jest.fn(() => [
|
||||
mockDirectCommand,
|
||||
mockSubmenuCommand,
|
||||
])
|
||||
})
|
||||
|
||||
describe('Direct Mode Commands', () => {
|
||||
it('should execute immediately when selected', () => {
|
||||
const mockSetShow = jest.fn()
|
||||
const mockSetSearchQuery = jest.fn()
|
||||
|
||||
// Simulate command selection
|
||||
const handler = slashCommandRegistry.findCommand('docs')
|
||||
expect(handler?.mode).toBe('direct')
|
||||
|
||||
if (handler?.mode === 'direct' && handler.execute) {
|
||||
handler.execute()
|
||||
mockSetShow(false)
|
||||
mockSetSearchQuery('')
|
||||
}
|
||||
|
||||
expect(mockDirectCommand.execute).toHaveBeenCalled()
|
||||
expect(mockSetShow).toHaveBeenCalledWith(false)
|
||||
expect(mockSetSearchQuery).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('should not enter submenu for direct mode commands', () => {
|
||||
const handler = slashCommandRegistry.findCommand('docs')
|
||||
expect(handler?.mode).toBe('direct')
|
||||
expect(handler?.execute).toBeDefined()
|
||||
})
|
||||
|
||||
it('should close modal after execution', () => {
|
||||
const mockModalClose = jest.fn()
|
||||
|
||||
const handler = slashCommandRegistry.findCommand('docs')
|
||||
if (handler?.mode === 'direct' && handler.execute) {
|
||||
handler.execute()
|
||||
mockModalClose()
|
||||
}
|
||||
|
||||
expect(mockModalClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Submenu Mode Commands', () => {
|
||||
it('should show options instead of executing immediately', async () => {
|
||||
const handler = slashCommandRegistry.findCommand('theme')
|
||||
expect(handler?.mode).toBe('submenu')
|
||||
|
||||
const results = await handler?.search('', 'en')
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results?.[0].title).toBe('Light Theme')
|
||||
expect(results?.[1].title).toBe('Dark Theme')
|
||||
})
|
||||
|
||||
it('should not have execute function for submenu mode', () => {
|
||||
const handler = slashCommandRegistry.findCommand('theme')
|
||||
expect(handler?.mode).toBe('submenu')
|
||||
expect(handler?.execute).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should keep modal open for selection', () => {
|
||||
const mockModalClose = jest.fn()
|
||||
|
||||
const handler = slashCommandRegistry.findCommand('theme')
|
||||
// For submenu mode, modal should not close immediately
|
||||
expect(handler?.mode).toBe('submenu')
|
||||
expect(mockModalClose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mode Detection and Routing', () => {
|
||||
it('should correctly identify direct mode commands', () => {
|
||||
const commands = slashCommandRegistry.getAllCommands()
|
||||
const directCommands = commands.filter(cmd => cmd.mode === 'direct')
|
||||
const submenuCommands = commands.filter(cmd => cmd.mode === 'submenu')
|
||||
|
||||
expect(directCommands).toContainEqual(expect.objectContaining({ name: 'docs' }))
|
||||
expect(submenuCommands).toContainEqual(expect.objectContaining({ name: 'theme' }))
|
||||
})
|
||||
|
||||
it('should handle missing mode property gracefully', () => {
|
||||
const commandWithoutMode: SlashCommandHandler = {
|
||||
name: 'test',
|
||||
description: 'Test command',
|
||||
search: jest.fn(),
|
||||
register: jest.fn(),
|
||||
unregister: jest.fn(),
|
||||
}
|
||||
|
||||
;(slashCommandRegistry as any).findCommand = jest.fn(() => commandWithoutMode)
|
||||
|
||||
const handler = slashCommandRegistry.findCommand('test')
|
||||
// Default behavior should be submenu when mode is not specified
|
||||
expect(handler?.mode).toBeUndefined()
|
||||
expect(handler?.execute).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Enter Key Handling', () => {
|
||||
// Helper function to simulate key handler behavior
|
||||
const createKeyHandler = () => {
|
||||
return (commandKey: string) => {
|
||||
if (commandKey.startsWith('/')) {
|
||||
const commandName = commandKey.substring(1)
|
||||
const handler = slashCommandRegistry.findCommand(commandName)
|
||||
if (handler?.mode === 'direct' && handler.execute) {
|
||||
handler.execute()
|
||||
return true // Indicates handled
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
it('should trigger direct execution on Enter for direct mode', () => {
|
||||
const keyHandler = createKeyHandler()
|
||||
const handled = keyHandler('/docs')
|
||||
expect(handled).toBe(true)
|
||||
expect(mockDirectCommand.execute).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not trigger direct execution for submenu mode', () => {
|
||||
const keyHandler = createKeyHandler()
|
||||
const handled = keyHandler('/theme')
|
||||
expect(handled).toBe(false)
|
||||
expect(mockSubmenuCommand.search).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Command Registration', () => {
|
||||
it('should register both direct and submenu commands', () => {
|
||||
mockDirectCommand.register?.({})
|
||||
mockSubmenuCommand.register?.({ setTheme: jest.fn() })
|
||||
|
||||
expect(mockDirectCommand.register).toHaveBeenCalled()
|
||||
expect(mockSubmenuCommand.register).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle unregistration for both command types', () => {
|
||||
// Test unregister for direct command
|
||||
mockDirectCommand.unregister?.()
|
||||
expect(mockDirectCommand.unregister).toHaveBeenCalled()
|
||||
|
||||
// Test unregister for submenu command
|
||||
mockSubmenuCommand.unregister?.()
|
||||
expect(mockSubmenuCommand.unregister).toHaveBeenCalled()
|
||||
|
||||
// Verify both were called independently
|
||||
expect(mockDirectCommand.unregister).toHaveBeenCalledTimes(1)
|
||||
expect(mockSubmenuCommand.unregister).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
119
dify/web/__tests__/i18n-upload-features.test.ts
Normal file
119
dify/web/__tests__/i18n-upload-features.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Test suite for verifying upload feature translations across all locales
|
||||
* Specifically tests for issue #23062: Missing Upload feature translations (esp. audioUpload) across most locales
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
// Get all supported locales from the i18n directory
|
||||
const I18N_DIR = path.join(__dirname, '../i18n')
|
||||
const getSupportedLocales = (): string[] => {
|
||||
return fs.readdirSync(I18N_DIR)
|
||||
.filter(item => fs.statSync(path.join(I18N_DIR, item)).isDirectory())
|
||||
.sort()
|
||||
}
|
||||
|
||||
// Helper function to load translation file content
|
||||
const loadTranslationContent = (locale: string): string => {
|
||||
const filePath = path.join(I18N_DIR, locale, 'app-debug.ts')
|
||||
|
||||
if (!fs.existsSync(filePath))
|
||||
throw new Error(`Translation file not found: ${filePath}`)
|
||||
|
||||
return fs.readFileSync(filePath, 'utf-8')
|
||||
}
|
||||
|
||||
// Helper function to check if upload features exist
|
||||
const hasUploadFeatures = (content: string): { [key: string]: boolean } => {
|
||||
return {
|
||||
fileUpload: /fileUpload\s*:\s*{/.test(content),
|
||||
imageUpload: /imageUpload\s*:\s*{/.test(content),
|
||||
documentUpload: /documentUpload\s*:\s*{/.test(content),
|
||||
audioUpload: /audioUpload\s*:\s*{/.test(content),
|
||||
featureBar: /bar\s*:\s*{/.test(content),
|
||||
}
|
||||
}
|
||||
|
||||
describe('Upload Features i18n Translations - Issue #23062', () => {
|
||||
let supportedLocales: string[]
|
||||
|
||||
beforeAll(() => {
|
||||
supportedLocales = getSupportedLocales()
|
||||
console.log(`Testing ${supportedLocales.length} locales for upload features`)
|
||||
})
|
||||
|
||||
test('all locales should have translation files', () => {
|
||||
supportedLocales.forEach((locale) => {
|
||||
const filePath = path.join(I18N_DIR, locale, 'app-debug.ts')
|
||||
expect(fs.existsSync(filePath)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test('all locales should have required upload features', () => {
|
||||
const results: { [locale: string]: { [feature: string]: boolean } } = {}
|
||||
|
||||
supportedLocales.forEach((locale) => {
|
||||
const content = loadTranslationContent(locale)
|
||||
const features = hasUploadFeatures(content)
|
||||
results[locale] = features
|
||||
|
||||
// Check that all upload features exist
|
||||
expect(features.fileUpload).toBe(true)
|
||||
expect(features.imageUpload).toBe(true)
|
||||
expect(features.documentUpload).toBe(true)
|
||||
expect(features.audioUpload).toBe(true)
|
||||
expect(features.featureBar).toBe(true)
|
||||
})
|
||||
|
||||
console.log('✅ All locales have complete upload features')
|
||||
})
|
||||
|
||||
test('previously missing locales should now have audioUpload - Issue #23062', () => {
|
||||
// These locales were specifically missing audioUpload
|
||||
const previouslyMissingLocales = ['fa-IR', 'hi-IN', 'ro-RO', 'sl-SI', 'th-TH', 'uk-UA', 'vi-VN']
|
||||
|
||||
previouslyMissingLocales.forEach((locale) => {
|
||||
const content = loadTranslationContent(locale)
|
||||
|
||||
// Verify audioUpload exists
|
||||
expect(/audioUpload\s*:\s*{/.test(content)).toBe(true)
|
||||
|
||||
// Verify it has title and description
|
||||
expect(/audioUpload[^}]*title\s*:/.test(content)).toBe(true)
|
||||
expect(/audioUpload[^}]*description\s*:/.test(content)).toBe(true)
|
||||
|
||||
console.log(`✅ ${locale} - Issue #23062 resolved: audioUpload feature present`)
|
||||
})
|
||||
})
|
||||
|
||||
test('upload features should have required properties', () => {
|
||||
supportedLocales.forEach((locale) => {
|
||||
const content = loadTranslationContent(locale)
|
||||
|
||||
// Check fileUpload has required properties
|
||||
if (/fileUpload\s*:\s*{/.test(content)) {
|
||||
expect(/fileUpload[^}]*title\s*:/.test(content)).toBe(true)
|
||||
expect(/fileUpload[^}]*description\s*:/.test(content)).toBe(true)
|
||||
}
|
||||
|
||||
// Check imageUpload has required properties
|
||||
if (/imageUpload\s*:\s*{/.test(content)) {
|
||||
expect(/imageUpload[^}]*title\s*:/.test(content)).toBe(true)
|
||||
expect(/imageUpload[^}]*description\s*:/.test(content)).toBe(true)
|
||||
}
|
||||
|
||||
// Check documentUpload has required properties
|
||||
if (/documentUpload\s*:\s*{/.test(content)) {
|
||||
expect(/documentUpload[^}]*title\s*:/.test(content)).toBe(true)
|
||||
expect(/documentUpload[^}]*description\s*:/.test(content)).toBe(true)
|
||||
}
|
||||
|
||||
// Check audioUpload has required properties
|
||||
if (/audioUpload\s*:\s*{/.test(content)) {
|
||||
expect(/audioUpload[^}]*title\s*:/.test(content)).toBe(true)
|
||||
expect(/audioUpload[^}]*description\s*:/.test(content)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
401
dify/web/__tests__/navigation-utils.test.ts
Normal file
401
dify/web/__tests__/navigation-utils.test.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Navigation Utilities Test
|
||||
*
|
||||
* Tests for the navigation utility functions to ensure they handle
|
||||
* query parameter preservation correctly across different scenarios.
|
||||
*/
|
||||
|
||||
import {
|
||||
createBackNavigation,
|
||||
createNavigationPath,
|
||||
createNavigationPathWithParams,
|
||||
datasetNavigation,
|
||||
extractQueryParams,
|
||||
mergeQueryParams,
|
||||
} from '@/utils/navigation'
|
||||
|
||||
// Mock router for testing
|
||||
const mockPush = jest.fn()
|
||||
const mockRouter = { push: mockPush }
|
||||
|
||||
describe('Navigation Utilities', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('createNavigationPath', () => {
|
||||
test('preserves query parameters by default', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?page=3&limit=10&keyword=test' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const path = createNavigationPath('/datasets/123/documents')
|
||||
expect(path).toBe('/datasets/123/documents?page=3&limit=10&keyword=test')
|
||||
})
|
||||
|
||||
test('returns clean path when preserveParams is false', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?page=3&limit=10' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const path = createNavigationPath('/datasets/123/documents', false)
|
||||
expect(path).toBe('/datasets/123/documents')
|
||||
})
|
||||
|
||||
test('handles empty query parameters', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const path = createNavigationPath('/datasets/123/documents')
|
||||
expect(path).toBe('/datasets/123/documents')
|
||||
})
|
||||
|
||||
test('handles errors gracefully', () => {
|
||||
// Mock window.location to throw an error
|
||||
Object.defineProperty(window, 'location', {
|
||||
get: () => {
|
||||
throw new Error('Location access denied')
|
||||
},
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
|
||||
const path = createNavigationPath('/datasets/123/documents')
|
||||
|
||||
expect(path).toBe('/datasets/123/documents')
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to preserve query parameters:', expect.any(Error))
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createBackNavigation', () => {
|
||||
test('creates function that navigates with preserved params', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?page=2&limit=25' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const backFn = createBackNavigation(mockRouter, '/datasets/123/documents')
|
||||
backFn()
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/123/documents?page=2&limit=25')
|
||||
})
|
||||
|
||||
test('creates function that navigates without params when specified', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?page=2&limit=25' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const backFn = createBackNavigation(mockRouter, '/datasets/123/documents', false)
|
||||
backFn()
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/123/documents')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractQueryParams', () => {
|
||||
test('extracts specified parameters', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?page=3&limit=10&keyword=test&other=value' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const params = extractQueryParams(['page', 'limit', 'keyword'])
|
||||
expect(params).toEqual({
|
||||
page: '3',
|
||||
limit: '10',
|
||||
keyword: 'test',
|
||||
})
|
||||
})
|
||||
|
||||
test('handles missing parameters', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?page=3' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const params = extractQueryParams(['page', 'limit', 'missing'])
|
||||
expect(params).toEqual({
|
||||
page: '3',
|
||||
})
|
||||
})
|
||||
|
||||
test('handles errors gracefully', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
get: () => {
|
||||
throw new Error('Location access denied')
|
||||
},
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
|
||||
const params = extractQueryParams(['page', 'limit'])
|
||||
|
||||
expect(params).toEqual({})
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to extract query parameters:', expect.any(Error))
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNavigationPathWithParams', () => {
|
||||
test('creates path with specified parameters', () => {
|
||||
const path = createNavigationPathWithParams('/datasets/123/documents', {
|
||||
page: 1,
|
||||
limit: 25,
|
||||
keyword: 'search term',
|
||||
})
|
||||
|
||||
expect(path).toBe('/datasets/123/documents?page=1&limit=25&keyword=search+term')
|
||||
})
|
||||
|
||||
test('filters out empty values', () => {
|
||||
const path = createNavigationPathWithParams('/datasets/123/documents', {
|
||||
page: 1,
|
||||
limit: '',
|
||||
keyword: 'test',
|
||||
filter: '',
|
||||
})
|
||||
|
||||
expect(path).toBe('/datasets/123/documents?page=1&keyword=test')
|
||||
})
|
||||
|
||||
test('handles errors gracefully', () => {
|
||||
// Mock URLSearchParams to throw an error
|
||||
const originalURLSearchParams = globalThis.URLSearchParams
|
||||
globalThis.URLSearchParams = jest.fn(() => {
|
||||
throw new Error('URLSearchParams error')
|
||||
}) as any
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
|
||||
const path = createNavigationPathWithParams('/datasets/123/documents', { page: 1 })
|
||||
|
||||
expect(path).toBe('/datasets/123/documents')
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to create navigation path with params:', expect.any(Error))
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
globalThis.URLSearchParams = originalURLSearchParams
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeQueryParams', () => {
|
||||
test('merges new params with existing ones', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?page=3&limit=10' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const merged = mergeQueryParams({ keyword: 'test', page: '1' })
|
||||
const result = merged.toString()
|
||||
|
||||
expect(result).toContain('page=1') // overridden
|
||||
expect(result).toContain('limit=10') // preserved
|
||||
expect(result).toContain('keyword=test') // added
|
||||
})
|
||||
|
||||
test('removes parameters when value is null', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?page=3&limit=10&keyword=test' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const merged = mergeQueryParams({ keyword: null, filter: 'active' })
|
||||
const result = merged.toString()
|
||||
|
||||
expect(result).toContain('page=3')
|
||||
expect(result).toContain('limit=10')
|
||||
expect(result).not.toContain('keyword')
|
||||
expect(result).toContain('filter=active')
|
||||
})
|
||||
|
||||
test('creates fresh params when preserveExisting is false', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?page=3&limit=10' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const merged = mergeQueryParams({ keyword: 'test' }, false)
|
||||
const result = merged.toString()
|
||||
|
||||
expect(result).toBe('keyword=test')
|
||||
})
|
||||
})
|
||||
|
||||
describe('datasetNavigation', () => {
|
||||
test('backToDocuments creates correct navigation function', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?page=2&limit=25' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const backFn = datasetNavigation.backToDocuments(mockRouter, 'dataset-123')
|
||||
backFn()
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents?page=2&limit=25')
|
||||
})
|
||||
|
||||
test('toDocumentDetail creates correct navigation function', () => {
|
||||
const detailFn = datasetNavigation.toDocumentDetail(mockRouter, 'dataset-123', 'doc-456')
|
||||
detailFn()
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents/doc-456')
|
||||
})
|
||||
|
||||
test('toDocumentSettings creates correct navigation function', () => {
|
||||
const settingsFn = datasetNavigation.toDocumentSettings(mockRouter, 'dataset-123', 'doc-456')
|
||||
settingsFn()
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents/doc-456/settings')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Real-world Integration Scenarios', () => {
|
||||
test('complete user workflow: list -> detail -> back', () => {
|
||||
// User starts on page 3 with search
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?page=3&keyword=API&limit=25' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
// Create back navigation function (as would be done in detail component)
|
||||
const backToDocuments = datasetNavigation.backToDocuments(mockRouter, 'main-dataset')
|
||||
|
||||
// User clicks back
|
||||
backToDocuments()
|
||||
|
||||
// Should return to exact same list state
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/main-dataset/documents?page=3&keyword=API&limit=25')
|
||||
})
|
||||
|
||||
test('user applies filters then views document', () => {
|
||||
// Complex filter state
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?page=1&limit=50&status=active&type=pdf&sort=created_at&order=desc' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const backFn = createBackNavigation(mockRouter, '/datasets/filtered-set/documents')
|
||||
backFn()
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/filtered-set/documents?page=1&limit=50&status=active&type=pdf&sort=created_at&order=desc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases and Error Handling', () => {
|
||||
test('handles special characters in query parameters', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?keyword=hello%20world&filter=type%3Apdf&tag=%E4%B8%AD%E6%96%87' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const path = createNavigationPath('/datasets/123/documents')
|
||||
expect(path).toContain('hello+world')
|
||||
expect(path).toContain('type%3Apdf')
|
||||
expect(path).toContain('%E4%B8%AD%E6%96%87')
|
||||
})
|
||||
|
||||
test('handles duplicate query parameters', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?tag=tag1&tag=tag2&tag=tag3' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const params = extractQueryParams(['tag'])
|
||||
// URLSearchParams.get() returns the first value
|
||||
expect(params.tag).toBe('tag1')
|
||||
})
|
||||
|
||||
test('handles very long query strings', () => {
|
||||
const longValue = 'a'.repeat(1000)
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: `?data=${longValue}` },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const path = createNavigationPath('/datasets/123/documents')
|
||||
expect(path).toContain(longValue)
|
||||
expect(path.length).toBeGreaterThan(1000)
|
||||
})
|
||||
|
||||
test('handles empty string values in query parameters', () => {
|
||||
const path = createNavigationPathWithParams('/datasets/123/documents', {
|
||||
page: 1,
|
||||
keyword: '',
|
||||
filter: '',
|
||||
sort: 'name',
|
||||
})
|
||||
|
||||
expect(path).toBe('/datasets/123/documents?page=1&sort=name')
|
||||
expect(path).not.toContain('keyword=')
|
||||
expect(path).not.toContain('filter=')
|
||||
})
|
||||
|
||||
test('handles null and undefined values in mergeQueryParams', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?page=1&limit=10&keyword=test' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const merged = mergeQueryParams({
|
||||
keyword: null,
|
||||
filter: undefined,
|
||||
sort: 'name',
|
||||
})
|
||||
const result = merged.toString()
|
||||
|
||||
expect(result).toContain('page=1')
|
||||
expect(result).toContain('limit=10')
|
||||
expect(result).not.toContain('keyword')
|
||||
expect(result).toContain('sort=name')
|
||||
})
|
||||
|
||||
test('handles navigation with hash fragments', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?page=1', hash: '#section-2' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const path = createNavigationPath('/datasets/123/documents')
|
||||
// Should preserve query params but not hash
|
||||
expect(path).toBe('/datasets/123/documents?page=1')
|
||||
})
|
||||
|
||||
test('handles malformed query strings gracefully', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?page=1&invalid&limit=10&=value&key=' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const params = extractQueryParams(['page', 'limit', 'invalid', 'key'])
|
||||
expect(params.page).toBe('1')
|
||||
expect(params.limit).toBe('10')
|
||||
// Malformed params should be handled by URLSearchParams
|
||||
expect(params.invalid).toBe('') // for `&invalid`
|
||||
expect(params.key).toBe('') // for `&key=`
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance Tests', () => {
|
||||
test('handles large number of query parameters efficiently', () => {
|
||||
const manyParams = Array.from({ length: 50 }, (_, i) => `param${i}=value${i}`).join('&')
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: `?${manyParams}` },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const startTime = Date.now()
|
||||
const path = createNavigationPath('/datasets/123/documents')
|
||||
const endTime = Date.now()
|
||||
|
||||
expect(endTime - startTime).toBeLessThan(50) // Should be fast
|
||||
expect(path).toContain('param0=value0')
|
||||
expect(path).toContain('param49=value49')
|
||||
})
|
||||
})
|
||||
})
|
||||
207
dify/web/__tests__/plugin-tool-workflow-error.test.tsx
Normal file
207
dify/web/__tests__/plugin-tool-workflow-error.test.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Test cases to reproduce the plugin tool workflow error
|
||||
* Issue: #23154 - Application error when loading plugin tools in workflow
|
||||
* Root cause: split() operation called on null/undefined values
|
||||
*/
|
||||
|
||||
describe('Plugin Tool Workflow Error Reproduction', () => {
|
||||
/**
|
||||
* Mock function to simulate the problematic code in switch-plugin-version.tsx:29
|
||||
* const [pluginId] = uniqueIdentifier.split(':')
|
||||
*/
|
||||
const mockSwitchPluginVersionLogic = (uniqueIdentifier: string | null | undefined) => {
|
||||
// This directly reproduces the problematic line from switch-plugin-version.tsx:29
|
||||
const [pluginId] = uniqueIdentifier!.split(':')
|
||||
return pluginId
|
||||
}
|
||||
|
||||
/**
|
||||
* Test case 1: Simulate null uniqueIdentifier
|
||||
* This should reproduce the error mentioned in the issue
|
||||
*/
|
||||
it('should reproduce error when uniqueIdentifier is null', () => {
|
||||
expect(() => {
|
||||
mockSwitchPluginVersionLogic(null)
|
||||
}).toThrow('Cannot read properties of null (reading \'split\')')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test case 2: Simulate undefined uniqueIdentifier
|
||||
*/
|
||||
it('should reproduce error when uniqueIdentifier is undefined', () => {
|
||||
expect(() => {
|
||||
mockSwitchPluginVersionLogic(undefined)
|
||||
}).toThrow('Cannot read properties of undefined (reading \'split\')')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test case 3: Simulate empty string uniqueIdentifier
|
||||
*/
|
||||
it('should handle empty string uniqueIdentifier', () => {
|
||||
expect(() => {
|
||||
const result = mockSwitchPluginVersionLogic('')
|
||||
expect(result).toBe('') // Empty string split by ':' returns ['']
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test case 4: Simulate malformed uniqueIdentifier without colon separator
|
||||
*/
|
||||
it('should handle malformed uniqueIdentifier without colon separator', () => {
|
||||
expect(() => {
|
||||
const result = mockSwitchPluginVersionLogic('malformed-identifier-without-colon')
|
||||
expect(result).toBe('malformed-identifier-without-colon') // No colon means full string returned
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test case 5: Simulate valid uniqueIdentifier
|
||||
*/
|
||||
it('should work correctly with valid uniqueIdentifier', () => {
|
||||
expect(() => {
|
||||
const result = mockSwitchPluginVersionLogic('valid-plugin-id:1.0.0')
|
||||
expect(result).toBe('valid-plugin-id')
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test for the variable processing split error in use-single-run-form-params
|
||||
*/
|
||||
describe('Variable Processing Split Error', () => {
|
||||
/**
|
||||
* Mock function to simulate the problematic code in use-single-run-form-params.ts:91
|
||||
* const getDependentVars = () => {
|
||||
* return varInputs.map(item => item.variable.slice(1, -1).split('.'))
|
||||
* }
|
||||
*/
|
||||
const mockGetDependentVars = (varInputs: Array<{ variable: string | null | undefined }>) => {
|
||||
return varInputs.map((item) => {
|
||||
// Guard against null/undefined variable to prevent app crash
|
||||
if (!item.variable || typeof item.variable !== 'string')
|
||||
return []
|
||||
|
||||
return item.variable.slice(1, -1).split('.')
|
||||
}).filter(arr => arr.length > 0) // Filter out empty arrays
|
||||
}
|
||||
|
||||
/**
|
||||
* Test case 1: Variable processing with null variable
|
||||
*/
|
||||
it('should handle null variable safely', () => {
|
||||
const varInputs = [{ variable: null }]
|
||||
|
||||
expect(() => {
|
||||
mockGetDependentVars(varInputs)
|
||||
}).not.toThrow()
|
||||
|
||||
const result = mockGetDependentVars(varInputs)
|
||||
expect(result).toEqual([]) // null variables are filtered out
|
||||
})
|
||||
|
||||
/**
|
||||
* Test case 2: Variable processing with undefined variable
|
||||
*/
|
||||
it('should handle undefined variable safely', () => {
|
||||
const varInputs = [{ variable: undefined }]
|
||||
|
||||
expect(() => {
|
||||
mockGetDependentVars(varInputs)
|
||||
}).not.toThrow()
|
||||
|
||||
const result = mockGetDependentVars(varInputs)
|
||||
expect(result).toEqual([]) // undefined variables are filtered out
|
||||
})
|
||||
|
||||
/**
|
||||
* Test case 3: Variable processing with empty string
|
||||
*/
|
||||
it('should handle empty string variable', () => {
|
||||
const varInputs = [{ variable: '' }]
|
||||
|
||||
expect(() => {
|
||||
mockGetDependentVars(varInputs)
|
||||
}).not.toThrow()
|
||||
|
||||
const result = mockGetDependentVars(varInputs)
|
||||
expect(result).toEqual([]) // Empty string is filtered out, so result is empty array
|
||||
})
|
||||
|
||||
/**
|
||||
* Test case 4: Variable processing with valid variable format
|
||||
*/
|
||||
it('should work correctly with valid variable format', () => {
|
||||
const varInputs = [{ variable: '{{workflow.node.output}}' }]
|
||||
|
||||
expect(() => {
|
||||
mockGetDependentVars(varInputs)
|
||||
}).not.toThrow()
|
||||
|
||||
const result = mockGetDependentVars(varInputs)
|
||||
expect(result[0]).toEqual(['{workflow', 'node', 'output}'])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Integration test to simulate the complete workflow scenario
|
||||
*/
|
||||
describe('Plugin Tool Workflow Integration', () => {
|
||||
/**
|
||||
* Simulate the scenario where plugin metadata is incomplete or corrupted
|
||||
* This can happen when:
|
||||
* 1. Plugin is being loaded from marketplace but metadata request fails
|
||||
* 2. Plugin configuration is corrupted in database
|
||||
* 3. Network issues during plugin loading
|
||||
*/
|
||||
it('should reproduce the client-side exception scenario', () => {
|
||||
// Mock incomplete plugin data that could cause the error
|
||||
const incompletePluginData = {
|
||||
// Missing or null uniqueIdentifier
|
||||
uniqueIdentifier: null,
|
||||
meta: null,
|
||||
minimum_dify_version: undefined,
|
||||
}
|
||||
|
||||
// This simulates the error path that leads to the white screen
|
||||
expect(() => {
|
||||
// Simulate the code path in switch-plugin-version.tsx:29
|
||||
// The actual problematic code doesn't use optional chaining
|
||||
const _pluginId = (incompletePluginData.uniqueIdentifier as any).split(':')[0]
|
||||
}).toThrow('Cannot read properties of null (reading \'split\')')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test the scenario mentioned in the issue where plugin tools are loaded in workflow
|
||||
*/
|
||||
it('should simulate plugin tool loading in workflow context', () => {
|
||||
// Mock the workflow context where plugin tools are being loaded
|
||||
const workflowPluginTools = [
|
||||
{
|
||||
provider_name: 'test-plugin',
|
||||
uniqueIdentifier: null, // This is the problematic case
|
||||
tool_name: 'test-tool',
|
||||
},
|
||||
{
|
||||
provider_name: 'valid-plugin',
|
||||
uniqueIdentifier: 'valid-plugin:1.0.0',
|
||||
tool_name: 'valid-tool',
|
||||
},
|
||||
]
|
||||
|
||||
// Process each plugin tool
|
||||
workflowPluginTools.forEach((tool, _index) => {
|
||||
if (tool.uniqueIdentifier === null) {
|
||||
// This reproduces the exact error scenario
|
||||
expect(() => {
|
||||
const _pluginId = (tool.uniqueIdentifier as any).split(':')[0]
|
||||
}).toThrow()
|
||||
}
|
||||
else {
|
||||
// Valid tools should work fine
|
||||
expect(() => {
|
||||
const _pluginId = tool.uniqueIdentifier.split(':')[0]
|
||||
}).not.toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
528
dify/web/__tests__/real-browser-flicker.test.tsx
Normal file
528
dify/web/__tests__/real-browser-flicker.test.tsx
Normal file
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* Real Browser Environment Dark Mode Flicker Test
|
||||
*
|
||||
* This test attempts to simulate real browser refresh scenarios including:
|
||||
* 1. SSR HTML generation phase
|
||||
* 2. Client-side JavaScript loading
|
||||
* 3. Theme system initialization
|
||||
* 4. CSS styles application timing
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const DARK_MODE_MEDIA_QUERY = /prefers-color-scheme:\s*dark/i
|
||||
|
||||
// Setup browser environment for testing
|
||||
const setupMockEnvironment = (storedTheme: string | null, systemPrefersDark = false) => {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
|
||||
try {
|
||||
window.localStorage.clear()
|
||||
}
|
||||
catch {
|
||||
// ignore if localStorage has been replaced by a throwing stub
|
||||
}
|
||||
|
||||
if (storedTheme === null)
|
||||
window.localStorage.removeItem('theme')
|
||||
else
|
||||
window.localStorage.setItem('theme', storedTheme)
|
||||
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
|
||||
const mockMatchMedia: typeof window.matchMedia = (query: string) => {
|
||||
const listeners = new Set<(event: MediaQueryListEvent) => void>()
|
||||
const isDarkQuery = DARK_MODE_MEDIA_QUERY.test(query)
|
||||
const matches = isDarkQuery ? systemPrefersDark : false
|
||||
|
||||
const handleAddListener = (listener: (event: MediaQueryListEvent) => void) => {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
const handleRemoveListener = (listener: (event: MediaQueryListEvent) => void) => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
|
||||
const handleAddEventListener = (_event: string, listener: EventListener) => {
|
||||
if (typeof listener === 'function')
|
||||
listeners.add(listener as (event: MediaQueryListEvent) => void)
|
||||
}
|
||||
|
||||
const handleRemoveEventListener = (_event: string, listener: EventListener) => {
|
||||
if (typeof listener === 'function')
|
||||
listeners.delete(listener as (event: MediaQueryListEvent) => void)
|
||||
}
|
||||
|
||||
const handleDispatchEvent = (event: Event) => {
|
||||
listeners.forEach(listener => listener(event as MediaQueryListEvent))
|
||||
return true
|
||||
}
|
||||
|
||||
const mediaQueryList: MediaQueryList = {
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: handleAddListener,
|
||||
removeListener: handleRemoveListener,
|
||||
addEventListener: handleAddEventListener,
|
||||
removeEventListener: handleRemoveEventListener,
|
||||
dispatchEvent: handleDispatchEvent,
|
||||
}
|
||||
|
||||
return mediaQueryList
|
||||
}
|
||||
|
||||
jest.spyOn(window, 'matchMedia').mockImplementation(mockMatchMedia)
|
||||
}
|
||||
|
||||
// Helper function to create timing page component
|
||||
const createTimingPageComponent = (
|
||||
timingData: Array<{ phase: string; timestamp: number; styles: { backgroundColor: string; color: string } }>,
|
||||
) => {
|
||||
const recordTiming = (phase: string, styles: { backgroundColor: string; color: string }) => {
|
||||
timingData.push({
|
||||
phase,
|
||||
timestamp: performance.now(),
|
||||
styles,
|
||||
})
|
||||
}
|
||||
|
||||
const TimingPageComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
const isDark = mounted ? theme === 'dark' : false
|
||||
|
||||
const currentStyles = {
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
color: isDark ? '#ffffff' : '#000000',
|
||||
}
|
||||
|
||||
recordTiming(mounted ? 'CSR' : 'Initial', currentStyles)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="timing-page"
|
||||
style={currentStyles}
|
||||
>
|
||||
<div data-testid="timing-status">
|
||||
Phase: {mounted ? 'CSR' : 'Initial'} | Theme: {theme} | Visual: {isDark ? 'dark' : 'light'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return TimingPageComponent
|
||||
}
|
||||
|
||||
// Helper function to create CSS test component
|
||||
const createCSSTestComponent = (
|
||||
cssStates: Array<{ className: string; timestamp: number }>,
|
||||
) => {
|
||||
const recordCSSState = (className: string) => {
|
||||
cssStates.push({
|
||||
className,
|
||||
timestamp: performance.now(),
|
||||
})
|
||||
}
|
||||
|
||||
const CSSTestComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
const isDark = mounted ? theme === 'dark' : false
|
||||
|
||||
const className = `min-h-screen ${isDark ? 'bg-gray-900 text-white' : 'bg-white text-black'}`
|
||||
|
||||
recordCSSState(className)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="css-component"
|
||||
className={className}
|
||||
>
|
||||
<div data-testid="css-classes">Classes: {className}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return CSSTestComponent
|
||||
}
|
||||
|
||||
// Helper function to create performance test component
|
||||
const createPerformanceTestComponent = (
|
||||
performanceMarks: Array<{ event: string; timestamp: number }>,
|
||||
) => {
|
||||
const recordPerformanceMark = (event: string) => {
|
||||
performanceMarks.push({ event, timestamp: performance.now() })
|
||||
}
|
||||
|
||||
const PerformanceTestComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
|
||||
recordPerformanceMark('component-render')
|
||||
|
||||
useEffect(() => {
|
||||
recordPerformanceMark('mount-start')
|
||||
setMounted(true)
|
||||
recordPerformanceMark('mount-complete')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (theme)
|
||||
recordPerformanceMark('theme-available')
|
||||
}, [theme])
|
||||
|
||||
return (
|
||||
<div data-testid="performance-test">
|
||||
Mounted: {mounted.toString()} | Theme: {theme || 'loading'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return PerformanceTestComponent
|
||||
}
|
||||
|
||||
// Simulate real page component based on Dify's actual theme usage
|
||||
const PageComponent = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Simulate common theme usage pattern in Dify
|
||||
const isDark = mounted ? theme === 'dark' : false
|
||||
|
||||
return (
|
||||
<div data-theme={isDark ? 'dark' : 'light'}>
|
||||
<div
|
||||
data-testid="page-content"
|
||||
style={{ backgroundColor: isDark ? '#1f2937' : '#ffffff' }}
|
||||
>
|
||||
<h1 style={{ color: isDark ? '#ffffff' : '#000000' }}>
|
||||
Dify Application
|
||||
</h1>
|
||||
<div data-testid="theme-indicator">
|
||||
Current Theme: {mounted ? theme : 'unknown'}
|
||||
</div>
|
||||
<div data-testid="visual-appearance">
|
||||
Appearance: {isDark ? 'dark' : 'light'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TestThemeProvider = ({ children }: { children: React.ReactNode }) => (
|
||||
<ThemeProvider
|
||||
attribute="data-theme"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
enableColorScheme={false}
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
describe('Real Browser Environment Dark Mode Flicker Test', () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
jest.clearAllMocks()
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.clear()
|
||||
}
|
||||
catch {
|
||||
// ignore when localStorage is replaced with an error-throwing stub
|
||||
}
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
}
|
||||
})
|
||||
|
||||
describe('Page Refresh Scenario Simulation', () => {
|
||||
test('simulates complete page loading process with dark theme', async () => {
|
||||
// Setup: User previously selected dark mode
|
||||
setupMockEnvironment('dark')
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
// Check initial client-side rendering state
|
||||
const initialState = {
|
||||
theme: screen.getByTestId('theme-indicator').textContent,
|
||||
appearance: screen.getByTestId('visual-appearance').textContent,
|
||||
}
|
||||
console.log('Initial client state:', initialState)
|
||||
|
||||
// Wait for theme system to fully initialize
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('theme-indicator')).toHaveTextContent('Current Theme: dark')
|
||||
})
|
||||
|
||||
const finalState = {
|
||||
theme: screen.getByTestId('theme-indicator').textContent,
|
||||
appearance: screen.getByTestId('visual-appearance').textContent,
|
||||
}
|
||||
console.log('Final state:', finalState)
|
||||
|
||||
// Document the state change - this is the source of flicker
|
||||
console.log('State change detection: Initial -> Final')
|
||||
})
|
||||
|
||||
test('handles light theme correctly', async () => {
|
||||
setupMockEnvironment('light')
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('theme-indicator')).toHaveTextContent('Current Theme: light')
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('visual-appearance')).toHaveTextContent('Appearance: light')
|
||||
})
|
||||
|
||||
test('handles system theme with dark preference', async () => {
|
||||
setupMockEnvironment('system', true) // system theme, dark preference
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('theme-indicator')).toHaveTextContent('Current Theme: dark')
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('visual-appearance')).toHaveTextContent('Appearance: dark')
|
||||
})
|
||||
|
||||
test('handles system theme with light preference', async () => {
|
||||
setupMockEnvironment('system', false) // system theme, light preference
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('theme-indicator')).toHaveTextContent('Current Theme: light')
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('visual-appearance')).toHaveTextContent('Appearance: light')
|
||||
})
|
||||
|
||||
test('handles no stored theme (defaults to system)', async () => {
|
||||
setupMockEnvironment(null, false) // no stored theme, system prefers light
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('theme-indicator')).toHaveTextContent('Current Theme: light')
|
||||
})
|
||||
})
|
||||
|
||||
test('measures timing window of style changes', async () => {
|
||||
setupMockEnvironment('dark')
|
||||
|
||||
const timingData: Array<{ phase: string; timestamp: number; styles: any }> = []
|
||||
const TimingPageComponent = createTimingPageComponent(timingData)
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<TimingPageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('timing-status')).toHaveTextContent('Phase: CSR')
|
||||
})
|
||||
|
||||
// Analyze timing and style changes
|
||||
console.log('\n=== Style Change Timeline ===')
|
||||
timingData.forEach((data, index) => {
|
||||
console.log(`${index + 1}. ${data.phase}: bg=${data.styles.backgroundColor}, color=${data.styles.color}`)
|
||||
})
|
||||
|
||||
// Check if there are style changes (this is visible flicker)
|
||||
const hasStyleChange = timingData.length > 1
|
||||
&& timingData[0].styles.backgroundColor !== timingData[timingData.length - 1].styles.backgroundColor
|
||||
|
||||
if (hasStyleChange)
|
||||
console.log('⚠️ Style changes detected - this causes visible flicker')
|
||||
else
|
||||
console.log('✅ No style changes detected')
|
||||
|
||||
expect(timingData.length).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS Application Timing Tests', () => {
|
||||
test('checks CSS class changes causing flicker', async () => {
|
||||
setupMockEnvironment('dark')
|
||||
|
||||
const cssStates: Array<{ className: string; timestamp: number }> = []
|
||||
const CSSTestComponent = createCSSTestComponent(cssStates)
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<CSSTestComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('css-classes')).toHaveTextContent('bg-gray-900 text-white')
|
||||
})
|
||||
|
||||
console.log('\n=== CSS Class Change Detection ===')
|
||||
cssStates.forEach((state, index) => {
|
||||
console.log(`${index + 1}. ${state.className}`)
|
||||
})
|
||||
|
||||
// Check if CSS classes have changed
|
||||
const hasCSSChange = cssStates.length > 1
|
||||
&& cssStates[0].className !== cssStates[cssStates.length - 1].className
|
||||
|
||||
if (hasCSSChange) {
|
||||
console.log('⚠️ CSS class changes detected - may cause style flicker')
|
||||
console.log(`From: "${cssStates[0].className}"`)
|
||||
console.log(`To: "${cssStates[cssStates.length - 1].className}"`)
|
||||
}
|
||||
|
||||
expect(hasCSSChange).toBe(true) // We expect to see this change
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases and Error Handling', () => {
|
||||
test('handles localStorage access errors gracefully', async () => {
|
||||
setupMockEnvironment(null)
|
||||
|
||||
const mockStorage = {
|
||||
getItem: jest.fn(() => {
|
||||
throw new Error('LocalStorage access denied')
|
||||
}),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: mockStorage,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
try {
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
// Should fallback gracefully without crashing
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('theme-indicator')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should default to light theme when localStorage fails
|
||||
expect(screen.getByTestId('visual-appearance')).toHaveTextContent('Appearance: light')
|
||||
}
|
||||
finally {
|
||||
Reflect.deleteProperty(window, 'localStorage')
|
||||
}
|
||||
})
|
||||
|
||||
test('handles invalid theme values in localStorage', async () => {
|
||||
setupMockEnvironment('invalid-theme-value')
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<PageComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('theme-indicator')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should handle invalid values gracefully
|
||||
const themeIndicator = screen.getByTestId('theme-indicator')
|
||||
expect(themeIndicator).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance and Regression Tests', () => {
|
||||
test('verifies ThemeProvider position fix reduces initialization delay', async () => {
|
||||
const performanceMarks: Array<{ event: string; timestamp: number }> = []
|
||||
|
||||
setupMockEnvironment('dark')
|
||||
|
||||
expect(window.localStorage.getItem('theme')).toBe('dark')
|
||||
|
||||
const PerformanceTestComponent = createPerformanceTestComponent(performanceMarks)
|
||||
|
||||
render(
|
||||
<TestThemeProvider>
|
||||
<PerformanceTestComponent />
|
||||
</TestThemeProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('performance-test')).toHaveTextContent('Theme: dark')
|
||||
})
|
||||
|
||||
// Analyze performance timeline
|
||||
console.log('\n=== Performance Timeline ===')
|
||||
performanceMarks.forEach((mark) => {
|
||||
console.log(`${mark.event}: ${mark.timestamp.toFixed(2)}ms`)
|
||||
})
|
||||
|
||||
expect(performanceMarks.length).toBeGreaterThan(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Solution Requirements Definition', () => {
|
||||
test('defines technical requirements to eliminate flicker', () => {
|
||||
const technicalRequirements = {
|
||||
ssrConsistency: 'SSR and CSR must render identical initial styles',
|
||||
synchronousDetection: 'Theme detection must complete synchronously before first render',
|
||||
noStyleChanges: 'No visible style changes should occur after hydration',
|
||||
performanceImpact: 'Solution should not significantly impact page load performance',
|
||||
browserCompatibility: 'Must work consistently across all major browsers',
|
||||
}
|
||||
|
||||
console.log('\n=== Technical Requirements ===')
|
||||
Object.entries(technicalRequirements).forEach(([key, requirement]) => {
|
||||
console.log(`${key}: ${requirement}`)
|
||||
expect(requirement).toBeDefined()
|
||||
})
|
||||
|
||||
// A successful solution should pass all these requirements
|
||||
})
|
||||
})
|
||||
})
|
||||
402
dify/web/__tests__/unified-tags-logic.test.ts
Normal file
402
dify/web/__tests__/unified-tags-logic.test.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Unified Tags Editing - Pure Logic Tests
|
||||
*
|
||||
* This test file validates the core business logic and state management
|
||||
* behaviors introduced in the recent 7 commits without requiring complex mocks.
|
||||
*/
|
||||
|
||||
describe('Unified Tags Editing - Pure Logic Tests', () => {
|
||||
describe('Tag State Management Logic', () => {
|
||||
it('should detect when tag values have changed', () => {
|
||||
const currentValue = ['tag1', 'tag2']
|
||||
const newSelectedTagIDs = ['tag1', 'tag3']
|
||||
|
||||
// This is the valueNotChanged logic from TagSelector component
|
||||
const valueNotChanged
|
||||
= currentValue.length === newSelectedTagIDs.length
|
||||
&& currentValue.every(v => newSelectedTagIDs.includes(v))
|
||||
&& newSelectedTagIDs.every(v => currentValue.includes(v))
|
||||
|
||||
expect(valueNotChanged).toBe(false)
|
||||
})
|
||||
|
||||
it('should correctly identify unchanged tag values', () => {
|
||||
const currentValue = ['tag1', 'tag2']
|
||||
const newSelectedTagIDs = ['tag2', 'tag1'] // Same tags, different order
|
||||
|
||||
const valueNotChanged
|
||||
= currentValue.length === newSelectedTagIDs.length
|
||||
&& currentValue.every(v => newSelectedTagIDs.includes(v))
|
||||
&& newSelectedTagIDs.every(v => currentValue.includes(v))
|
||||
|
||||
expect(valueNotChanged).toBe(true)
|
||||
})
|
||||
|
||||
it('should calculate correct tag operations for binding/unbinding', () => {
|
||||
const currentValue = ['tag1', 'tag2']
|
||||
const selectedTagIDs = ['tag2', 'tag3']
|
||||
|
||||
// This is the handleValueChange logic from TagSelector
|
||||
const addTagIDs = selectedTagIDs.filter(v => !currentValue.includes(v))
|
||||
const removeTagIDs = currentValue.filter(v => !selectedTagIDs.includes(v))
|
||||
|
||||
expect(addTagIDs).toEqual(['tag3'])
|
||||
expect(removeTagIDs).toEqual(['tag1'])
|
||||
})
|
||||
|
||||
it('should handle empty tag arrays correctly', () => {
|
||||
const currentValue: string[] = []
|
||||
const selectedTagIDs = ['tag1']
|
||||
|
||||
const addTagIDs = selectedTagIDs.filter(v => !currentValue.includes(v))
|
||||
const removeTagIDs = currentValue.filter(v => !selectedTagIDs.includes(v))
|
||||
|
||||
expect(addTagIDs).toEqual(['tag1'])
|
||||
expect(removeTagIDs).toEqual([])
|
||||
expect(currentValue.length).toBe(0) // Verify empty array usage
|
||||
})
|
||||
|
||||
it('should handle removing all tags', () => {
|
||||
const currentValue = ['tag1', 'tag2']
|
||||
const selectedTagIDs: string[] = []
|
||||
|
||||
const addTagIDs = selectedTagIDs.filter(v => !currentValue.includes(v))
|
||||
const removeTagIDs = currentValue.filter(v => !selectedTagIDs.includes(v))
|
||||
|
||||
expect(addTagIDs).toEqual([])
|
||||
expect(removeTagIDs).toEqual(['tag1', 'tag2'])
|
||||
expect(selectedTagIDs.length).toBe(0) // Verify empty array usage
|
||||
})
|
||||
})
|
||||
|
||||
describe('Fallback Logic (from layout-main.tsx)', () => {
|
||||
type Tag = { id: string; name: string }
|
||||
type AppDetail = { tags: Tag[] }
|
||||
type FallbackResult = { tags?: Tag[] } | null
|
||||
// no-op
|
||||
it('should trigger fallback when tags are missing or empty', () => {
|
||||
const appDetailWithoutTags: AppDetail = { tags: [] }
|
||||
const appDetailWithTags: AppDetail = { tags: [{ id: 'tag1', name: 't' }] }
|
||||
const appDetailWithUndefinedTags: { tags: Tag[] | undefined } = { tags: undefined }
|
||||
|
||||
// This simulates the condition in layout-main.tsx
|
||||
const shouldFallback1 = appDetailWithoutTags.tags.length === 0
|
||||
const shouldFallback2 = appDetailWithTags.tags.length === 0
|
||||
const shouldFallback3 = !appDetailWithUndefinedTags.tags || appDetailWithUndefinedTags.tags.length === 0
|
||||
|
||||
expect(shouldFallback1).toBe(true) // Empty array should trigger fallback
|
||||
expect(shouldFallback2).toBe(false) // Has tags, no fallback needed
|
||||
expect(shouldFallback3).toBe(true) // Undefined tags should trigger fallback
|
||||
})
|
||||
|
||||
it('should preserve tags when fallback succeeds', () => {
|
||||
const originalAppDetail: AppDetail = { tags: [] }
|
||||
const fallbackResult: { tags?: Tag[] } = { tags: [{ id: 'tag1', name: 'fallback-tag' }] }
|
||||
|
||||
// This simulates the successful fallback in layout-main.tsx
|
||||
const tags = fallbackResult.tags
|
||||
if (tags)
|
||||
originalAppDetail.tags = tags
|
||||
|
||||
expect(originalAppDetail.tags).toEqual(fallbackResult.tags)
|
||||
expect(originalAppDetail.tags.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should continue with empty tags when fallback fails', () => {
|
||||
const originalAppDetail: AppDetail = { tags: [] }
|
||||
const fallbackResult = null as FallbackResult
|
||||
|
||||
// This simulates fallback failure in layout-main.tsx
|
||||
const tags: Tag[] | undefined = fallbackResult && 'tags' in fallbackResult ? fallbackResult.tags : undefined
|
||||
if (tags)
|
||||
originalAppDetail.tags = tags
|
||||
|
||||
expect(originalAppDetail.tags).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('TagSelector Auto-initialization Logic', () => {
|
||||
it('should trigger getTagList when tagList is empty', () => {
|
||||
const tagList: any[] = []
|
||||
let getTagListCalled = false
|
||||
const getTagList = () => {
|
||||
getTagListCalled = true
|
||||
}
|
||||
|
||||
// This simulates the useEffect in TagSelector
|
||||
if (tagList.length === 0)
|
||||
getTagList()
|
||||
|
||||
expect(getTagListCalled).toBe(true)
|
||||
})
|
||||
|
||||
it('should not trigger getTagList when tagList has items', () => {
|
||||
const tagList = [{ id: 'tag1', name: 'existing-tag' }]
|
||||
let getTagListCalled = false
|
||||
const getTagList = () => {
|
||||
getTagListCalled = true
|
||||
}
|
||||
|
||||
// This simulates the useEffect in TagSelector
|
||||
if (tagList.length === 0)
|
||||
getTagList()
|
||||
|
||||
expect(getTagListCalled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('State Initialization Patterns', () => {
|
||||
it('should maintain AppCard tag state pattern', () => {
|
||||
const app = { tags: [{ id: 'tag1', name: 'test' }] }
|
||||
|
||||
// Original AppCard pattern: useState(app.tags)
|
||||
const initialTags = app.tags
|
||||
expect(Array.isArray(initialTags)).toBe(true)
|
||||
expect(initialTags.length).toBe(1)
|
||||
expect(initialTags).toBe(app.tags) // Reference equality for AppCard
|
||||
})
|
||||
|
||||
it('should maintain AppInfo tag state pattern', () => {
|
||||
const appDetail = { tags: [{ id: 'tag1', name: 'test' }] }
|
||||
|
||||
// New AppInfo pattern: useState(appDetail?.tags || [])
|
||||
const initialTags = appDetail?.tags || []
|
||||
expect(Array.isArray(initialTags)).toBe(true)
|
||||
expect(initialTags.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle undefined appDetail gracefully in AppInfo', () => {
|
||||
const appDetail = undefined
|
||||
|
||||
// AppInfo pattern with undefined appDetail
|
||||
const initialTags = (appDetail as any)?.tags || []
|
||||
expect(Array.isArray(initialTags)).toBe(true)
|
||||
expect(initialTags.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS Class and Layout Logic', () => {
|
||||
it('should apply correct minimum width condition', () => {
|
||||
const minWidth = 'true'
|
||||
|
||||
// This tests the minWidth logic in TagSelector
|
||||
const shouldApplyMinWidth = minWidth && '!min-w-80'
|
||||
expect(shouldApplyMinWidth).toBe('!min-w-80')
|
||||
})
|
||||
|
||||
it('should not apply minimum width when not specified', () => {
|
||||
const minWidth = undefined
|
||||
|
||||
const shouldApplyMinWidth = minWidth && '!min-w-80'
|
||||
expect(shouldApplyMinWidth).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should handle overflow layout classes correctly', () => {
|
||||
// This tests the layout pattern from AppCard and new AppInfo
|
||||
const overflowLayoutClasses = {
|
||||
container: 'flex w-0 grow items-center',
|
||||
inner: 'w-full',
|
||||
truncate: 'truncate',
|
||||
}
|
||||
|
||||
expect(overflowLayoutClasses.container).toContain('w-0 grow')
|
||||
expect(overflowLayoutClasses.inner).toContain('w-full')
|
||||
expect(overflowLayoutClasses.truncate).toBe('truncate')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchAppWithTags Service Logic', () => {
|
||||
it('should correctly find app by ID from app list', () => {
|
||||
const appList = [
|
||||
{ id: 'app1', name: 'App 1', tags: [] },
|
||||
{ id: 'test-app-id', name: 'Test App', tags: [{ id: 'tag1', name: 'test' }] },
|
||||
{ id: 'app3', name: 'App 3', tags: [] },
|
||||
]
|
||||
const targetAppId = 'test-app-id'
|
||||
|
||||
// This simulates the logic in fetchAppWithTags
|
||||
const foundApp = appList.find(app => app.id === targetAppId)
|
||||
|
||||
expect(foundApp).toBeDefined()
|
||||
expect(foundApp?.id).toBe('test-app-id')
|
||||
expect(foundApp?.tags.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should return null when app not found', () => {
|
||||
const appList = [
|
||||
{ id: 'app1', name: 'App 1' },
|
||||
{ id: 'app2', name: 'App 2' },
|
||||
]
|
||||
const targetAppId = 'nonexistent-app'
|
||||
|
||||
const foundApp = appList.find(app => app.id === targetAppId) || null
|
||||
|
||||
expect(foundApp).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle empty app list', () => {
|
||||
const appList: any[] = []
|
||||
const targetAppId = 'any-app'
|
||||
|
||||
const foundApp = appList.find(app => app.id === targetAppId) || null
|
||||
|
||||
expect(foundApp).toBeNull()
|
||||
expect(appList.length).toBe(0) // Verify empty array usage
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Structure Validation', () => {
|
||||
it('should maintain consistent tag data structure', () => {
|
||||
const tag = {
|
||||
id: 'tag1',
|
||||
name: 'test-tag',
|
||||
type: 'app',
|
||||
binding_count: 1,
|
||||
}
|
||||
|
||||
expect(tag).toHaveProperty('id')
|
||||
expect(tag).toHaveProperty('name')
|
||||
expect(tag).toHaveProperty('type')
|
||||
expect(tag).toHaveProperty('binding_count')
|
||||
expect(tag.type).toBe('app')
|
||||
expect(typeof tag.binding_count).toBe('number')
|
||||
})
|
||||
|
||||
it('should handle tag arrays correctly', () => {
|
||||
const tags = [
|
||||
{ id: 'tag1', name: 'Tag 1', type: 'app', binding_count: 1 },
|
||||
{ id: 'tag2', name: 'Tag 2', type: 'app', binding_count: 0 },
|
||||
]
|
||||
|
||||
expect(Array.isArray(tags)).toBe(true)
|
||||
expect(tags.length).toBe(2)
|
||||
expect(tags.every(tag => tag.type === 'app')).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate app data structure with tags', () => {
|
||||
const app = {
|
||||
id: 'test-app',
|
||||
name: 'Test App',
|
||||
tags: [
|
||||
{ id: 'tag1', name: 'Tag 1', type: 'app', binding_count: 1 },
|
||||
],
|
||||
}
|
||||
|
||||
expect(app).toHaveProperty('id')
|
||||
expect(app).toHaveProperty('name')
|
||||
expect(app).toHaveProperty('tags')
|
||||
expect(Array.isArray(app.tags)).toBe(true)
|
||||
expect(app.tags.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance and Edge Cases', () => {
|
||||
it('should handle large tag arrays efficiently', () => {
|
||||
const largeTags = Array.from({ length: 100 }, (_, i) => `tag${i}`)
|
||||
const selectedTags = ['tag1', 'tag50', 'tag99']
|
||||
|
||||
// Performance test: filtering should be efficient
|
||||
const startTime = Date.now()
|
||||
const addTags = selectedTags.filter(tag => !largeTags.includes(tag))
|
||||
const removeTags = largeTags.filter(tag => !selectedTags.includes(tag))
|
||||
const endTime = Date.now()
|
||||
|
||||
expect(endTime - startTime).toBeLessThan(10) // Should be very fast
|
||||
expect(addTags.length).toBe(0) // All selected tags exist
|
||||
expect(removeTags.length).toBe(97) // 100 - 3 = 97 tags to remove
|
||||
})
|
||||
|
||||
it('should handle malformed tag data gracefully', () => {
|
||||
const mixedData = [
|
||||
{ id: 'valid1', name: 'Valid Tag', type: 'app', binding_count: 1 },
|
||||
{ id: 'invalid1' }, // Missing required properties
|
||||
null,
|
||||
undefined,
|
||||
{ id: 'valid2', name: 'Another Valid', type: 'app', binding_count: 0 },
|
||||
]
|
||||
|
||||
// Filter out invalid entries
|
||||
const validTags = mixedData.filter((tag): tag is { id: string; name: string; type: string; binding_count: number } =>
|
||||
tag != null
|
||||
&& typeof tag === 'object'
|
||||
&& 'id' in tag
|
||||
&& 'name' in tag
|
||||
&& 'type' in tag
|
||||
&& 'binding_count' in tag
|
||||
&& typeof tag.binding_count === 'number',
|
||||
)
|
||||
|
||||
expect(validTags.length).toBe(2)
|
||||
expect(validTags.every(tag => tag.id && tag.name)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle concurrent tag operations correctly', () => {
|
||||
const operations = [
|
||||
{ type: 'add', tagIds: ['tag1', 'tag2'] },
|
||||
{ type: 'remove', tagIds: ['tag3'] },
|
||||
{ type: 'add', tagIds: ['tag4'] },
|
||||
]
|
||||
|
||||
// Simulate processing operations
|
||||
const results = operations.map(op => ({
|
||||
...op,
|
||||
processed: true,
|
||||
timestamp: Date.now(),
|
||||
}))
|
||||
|
||||
expect(results.length).toBe(3)
|
||||
expect(results.every(result => result.processed)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Backward Compatibility Verification', () => {
|
||||
it('should not break existing AppCard behavior', () => {
|
||||
// Verify AppCard continues to work with original patterns
|
||||
const originalAppCardLogic = {
|
||||
initializeTags: (app: any) => app.tags,
|
||||
updateTags: (_currentTags: any[], newTags: any[]) => newTags,
|
||||
shouldRefresh: true,
|
||||
}
|
||||
|
||||
const app = { tags: [{ id: 'tag1', name: 'original' }] }
|
||||
const initializedTags = originalAppCardLogic.initializeTags(app)
|
||||
|
||||
expect(initializedTags).toBe(app.tags)
|
||||
expect(originalAppCardLogic.shouldRefresh).toBe(true)
|
||||
})
|
||||
|
||||
it('should ensure AppInfo follows AppCard patterns', () => {
|
||||
// Verify AppInfo uses compatible state management
|
||||
const appCardPattern = (app: any) => app.tags
|
||||
const appInfoPattern = (appDetail: any) => appDetail?.tags || []
|
||||
|
||||
const appWithTags = { tags: [{ id: 'tag1' }] }
|
||||
const appWithoutTags = { tags: [] }
|
||||
const undefinedApp = undefined
|
||||
|
||||
expect(appCardPattern(appWithTags)).toEqual(appInfoPattern(appWithTags))
|
||||
expect(appInfoPattern(appWithoutTags)).toEqual([])
|
||||
expect(appInfoPattern(undefinedApp)).toEqual([])
|
||||
})
|
||||
|
||||
it('should maintain consistent API parameters', () => {
|
||||
// Verify service layer maintains expected parameters
|
||||
const fetchAppListParams = {
|
||||
url: '/apps',
|
||||
params: { page: 1, limit: 100 },
|
||||
}
|
||||
|
||||
const tagApiParams = {
|
||||
bindTag: (tagIDs: string[], targetID: string, type: string) => ({ tagIDs, targetID, type }),
|
||||
unBindTag: (tagID: string, targetID: string, type: string) => ({ tagID, targetID, type }),
|
||||
}
|
||||
|
||||
expect(fetchAppListParams.url).toBe('/apps')
|
||||
expect(fetchAppListParams.params.limit).toBe(100)
|
||||
|
||||
const bindResult = tagApiParams.bindTag(['tag1'], 'app1', 'app')
|
||||
expect(bindResult.tagIDs).toEqual(['tag1'])
|
||||
expect(bindResult.type).toBe('app')
|
||||
})
|
||||
})
|
||||
})
|
||||
616
dify/web/__tests__/workflow-onboarding-integration.test.tsx
Normal file
616
dify/web/__tests__/workflow-onboarding-integration.test.tsx
Normal file
@@ -0,0 +1,616 @@
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
|
||||
// Type for mocked store
|
||||
type MockWorkflowStore = {
|
||||
showOnboarding: boolean
|
||||
setShowOnboarding: jest.Mock
|
||||
hasShownOnboarding: boolean
|
||||
setHasShownOnboarding: jest.Mock
|
||||
hasSelectedStartNode: boolean
|
||||
setHasSelectedStartNode: jest.Mock
|
||||
setShouldAutoOpenStartNodeSelector: jest.Mock
|
||||
notInitialWorkflow: boolean
|
||||
}
|
||||
|
||||
// Type for mocked node
|
||||
type MockNode = {
|
||||
id: string
|
||||
data: { type?: BlockEnum }
|
||||
}
|
||||
|
||||
// Mock zustand store
|
||||
jest.mock('@/app/components/workflow/store')
|
||||
|
||||
// Mock ReactFlow store
|
||||
const mockGetNodes = jest.fn()
|
||||
jest.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({
|
||||
getState: () => ({
|
||||
getNodes: mockGetNodes,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Workflow Onboarding Integration Logic', () => {
|
||||
const mockSetShowOnboarding = jest.fn()
|
||||
const mockSetHasSelectedStartNode = jest.fn()
|
||||
const mockSetHasShownOnboarding = jest.fn()
|
||||
const mockSetShouldAutoOpenStartNodeSelector = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
// Mock store implementation
|
||||
;(useWorkflowStore as jest.Mock).mockReturnValue({
|
||||
showOnboarding: false,
|
||||
setShowOnboarding: mockSetShowOnboarding,
|
||||
hasSelectedStartNode: false,
|
||||
setHasSelectedStartNode: mockSetHasSelectedStartNode,
|
||||
hasShownOnboarding: false,
|
||||
setHasShownOnboarding: mockSetHasShownOnboarding,
|
||||
notInitialWorkflow: false,
|
||||
shouldAutoOpenStartNodeSelector: false,
|
||||
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Onboarding State Management', () => {
|
||||
it('should initialize onboarding state correctly', () => {
|
||||
const store = useWorkflowStore() as unknown as MockWorkflowStore
|
||||
|
||||
expect(store.showOnboarding).toBe(false)
|
||||
expect(store.hasSelectedStartNode).toBe(false)
|
||||
expect(store.hasShownOnboarding).toBe(false)
|
||||
})
|
||||
|
||||
it('should update onboarding visibility', () => {
|
||||
const store = useWorkflowStore() as unknown as MockWorkflowStore
|
||||
|
||||
store.setShowOnboarding(true)
|
||||
expect(mockSetShowOnboarding).toHaveBeenCalledWith(true)
|
||||
|
||||
store.setShowOnboarding(false)
|
||||
expect(mockSetShowOnboarding).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should track node selection state', () => {
|
||||
const store = useWorkflowStore() as unknown as MockWorkflowStore
|
||||
|
||||
store.setHasSelectedStartNode(true)
|
||||
expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should track onboarding show state', () => {
|
||||
const store = useWorkflowStore() as unknown as MockWorkflowStore
|
||||
|
||||
store.setHasShownOnboarding(true)
|
||||
expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Validation Logic', () => {
|
||||
/**
|
||||
* Test the critical fix in use-nodes-sync-draft.ts
|
||||
* This ensures trigger nodes are recognized as valid start nodes
|
||||
*/
|
||||
it('should validate Start node as valid start node', () => {
|
||||
const mockNode = {
|
||||
data: { type: BlockEnum.Start },
|
||||
id: 'start-1',
|
||||
}
|
||||
|
||||
// Simulate the validation logic from use-nodes-sync-draft.ts
|
||||
const isValidStartNode = mockNode.data.type === BlockEnum.Start
|
||||
|| mockNode.data.type === BlockEnum.TriggerSchedule
|
||||
|| mockNode.data.type === BlockEnum.TriggerWebhook
|
||||
|| mockNode.data.type === BlockEnum.TriggerPlugin
|
||||
|
||||
expect(isValidStartNode).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate TriggerSchedule as valid start node', () => {
|
||||
const mockNode = {
|
||||
data: { type: BlockEnum.TriggerSchedule },
|
||||
id: 'trigger-schedule-1',
|
||||
}
|
||||
|
||||
const isValidStartNode = mockNode.data.type === BlockEnum.Start
|
||||
|| mockNode.data.type === BlockEnum.TriggerSchedule
|
||||
|| mockNode.data.type === BlockEnum.TriggerWebhook
|
||||
|| mockNode.data.type === BlockEnum.TriggerPlugin
|
||||
|
||||
expect(isValidStartNode).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate TriggerWebhook as valid start node', () => {
|
||||
const mockNode = {
|
||||
data: { type: BlockEnum.TriggerWebhook },
|
||||
id: 'trigger-webhook-1',
|
||||
}
|
||||
|
||||
const isValidStartNode = mockNode.data.type === BlockEnum.Start
|
||||
|| mockNode.data.type === BlockEnum.TriggerSchedule
|
||||
|| mockNode.data.type === BlockEnum.TriggerWebhook
|
||||
|| mockNode.data.type === BlockEnum.TriggerPlugin
|
||||
|
||||
expect(isValidStartNode).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate TriggerPlugin as valid start node', () => {
|
||||
const mockNode = {
|
||||
data: { type: BlockEnum.TriggerPlugin },
|
||||
id: 'trigger-plugin-1',
|
||||
}
|
||||
|
||||
const isValidStartNode = mockNode.data.type === BlockEnum.Start
|
||||
|| mockNode.data.type === BlockEnum.TriggerSchedule
|
||||
|| mockNode.data.type === BlockEnum.TriggerWebhook
|
||||
|| mockNode.data.type === BlockEnum.TriggerPlugin
|
||||
|
||||
expect(isValidStartNode).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject non-trigger nodes as invalid start nodes', () => {
|
||||
const mockNode = {
|
||||
data: { type: BlockEnum.LLM },
|
||||
id: 'llm-1',
|
||||
}
|
||||
|
||||
const isValidStartNode = mockNode.data.type === BlockEnum.Start
|
||||
|| mockNode.data.type === BlockEnum.TriggerSchedule
|
||||
|| mockNode.data.type === BlockEnum.TriggerWebhook
|
||||
|| mockNode.data.type === BlockEnum.TriggerPlugin
|
||||
|
||||
expect(isValidStartNode).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle array of nodes with mixed types', () => {
|
||||
const mockNodes = [
|
||||
{ data: { type: BlockEnum.LLM }, id: 'llm-1' },
|
||||
{ data: { type: BlockEnum.TriggerWebhook }, id: 'webhook-1' },
|
||||
{ data: { type: BlockEnum.Answer }, id: 'answer-1' },
|
||||
]
|
||||
|
||||
// Simulate hasStartNode logic from use-nodes-sync-draft.ts
|
||||
const hasStartNode = mockNodes.find(node =>
|
||||
node.data.type === BlockEnum.Start
|
||||
|| node.data.type === BlockEnum.TriggerSchedule
|
||||
|| node.data.type === BlockEnum.TriggerWebhook
|
||||
|| node.data.type === BlockEnum.TriggerPlugin,
|
||||
)
|
||||
|
||||
expect(hasStartNode).toBeTruthy()
|
||||
expect(hasStartNode?.id).toBe('webhook-1')
|
||||
})
|
||||
|
||||
it('should return undefined when no valid start nodes exist', () => {
|
||||
const mockNodes = [
|
||||
{ data: { type: BlockEnum.LLM }, id: 'llm-1' },
|
||||
{ data: { type: BlockEnum.Answer }, id: 'answer-1' },
|
||||
]
|
||||
|
||||
const hasStartNode = mockNodes.find(node =>
|
||||
node.data.type === BlockEnum.Start
|
||||
|| node.data.type === BlockEnum.TriggerSchedule
|
||||
|| node.data.type === BlockEnum.TriggerWebhook
|
||||
|| node.data.type === BlockEnum.TriggerPlugin,
|
||||
)
|
||||
|
||||
expect(hasStartNode).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auto-open Logic for Node Handles', () => {
|
||||
/**
|
||||
* Test the auto-open logic from node-handle.tsx
|
||||
* This ensures all trigger types auto-open the block selector when flagged
|
||||
*/
|
||||
it('should auto-expand for Start node in new workflow', () => {
|
||||
const shouldAutoOpenStartNodeSelector = true
|
||||
const nodeType = BlockEnum.Start
|
||||
const isChatMode = false
|
||||
|
||||
const shouldAutoExpand = shouldAutoOpenStartNodeSelector && (
|
||||
nodeType === BlockEnum.Start
|
||||
|| nodeType === BlockEnum.TriggerSchedule
|
||||
|| nodeType === BlockEnum.TriggerWebhook
|
||||
|| nodeType === BlockEnum.TriggerPlugin
|
||||
) && !isChatMode
|
||||
|
||||
expect(shouldAutoExpand).toBe(true)
|
||||
})
|
||||
|
||||
it('should auto-expand for TriggerSchedule in new workflow', () => {
|
||||
const shouldAutoOpenStartNodeSelector = true
|
||||
const nodeType: BlockEnum = BlockEnum.TriggerSchedule
|
||||
const isChatMode = false
|
||||
const validStartTypes = [BlockEnum.Start, BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin]
|
||||
|
||||
const shouldAutoExpand = shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode
|
||||
|
||||
expect(shouldAutoExpand).toBe(true)
|
||||
})
|
||||
|
||||
it('should auto-expand for TriggerWebhook in new workflow', () => {
|
||||
const shouldAutoOpenStartNodeSelector = true
|
||||
const nodeType: BlockEnum = BlockEnum.TriggerWebhook
|
||||
const isChatMode = false
|
||||
const validStartTypes = [BlockEnum.Start, BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin]
|
||||
|
||||
const shouldAutoExpand = shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode
|
||||
|
||||
expect(shouldAutoExpand).toBe(true)
|
||||
})
|
||||
|
||||
it('should auto-expand for TriggerPlugin in new workflow', () => {
|
||||
const shouldAutoOpenStartNodeSelector = true
|
||||
const nodeType: BlockEnum = BlockEnum.TriggerPlugin
|
||||
const isChatMode = false
|
||||
const validStartTypes = [BlockEnum.Start, BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin]
|
||||
|
||||
const shouldAutoExpand = shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode
|
||||
|
||||
expect(shouldAutoExpand).toBe(true)
|
||||
})
|
||||
|
||||
it('should not auto-expand for non-trigger nodes', () => {
|
||||
const shouldAutoOpenStartNodeSelector = true
|
||||
const nodeType: BlockEnum = BlockEnum.LLM
|
||||
const isChatMode = false
|
||||
const validStartTypes = [BlockEnum.Start, BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin]
|
||||
|
||||
const shouldAutoExpand = shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode
|
||||
|
||||
expect(shouldAutoExpand).toBe(false)
|
||||
})
|
||||
|
||||
it('should not auto-expand in chat mode', () => {
|
||||
const shouldAutoOpenStartNodeSelector = true
|
||||
const nodeType = BlockEnum.Start
|
||||
const isChatMode = true
|
||||
|
||||
const shouldAutoExpand = shouldAutoOpenStartNodeSelector && (
|
||||
nodeType === BlockEnum.Start
|
||||
|| nodeType === BlockEnum.TriggerSchedule
|
||||
|| nodeType === BlockEnum.TriggerWebhook
|
||||
|| nodeType === BlockEnum.TriggerPlugin
|
||||
) && !isChatMode
|
||||
|
||||
expect(shouldAutoExpand).toBe(false)
|
||||
})
|
||||
|
||||
it('should not auto-expand for existing workflows', () => {
|
||||
const shouldAutoOpenStartNodeSelector = false
|
||||
const nodeType = BlockEnum.Start
|
||||
const isChatMode = false
|
||||
|
||||
const shouldAutoExpand = shouldAutoOpenStartNodeSelector && (
|
||||
nodeType === BlockEnum.Start
|
||||
|| nodeType === BlockEnum.TriggerSchedule
|
||||
|| nodeType === BlockEnum.TriggerWebhook
|
||||
|| nodeType === BlockEnum.TriggerPlugin
|
||||
) && !isChatMode
|
||||
|
||||
expect(shouldAutoExpand).toBe(false)
|
||||
})
|
||||
it('should reset auto-open flag after triggering once', () => {
|
||||
let shouldAutoOpenStartNodeSelector = true
|
||||
const nodeType = BlockEnum.Start
|
||||
const isChatMode = false
|
||||
|
||||
const shouldAutoExpand = shouldAutoOpenStartNodeSelector && (
|
||||
nodeType === BlockEnum.Start
|
||||
|| nodeType === BlockEnum.TriggerSchedule
|
||||
|| nodeType === BlockEnum.TriggerWebhook
|
||||
|| nodeType === BlockEnum.TriggerPlugin
|
||||
) && !isChatMode
|
||||
|
||||
if (shouldAutoExpand)
|
||||
shouldAutoOpenStartNodeSelector = false
|
||||
|
||||
expect(shouldAutoExpand).toBe(true)
|
||||
expect(shouldAutoOpenStartNodeSelector).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Creation Without Auto-selection', () => {
|
||||
/**
|
||||
* Test that nodes are created without the 'selected: true' property
|
||||
* This prevents auto-opening the properties panel
|
||||
*/
|
||||
it('should create Start node without auto-selection', () => {
|
||||
const nodeData = { type: BlockEnum.Start, title: 'Start' }
|
||||
|
||||
// Simulate node creation logic from workflow-children.tsx
|
||||
const createdNodeData: Record<string, unknown> = {
|
||||
...nodeData,
|
||||
// Note: 'selected: true' should NOT be added
|
||||
}
|
||||
|
||||
expect(createdNodeData.selected).toBeUndefined()
|
||||
expect(createdNodeData.type).toBe(BlockEnum.Start)
|
||||
})
|
||||
|
||||
it('should create TriggerWebhook node without auto-selection', () => {
|
||||
const nodeData = { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' }
|
||||
const toolConfig = { webhook_url: 'https://example.com/webhook' }
|
||||
|
||||
const createdNodeData: Record<string, unknown> = {
|
||||
...nodeData,
|
||||
...toolConfig,
|
||||
// Note: 'selected: true' should NOT be added
|
||||
}
|
||||
|
||||
expect(createdNodeData.selected).toBeUndefined()
|
||||
expect(createdNodeData.type).toBe(BlockEnum.TriggerWebhook)
|
||||
expect(createdNodeData.webhook_url).toBe('https://example.com/webhook')
|
||||
})
|
||||
|
||||
it('should preserve other node properties while avoiding auto-selection', () => {
|
||||
const nodeData = {
|
||||
type: BlockEnum.TriggerSchedule,
|
||||
title: 'Schedule Trigger',
|
||||
config: { interval: '1h' },
|
||||
}
|
||||
|
||||
const createdNodeData: Record<string, unknown> = {
|
||||
...nodeData,
|
||||
}
|
||||
|
||||
expect(createdNodeData.selected).toBeUndefined()
|
||||
expect(createdNodeData.type).toBe(BlockEnum.TriggerSchedule)
|
||||
expect(createdNodeData.title).toBe('Schedule Trigger')
|
||||
expect(createdNodeData.config).toEqual({ interval: '1h' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow Initialization Logic', () => {
|
||||
/**
|
||||
* Test the initialization logic from use-workflow-init.ts
|
||||
* This ensures onboarding is triggered correctly for new workflows
|
||||
*/
|
||||
it('should trigger onboarding for new workflow when draft does not exist', () => {
|
||||
// Simulate the error handling logic from use-workflow-init.ts
|
||||
const error = {
|
||||
json: jest.fn().mockResolvedValue({ code: 'draft_workflow_not_exist' }),
|
||||
bodyUsed: false,
|
||||
}
|
||||
|
||||
const mockWorkflowStore = {
|
||||
setState: jest.fn(),
|
||||
}
|
||||
|
||||
// Simulate error handling
|
||||
if (error && error.json && !error.bodyUsed) {
|
||||
error.json().then((err: any) => {
|
||||
if (err.code === 'draft_workflow_not_exist') {
|
||||
mockWorkflowStore.setState({
|
||||
notInitialWorkflow: true,
|
||||
showOnboarding: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return error.json().then(() => {
|
||||
expect(mockWorkflowStore.setState).toHaveBeenCalledWith({
|
||||
notInitialWorkflow: true,
|
||||
showOnboarding: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not trigger onboarding for existing workflows', () => {
|
||||
// Simulate successful draft fetch
|
||||
const mockWorkflowStore = {
|
||||
setState: jest.fn(),
|
||||
}
|
||||
|
||||
// Normal initialization path should not set showOnboarding: true
|
||||
mockWorkflowStore.setState({
|
||||
environmentVariables: [],
|
||||
conversationVariables: [],
|
||||
})
|
||||
|
||||
expect(mockWorkflowStore.setState).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ showOnboarding: true }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should create empty draft with proper structure', () => {
|
||||
const mockSyncWorkflowDraft = jest.fn()
|
||||
const appId = 'test-app-id'
|
||||
|
||||
// Simulate the syncWorkflowDraft call from use-workflow-init.ts
|
||||
const draftParams = {
|
||||
url: `/apps/${appId}/workflows/draft`,
|
||||
params: {
|
||||
graph: {
|
||||
nodes: [], // Empty nodes initially
|
||||
edges: [],
|
||||
},
|
||||
features: {
|
||||
retriever_resource: { enabled: true },
|
||||
},
|
||||
environment_variables: [],
|
||||
conversation_variables: [],
|
||||
},
|
||||
}
|
||||
|
||||
mockSyncWorkflowDraft(draftParams)
|
||||
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({
|
||||
url: `/apps/${appId}/workflows/draft`,
|
||||
params: {
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
features: {
|
||||
retriever_resource: { enabled: true },
|
||||
},
|
||||
environment_variables: [],
|
||||
conversation_variables: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auto-Detection for Empty Canvas', () => {
|
||||
beforeEach(() => {
|
||||
mockGetNodes.mockClear()
|
||||
})
|
||||
|
||||
it('should detect empty canvas and trigger onboarding', () => {
|
||||
// Mock empty canvas
|
||||
mockGetNodes.mockReturnValue([])
|
||||
|
||||
// Mock store with proper state for auto-detection
|
||||
;(useWorkflowStore as jest.Mock).mockReturnValue({
|
||||
showOnboarding: false,
|
||||
hasShownOnboarding: false,
|
||||
notInitialWorkflow: false,
|
||||
setShowOnboarding: mockSetShowOnboarding,
|
||||
setHasShownOnboarding: mockSetHasShownOnboarding,
|
||||
hasSelectedStartNode: false,
|
||||
setHasSelectedStartNode: mockSetHasSelectedStartNode,
|
||||
shouldAutoOpenStartNodeSelector: false,
|
||||
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
|
||||
getState: () => ({
|
||||
showOnboarding: false,
|
||||
hasShownOnboarding: false,
|
||||
notInitialWorkflow: false,
|
||||
setShowOnboarding: mockSetShowOnboarding,
|
||||
setHasShownOnboarding: mockSetHasShownOnboarding,
|
||||
hasSelectedStartNode: false,
|
||||
setHasSelectedStartNode: mockSetHasSelectedStartNode,
|
||||
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
|
||||
}),
|
||||
})
|
||||
|
||||
// Simulate empty canvas check logic
|
||||
const nodes = mockGetNodes()
|
||||
const startNodeTypes = [
|
||||
BlockEnum.Start,
|
||||
BlockEnum.TriggerSchedule,
|
||||
BlockEnum.TriggerWebhook,
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
const hasStartNode = nodes.some((node: MockNode) => startNodeTypes.includes(node.data?.type as BlockEnum))
|
||||
const isEmpty = nodes.length === 0 || !hasStartNode
|
||||
|
||||
expect(isEmpty).toBe(true)
|
||||
expect(nodes.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should detect canvas with non-start nodes as empty', () => {
|
||||
// Mock canvas with non-start nodes
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: '1', data: { type: BlockEnum.LLM } },
|
||||
{ id: '2', data: { type: BlockEnum.Code } },
|
||||
])
|
||||
|
||||
const nodes = mockGetNodes()
|
||||
const startNodeTypes = [
|
||||
BlockEnum.Start,
|
||||
BlockEnum.TriggerSchedule,
|
||||
BlockEnum.TriggerWebhook,
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
const hasStartNode = nodes.some((node: MockNode) => startNodeTypes.includes(node.data.type as BlockEnum))
|
||||
const isEmpty = nodes.length === 0 || !hasStartNode
|
||||
|
||||
expect(isEmpty).toBe(true)
|
||||
expect(hasStartNode).toBe(false)
|
||||
})
|
||||
|
||||
it('should not detect canvas with start nodes as empty', () => {
|
||||
// Mock canvas with start node
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: '1', data: { type: BlockEnum.Start } },
|
||||
])
|
||||
|
||||
const nodes = mockGetNodes()
|
||||
const startNodeTypes = [
|
||||
BlockEnum.Start,
|
||||
BlockEnum.TriggerSchedule,
|
||||
BlockEnum.TriggerWebhook,
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
const hasStartNode = nodes.some((node: MockNode) => startNodeTypes.includes(node.data.type as BlockEnum))
|
||||
const isEmpty = nodes.length === 0 || !hasStartNode
|
||||
|
||||
expect(isEmpty).toBe(false)
|
||||
expect(hasStartNode).toBe(true)
|
||||
})
|
||||
|
||||
it('should not trigger onboarding if already shown in session', () => {
|
||||
// Mock empty canvas
|
||||
mockGetNodes.mockReturnValue([])
|
||||
|
||||
// Mock store with hasShownOnboarding = true
|
||||
;(useWorkflowStore as jest.Mock).mockReturnValue({
|
||||
showOnboarding: false,
|
||||
hasShownOnboarding: true, // Already shown in this session
|
||||
notInitialWorkflow: false,
|
||||
setShowOnboarding: mockSetShowOnboarding,
|
||||
setHasShownOnboarding: mockSetHasShownOnboarding,
|
||||
hasSelectedStartNode: false,
|
||||
setHasSelectedStartNode: mockSetHasSelectedStartNode,
|
||||
shouldAutoOpenStartNodeSelector: false,
|
||||
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
|
||||
getState: () => ({
|
||||
showOnboarding: false,
|
||||
hasShownOnboarding: true,
|
||||
notInitialWorkflow: false,
|
||||
setShowOnboarding: mockSetShowOnboarding,
|
||||
setHasShownOnboarding: mockSetHasShownOnboarding,
|
||||
hasSelectedStartNode: false,
|
||||
setHasSelectedStartNode: mockSetHasSelectedStartNode,
|
||||
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
|
||||
}),
|
||||
})
|
||||
|
||||
// Simulate the check logic with hasShownOnboarding = true
|
||||
const store = useWorkflowStore() as unknown as MockWorkflowStore
|
||||
const shouldTrigger = !store.hasShownOnboarding && !store.showOnboarding && !store.notInitialWorkflow
|
||||
|
||||
expect(shouldTrigger).toBe(false)
|
||||
})
|
||||
|
||||
it('should not trigger onboarding during initial workflow creation', () => {
|
||||
// Mock empty canvas
|
||||
mockGetNodes.mockReturnValue([])
|
||||
|
||||
// Mock store with notInitialWorkflow = true (initial creation)
|
||||
;(useWorkflowStore as jest.Mock).mockReturnValue({
|
||||
showOnboarding: false,
|
||||
hasShownOnboarding: false,
|
||||
notInitialWorkflow: true, // Initial workflow creation
|
||||
setShowOnboarding: mockSetShowOnboarding,
|
||||
setHasShownOnboarding: mockSetHasShownOnboarding,
|
||||
hasSelectedStartNode: false,
|
||||
setHasSelectedStartNode: mockSetHasSelectedStartNode,
|
||||
shouldAutoOpenStartNodeSelector: false,
|
||||
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
|
||||
getState: () => ({
|
||||
showOnboarding: false,
|
||||
hasShownOnboarding: false,
|
||||
notInitialWorkflow: true,
|
||||
setShowOnboarding: mockSetShowOnboarding,
|
||||
setHasShownOnboarding: mockSetHasShownOnboarding,
|
||||
hasSelectedStartNode: false,
|
||||
setHasSelectedStartNode: mockSetHasSelectedStartNode,
|
||||
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
|
||||
}),
|
||||
})
|
||||
|
||||
// Simulate the check logic with notInitialWorkflow = true
|
||||
const store = useWorkflowStore() as unknown as MockWorkflowStore
|
||||
const shouldTrigger = !store.hasShownOnboarding && !store.showOnboarding && !store.notInitialWorkflow
|
||||
|
||||
expect(shouldTrigger).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
301
dify/web/__tests__/workflow-parallel-limit.test.tsx
Normal file
301
dify/web/__tests__/workflow-parallel-limit.test.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* MAX_PARALLEL_LIMIT Configuration Bug Test
|
||||
*
|
||||
* This test reproduces and verifies the fix for issue #23083:
|
||||
* MAX_PARALLEL_LIMIT environment variable does not take effect in iteration panel
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
// Mock environment variables before importing constants
|
||||
const originalEnv = process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
|
||||
|
||||
// Test with different environment values
|
||||
function setupEnvironment(value?: string) {
|
||||
if (value)
|
||||
process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT = value
|
||||
else
|
||||
delete process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
|
||||
|
||||
// Clear module cache to force re-evaluation
|
||||
jest.resetModules()
|
||||
}
|
||||
|
||||
function restoreEnvironment() {
|
||||
if (originalEnv)
|
||||
process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT = originalEnv
|
||||
else
|
||||
delete process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
|
||||
|
||||
jest.resetModules()
|
||||
}
|
||||
|
||||
// Mock i18next with proper implementation
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
if (key.includes('MaxParallelismTitle')) return 'Max Parallelism'
|
||||
if (key.includes('MaxParallelismDesc')) return 'Maximum number of parallel executions'
|
||||
if (key.includes('parallelMode')) return 'Parallel Mode'
|
||||
if (key.includes('parallelPanelDesc')) return 'Enable parallel execution'
|
||||
if (key.includes('errorResponseMethod')) return 'Error Response Method'
|
||||
return key
|
||||
},
|
||||
}),
|
||||
initReactI18next: {
|
||||
type: '3rdParty',
|
||||
init: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock i18next module completely to prevent initialization issues
|
||||
jest.mock('i18next', () => ({
|
||||
use: jest.fn().mockReturnThis(),
|
||||
init: jest.fn().mockReturnThis(),
|
||||
t: jest.fn(key => key),
|
||||
isInitialized: true,
|
||||
}))
|
||||
|
||||
// Mock the useConfig hook
|
||||
jest.mock('@/app/components/workflow/nodes/iteration/use-config', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
inputs: {
|
||||
is_parallel: true,
|
||||
parallel_nums: 5,
|
||||
error_handle_mode: 'terminated',
|
||||
},
|
||||
changeParallel: jest.fn(),
|
||||
changeParallelNums: jest.fn(),
|
||||
changeErrorHandleMode: jest.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock other components
|
||||
jest.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => {
|
||||
return function MockVarReferencePicker() {
|
||||
return <div data-testid="var-reference-picker">VarReferencePicker</div>
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/app/components/workflow/nodes/_base/components/split', () => {
|
||||
return function MockSplit() {
|
||||
return <div data-testid="split">Split</div>
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/app/components/workflow/nodes/_base/components/field', () => {
|
||||
return function MockField({ title, children }: { title: string, children: React.ReactNode }) {
|
||||
return (
|
||||
<div data-testid="field">
|
||||
<label>{title}</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/app/components/base/switch', () => {
|
||||
return function MockSwitch({ defaultValue }: { defaultValue: boolean }) {
|
||||
return <input type="checkbox" defaultChecked={defaultValue} data-testid="switch" />
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/app/components/base/select', () => {
|
||||
return function MockSelect() {
|
||||
return <select data-testid="select">Select</select>
|
||||
}
|
||||
})
|
||||
|
||||
// Use defaultValue to avoid controlled input warnings
|
||||
jest.mock('@/app/components/base/slider', () => {
|
||||
return function MockSlider({ value, max, min }: { value: number, max: number, min: number }) {
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
defaultValue={value}
|
||||
max={max}
|
||||
min={min}
|
||||
data-testid="slider"
|
||||
data-max={max}
|
||||
data-min={min}
|
||||
readOnly
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Use defaultValue to avoid controlled input warnings
|
||||
jest.mock('@/app/components/base/input', () => {
|
||||
return function MockInput({ type, max, min, value }: { type: string, max: number, min: number, value: number }) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
defaultValue={value}
|
||||
max={max}
|
||||
min={min}
|
||||
data-testid="number-input"
|
||||
data-max={max}
|
||||
data-min={min}
|
||||
readOnly
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe('MAX_PARALLEL_LIMIT Configuration Bug', () => {
|
||||
const mockNodeData = {
|
||||
id: 'test-iteration-node',
|
||||
type: 'iteration' as const,
|
||||
data: {
|
||||
title: 'Test Iteration',
|
||||
desc: 'Test iteration node',
|
||||
iterator_selector: ['test'],
|
||||
output_selector: ['output'],
|
||||
is_parallel: true,
|
||||
parallel_nums: 5,
|
||||
error_handle_mode: 'terminated' as const,
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnvironment()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
restoreEnvironment()
|
||||
})
|
||||
|
||||
describe('Environment Variable Parsing', () => {
|
||||
it('should parse MAX_PARALLEL_LIMIT from NEXT_PUBLIC_MAX_PARALLEL_LIMIT environment variable', () => {
|
||||
setupEnvironment('25')
|
||||
const { MAX_PARALLEL_LIMIT } = require('@/config')
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(25)
|
||||
})
|
||||
|
||||
it('should fallback to default when environment variable is not set', () => {
|
||||
setupEnvironment() // No environment variable
|
||||
const { MAX_PARALLEL_LIMIT } = require('@/config')
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10)
|
||||
})
|
||||
|
||||
it('should handle invalid environment variable values', () => {
|
||||
setupEnvironment('invalid')
|
||||
const { MAX_PARALLEL_LIMIT } = require('@/config')
|
||||
|
||||
// Should fall back to default when parsing fails
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10)
|
||||
})
|
||||
|
||||
it('should handle empty environment variable', () => {
|
||||
setupEnvironment('')
|
||||
const { MAX_PARALLEL_LIMIT } = require('@/config')
|
||||
|
||||
// Should fall back to default when empty
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10)
|
||||
})
|
||||
|
||||
// Edge cases for boundary values
|
||||
it('should clamp MAX_PARALLEL_LIMIT to MIN when env is 0 or negative', () => {
|
||||
setupEnvironment('0')
|
||||
let { MAX_PARALLEL_LIMIT } = require('@/config')
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10) // Falls back to default
|
||||
|
||||
setupEnvironment('-5')
|
||||
;({ MAX_PARALLEL_LIMIT } = require('@/config'))
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(10) // Falls back to default
|
||||
})
|
||||
|
||||
it('should handle float numbers by parseInt behavior', () => {
|
||||
setupEnvironment('12.7')
|
||||
const { MAX_PARALLEL_LIMIT } = require('@/config')
|
||||
// parseInt truncates to integer
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UI Component Integration (Main Fix Verification)', () => {
|
||||
it('should render iteration panel with environment-configured max value', () => {
|
||||
// Set environment variable to a different value
|
||||
setupEnvironment('30')
|
||||
|
||||
// Import Panel after setting environment
|
||||
const Panel = require('@/app/components/workflow/nodes/iteration/panel').default
|
||||
const { MAX_PARALLEL_LIMIT } = require('@/config')
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="test-node"
|
||||
data={mockNodeData.data}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Behavior-focused assertion: UI max should equal MAX_PARALLEL_LIMIT
|
||||
const numberInput = screen.getByTestId('number-input')
|
||||
expect(numberInput).toHaveAttribute('data-max', String(MAX_PARALLEL_LIMIT))
|
||||
|
||||
const slider = screen.getByTestId('slider')
|
||||
expect(slider).toHaveAttribute('data-max', String(MAX_PARALLEL_LIMIT))
|
||||
|
||||
// Verify the actual values
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(30)
|
||||
expect(numberInput.getAttribute('data-max')).toBe('30')
|
||||
expect(slider.getAttribute('data-max')).toBe('30')
|
||||
})
|
||||
|
||||
it('should maintain UI consistency with different environment values', () => {
|
||||
setupEnvironment('15')
|
||||
const Panel = require('@/app/components/workflow/nodes/iteration/panel').default
|
||||
const { MAX_PARALLEL_LIMIT } = require('@/config')
|
||||
|
||||
render(
|
||||
<Panel
|
||||
id="test-node"
|
||||
data={mockNodeData.data}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Both input and slider should use the same max value from MAX_PARALLEL_LIMIT
|
||||
const numberInput = screen.getByTestId('number-input')
|
||||
const slider = screen.getByTestId('slider')
|
||||
|
||||
expect(numberInput.getAttribute('data-max')).toBe(slider.getAttribute('data-max'))
|
||||
expect(numberInput.getAttribute('data-max')).toBe(String(MAX_PARALLEL_LIMIT))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Legacy Constant Verification (For Transition Period)', () => {
|
||||
// Marked as transition/deprecation tests
|
||||
it('should maintain MAX_ITERATION_PARALLEL_NUM for backward compatibility', () => {
|
||||
const { MAX_ITERATION_PARALLEL_NUM } = require('@/app/components/workflow/constants')
|
||||
expect(typeof MAX_ITERATION_PARALLEL_NUM).toBe('number')
|
||||
expect(MAX_ITERATION_PARALLEL_NUM).toBe(10) // Hardcoded legacy value
|
||||
})
|
||||
|
||||
it('should demonstrate MAX_PARALLEL_LIMIT vs legacy constant difference', () => {
|
||||
setupEnvironment('50')
|
||||
const { MAX_PARALLEL_LIMIT } = require('@/config')
|
||||
const { MAX_ITERATION_PARALLEL_NUM } = require('@/app/components/workflow/constants')
|
||||
|
||||
// MAX_PARALLEL_LIMIT is configurable, MAX_ITERATION_PARALLEL_NUM is not
|
||||
expect(MAX_PARALLEL_LIMIT).toBe(50)
|
||||
expect(MAX_ITERATION_PARALLEL_NUM).toBe(10)
|
||||
expect(MAX_PARALLEL_LIMIT).not.toBe(MAX_ITERATION_PARALLEL_NUM)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Constants Validation', () => {
|
||||
it('should validate that required constants exist and have correct types', () => {
|
||||
const { MAX_PARALLEL_LIMIT } = require('@/config')
|
||||
const { MIN_ITERATION_PARALLEL_NUM } = require('@/app/components/workflow/constants')
|
||||
expect(typeof MAX_PARALLEL_LIMIT).toBe('number')
|
||||
expect(typeof MIN_ITERATION_PARALLEL_NUM).toBe('number')
|
||||
expect(MAX_PARALLEL_LIMIT).toBeGreaterThanOrEqual(MIN_ITERATION_PARALLEL_NUM)
|
||||
})
|
||||
})
|
||||
})
|
||||
76
dify/web/__tests__/xss-prevention.test.tsx
Normal file
76
dify/web/__tests__/xss-prevention.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* XSS Prevention Test Suite
|
||||
*
|
||||
* This test verifies that the XSS vulnerabilities in block-input and support-var-input
|
||||
* components have been properly fixed by replacing dangerouslySetInnerHTML with safe React rendering.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import BlockInput from '../app/components/base/block-input'
|
||||
import SupportVarInput from '../app/components/workflow/nodes/_base/components/support-var-input'
|
||||
|
||||
// Mock styles
|
||||
jest.mock('../app/components/app/configuration/base/var-highlight/style.module.css', () => ({
|
||||
item: 'mock-item-class',
|
||||
}))
|
||||
|
||||
describe('XSS Prevention - Block Input and Support Var Input Security', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('BlockInput Component Security', () => {
|
||||
it('should safely render malicious variable names without executing scripts', () => {
|
||||
const testInput = 'user@test.com{{<script>alert("XSS")</script>}}'
|
||||
const { container } = render(<BlockInput value={testInput} readonly={true} />)
|
||||
|
||||
const scriptElements = container.querySelectorAll('script')
|
||||
expect(scriptElements).toHaveLength(0)
|
||||
|
||||
const textContent = container.textContent
|
||||
expect(textContent).toContain('<script>')
|
||||
})
|
||||
|
||||
it('should preserve legitimate variable highlighting', () => {
|
||||
const legitimateInput = 'Hello {{userName}} welcome to {{appName}}'
|
||||
const { container } = render(<BlockInput value={legitimateInput} readonly={true} />)
|
||||
|
||||
const textContent = container.textContent
|
||||
expect(textContent).toContain('userName')
|
||||
expect(textContent).toContain('appName')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SupportVarInput Component Security', () => {
|
||||
it('should safely render malicious variable names without executing scripts', () => {
|
||||
const testInput = 'test@evil.com{{<img src=x onerror=alert(1)>}}'
|
||||
const { container } = render(<SupportVarInput value={testInput} readonly={true} />)
|
||||
|
||||
const scriptElements = container.querySelectorAll('script')
|
||||
const imgElements = container.querySelectorAll('img')
|
||||
|
||||
expect(scriptElements).toHaveLength(0)
|
||||
expect(imgElements).toHaveLength(0)
|
||||
|
||||
const textContent = container.textContent
|
||||
expect(textContent).toContain('<img')
|
||||
})
|
||||
})
|
||||
|
||||
describe('React Automatic Escaping Verification', () => {
|
||||
it('should confirm React automatic escaping works correctly', () => {
|
||||
const TestComponent = () => <span>{'<script>alert("xss")</script>'}</span>
|
||||
const { container } = render(<TestComponent />)
|
||||
|
||||
const spanElement = container.querySelector('span')
|
||||
const scriptElements = container.querySelectorAll('script')
|
||||
|
||||
expect(spanElement?.textContent).toBe('<script>alert("xss")</script>')
|
||||
expect(scriptElements).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
Reference in New Issue
Block a user