feat:新增dify1.11.1版本
This commit is contained in:
985
dify_1.11.1/web/testing/analyze-component.js
Normal file
985
dify_1.11.1/web/testing/analyze-component.js
Normal file
@@ -0,0 +1,985 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { Linter } = require('eslint')
|
||||
const sonarPlugin = require('eslint-plugin-sonarjs')
|
||||
const tsParser = require('@typescript-eslint/parser')
|
||||
|
||||
// ============================================================================
|
||||
// Simple Analyzer
|
||||
// ============================================================================
|
||||
|
||||
class ComponentAnalyzer {
|
||||
analyze(code, filePath, absolutePath) {
|
||||
const resolvedPath = absolutePath ?? path.resolve(process.cwd(), filePath)
|
||||
const fileName = path.basename(filePath, path.extname(filePath))
|
||||
const lineCount = code.split('\n').length
|
||||
|
||||
// Calculate complexity metrics
|
||||
const { total: rawComplexity, max: rawMaxComplexity } = this.calculateCognitiveComplexity(code)
|
||||
const complexity = this.normalizeComplexity(rawComplexity)
|
||||
const maxComplexity = this.normalizeComplexity(rawMaxComplexity)
|
||||
|
||||
// Count usage references (may take a few seconds)
|
||||
const usageCount = this.countUsageReferences(filePath, resolvedPath)
|
||||
|
||||
// Calculate test priority
|
||||
const priority = this.calculateTestPriority(complexity, usageCount)
|
||||
|
||||
return {
|
||||
name: fileName.charAt(0).toUpperCase() + fileName.slice(1),
|
||||
path: filePath,
|
||||
type: this.detectType(filePath, code),
|
||||
hasProps: code.includes('Props') || code.includes('interface'),
|
||||
hasState: code.includes('useState') || code.includes('useReducer'),
|
||||
hasEffects: code.includes('useEffect'),
|
||||
hasCallbacks: code.includes('useCallback'),
|
||||
hasMemo: code.includes('useMemo'),
|
||||
hasEvents: /on[A-Z]\w+/.test(code),
|
||||
hasRouter: code.includes('useRouter') || code.includes('usePathname'),
|
||||
hasAPI: code.includes('service/') || code.includes('fetch(') || code.includes('useSWR'),
|
||||
hasForwardRef: code.includes('forwardRef'),
|
||||
hasComponentMemo: /React\.memo|memo\(/.test(code),
|
||||
hasSuspense: code.includes('Suspense') || /\blazy\(/.test(code),
|
||||
hasPortal: code.includes('createPortal'),
|
||||
hasImperativeHandle: code.includes('useImperativeHandle'),
|
||||
hasSWR: code.includes('useSWR'),
|
||||
hasReactQuery: code.includes('useQuery') || code.includes('useMutation'),
|
||||
hasAhooks: code.includes("from 'ahooks'"),
|
||||
complexity,
|
||||
maxComplexity,
|
||||
rawComplexity,
|
||||
rawMaxComplexity,
|
||||
lineCount,
|
||||
usageCount,
|
||||
priority,
|
||||
}
|
||||
}
|
||||
|
||||
detectType(filePath, code) {
|
||||
const normalizedPath = filePath.replace(/\\/g, '/')
|
||||
if (normalizedPath.includes('/hooks/')) return 'hook'
|
||||
if (normalizedPath.includes('/utils/')) return 'util'
|
||||
if (/\/page\.(t|j)sx?$/.test(normalizedPath)) return 'page'
|
||||
if (/\/layout\.(t|j)sx?$/.test(normalizedPath)) return 'layout'
|
||||
if (/\/providers?\//.test(normalizedPath)) return 'provider'
|
||||
// Dify-specific types
|
||||
if (normalizedPath.includes('/components/base/')) return 'base-component'
|
||||
if (normalizedPath.includes('/context/')) return 'context'
|
||||
if (normalizedPath.includes('/store/')) return 'store'
|
||||
if (normalizedPath.includes('/service/')) return 'service'
|
||||
if (/use[A-Z]\w+/.test(code)) return 'component'
|
||||
return 'component'
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Cognitive Complexity using SonarJS ESLint plugin
|
||||
* Reference: https://www.sonarsource.com/blog/5-clean-code-tips-for-reducing-cognitive-complexity/
|
||||
*
|
||||
* Returns raw (unnormalized) complexity values:
|
||||
* - total: sum of all functions' complexity in the file
|
||||
* - max: highest single function complexity in the file
|
||||
*
|
||||
* Raw Score Thresholds (per function):
|
||||
* 0-15: Simple | 16-30: Medium | 31-50: Complex | 51+: Very Complex
|
||||
*
|
||||
* @returns {{ total: number, max: number }} raw total and max complexity
|
||||
*/
|
||||
calculateCognitiveComplexity(code) {
|
||||
const linter = new Linter()
|
||||
const baseConfig = {
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
},
|
||||
plugins: { sonarjs: sonarPlugin },
|
||||
}
|
||||
|
||||
try {
|
||||
// Get total complexity using 'metric' option (more stable)
|
||||
const totalConfig = {
|
||||
...baseConfig,
|
||||
rules: { 'sonarjs/cognitive-complexity': ['error', 0, 'metric'] },
|
||||
}
|
||||
const totalMessages = linter.verify(code, totalConfig)
|
||||
const totalMsg = totalMessages.find(
|
||||
msg => msg.ruleId === 'sonarjs/cognitive-complexity'
|
||||
&& msg.messageId === 'fileComplexity',
|
||||
)
|
||||
const total = totalMsg ? parseInt(totalMsg.message, 10) : 0
|
||||
|
||||
// Get max function complexity by analyzing each function
|
||||
const maxConfig = {
|
||||
...baseConfig,
|
||||
rules: { 'sonarjs/cognitive-complexity': ['error', 0] },
|
||||
}
|
||||
const maxMessages = linter.verify(code, maxConfig)
|
||||
let max = 0
|
||||
const complexityPattern = /reduce its Cognitive Complexity from (\d+)/
|
||||
|
||||
maxMessages.forEach((msg) => {
|
||||
if (msg.ruleId === 'sonarjs/cognitive-complexity') {
|
||||
const match = msg.message.match(complexityPattern)
|
||||
if (match && match[1])
|
||||
max = Math.max(max, parseInt(match[1], 10))
|
||||
}
|
||||
})
|
||||
|
||||
return { total, max }
|
||||
}
|
||||
catch {
|
||||
return { total: 0, max: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize cognitive complexity to 0-100 scale
|
||||
*
|
||||
* Mapping (aligned with SonarJS thresholds):
|
||||
* Raw 0-15 (Simple) -> Normalized 0-25
|
||||
* Raw 16-30 (Medium) -> Normalized 25-50
|
||||
* Raw 31-50 (Complex) -> Normalized 50-75
|
||||
* Raw 51+ (Very Complex) -> Normalized 75-100 (asymptotic)
|
||||
*/
|
||||
normalizeComplexity(rawComplexity) {
|
||||
if (rawComplexity <= 15) {
|
||||
// Linear: 0-15 -> 0-25
|
||||
return Math.round((rawComplexity / 15) * 25)
|
||||
}
|
||||
else if (rawComplexity <= 30) {
|
||||
// Linear: 16-30 -> 25-50
|
||||
return Math.round(25 + ((rawComplexity - 15) / 15) * 25)
|
||||
}
|
||||
else if (rawComplexity <= 50) {
|
||||
// Linear: 31-50 -> 50-75
|
||||
return Math.round(50 + ((rawComplexity - 30) / 20) * 25)
|
||||
}
|
||||
else {
|
||||
// Asymptotic: 51+ -> 75-100
|
||||
// Formula ensures score approaches but never exceeds 100
|
||||
return Math.round(75 + 25 * (1 - 1 / (1 + (rawComplexity - 50) / 100)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many times a component is referenced in the codebase
|
||||
* Scans TypeScript sources for import statements referencing the component
|
||||
*/
|
||||
countUsageReferences(filePath, absolutePath) {
|
||||
try {
|
||||
const resolvedComponentPath = absolutePath ?? path.resolve(process.cwd(), filePath)
|
||||
const fileName = path.basename(resolvedComponentPath, path.extname(resolvedComponentPath))
|
||||
|
||||
let searchName = fileName
|
||||
if (fileName === 'index') {
|
||||
const parentDir = path.dirname(resolvedComponentPath)
|
||||
searchName = path.basename(parentDir)
|
||||
}
|
||||
|
||||
if (!searchName) return 0
|
||||
|
||||
const searchRoots = this.collectSearchRoots(resolvedComponentPath)
|
||||
if (searchRoots.length === 0) return 0
|
||||
|
||||
const escapedName = ComponentAnalyzer.escapeRegExp(searchName)
|
||||
const patterns = [
|
||||
new RegExp(`from\\s+['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
|
||||
new RegExp(`import\\s*\\(\\s*['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
|
||||
new RegExp(`export\\s+(?:\\*|{[^}]*})\\s*from\\s+['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
|
||||
new RegExp(`require\\(\\s*['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`),
|
||||
]
|
||||
|
||||
const visited = new Set()
|
||||
let usageCount = 0
|
||||
|
||||
const stack = [...searchRoots]
|
||||
while (stack.length > 0) {
|
||||
const currentDir = stack.pop()
|
||||
if (!currentDir || visited.has(currentDir)) continue
|
||||
visited.add(currentDir)
|
||||
|
||||
const entries = fs.readdirSync(currentDir, { withFileTypes: true })
|
||||
|
||||
entries.forEach(entry => {
|
||||
const entryPath = path.join(currentDir, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (this.shouldSkipDir(entry.name)) return
|
||||
stack.push(entryPath)
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.shouldInspectFile(entry.name)) return
|
||||
|
||||
const normalizedEntryPath = path.resolve(entryPath)
|
||||
if (normalizedEntryPath === path.resolve(resolvedComponentPath)) return
|
||||
|
||||
const source = fs.readFileSync(entryPath, 'utf-8')
|
||||
if (!source.includes(searchName)) return
|
||||
|
||||
if (patterns.some(pattern => {
|
||||
pattern.lastIndex = 0
|
||||
return pattern.test(source)
|
||||
})) {
|
||||
usageCount += 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return usageCount
|
||||
}
|
||||
catch {
|
||||
// If command fails, return 0
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
collectSearchRoots(resolvedComponentPath) {
|
||||
const roots = new Set()
|
||||
|
||||
let currentDir = path.dirname(resolvedComponentPath)
|
||||
const workspaceRoot = process.cwd()
|
||||
|
||||
while (currentDir && currentDir !== path.dirname(currentDir)) {
|
||||
if (path.basename(currentDir) === 'app') {
|
||||
roots.add(currentDir)
|
||||
break
|
||||
}
|
||||
|
||||
if (currentDir === workspaceRoot) break
|
||||
currentDir = path.dirname(currentDir)
|
||||
}
|
||||
|
||||
const fallbackRoots = [
|
||||
path.join(workspaceRoot, 'app'),
|
||||
path.join(workspaceRoot, 'web', 'app'),
|
||||
path.join(workspaceRoot, 'src'),
|
||||
]
|
||||
|
||||
fallbackRoots.forEach(root => {
|
||||
if (fs.existsSync(root) && fs.statSync(root).isDirectory()) roots.add(root)
|
||||
})
|
||||
|
||||
return Array.from(roots)
|
||||
}
|
||||
|
||||
shouldSkipDir(dirName) {
|
||||
const normalized = dirName.toLowerCase()
|
||||
return [
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'dist',
|
||||
'out',
|
||||
'coverage',
|
||||
'build',
|
||||
'__tests__',
|
||||
'__mocks__',
|
||||
].includes(normalized)
|
||||
}
|
||||
|
||||
shouldInspectFile(fileName) {
|
||||
const normalized = fileName.toLowerCase()
|
||||
if (!(/\.(ts|tsx)$/i.test(fileName))) return false
|
||||
if (normalized.endsWith('.d.ts')) return false
|
||||
if (/\.(spec|test)\.(ts|tsx)$/.test(normalized)) return false
|
||||
if (normalized.endsWith('.stories.tsx')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
static escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate test priority based on cognitive complexity and usage
|
||||
*
|
||||
* Priority Score = 0.7 * Complexity + 0.3 * Usage Score (all normalized to 0-100)
|
||||
* - Complexity Score: 0-100 (normalized from SonarJS)
|
||||
* - Usage Score: 0-100 (based on reference count)
|
||||
*
|
||||
* Priority Levels (0-100):
|
||||
* - 0-25: 🟢 LOW
|
||||
* - 26-50: 🟡 MEDIUM
|
||||
* - 51-75: 🟠 HIGH
|
||||
* - 76-100: 🔴 CRITICAL
|
||||
*/
|
||||
calculateTestPriority(complexity, usageCount) {
|
||||
const complexityScore = complexity
|
||||
|
||||
// Normalize usage score to 0-100
|
||||
let usageScore
|
||||
if (usageCount === 0)
|
||||
usageScore = 0
|
||||
else if (usageCount <= 5)
|
||||
usageScore = 20
|
||||
else if (usageCount <= 20)
|
||||
usageScore = 40
|
||||
else if (usageCount <= 50)
|
||||
usageScore = 70
|
||||
else
|
||||
usageScore = 100
|
||||
|
||||
// Weighted average: complexity (70%) + usage (30%)
|
||||
const totalScore = Math.round(0.7 * complexityScore + 0.3 * usageScore)
|
||||
|
||||
return {
|
||||
score: totalScore,
|
||||
level: this.getPriorityLevel(totalScore),
|
||||
usageScore,
|
||||
complexityScore,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get priority level based on score (0-100 scale)
|
||||
*/
|
||||
getPriorityLevel(score) {
|
||||
if (score > 75) return '🔴 CRITICAL'
|
||||
if (score > 50) return '🟠 HIGH'
|
||||
if (score > 25) return '🟡 MEDIUM'
|
||||
return '🟢 LOW'
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Prompt Builder for AI Assistants
|
||||
// ============================================================================
|
||||
|
||||
class TestPromptBuilder {
|
||||
build(analysis) {
|
||||
const testPath = analysis.path.replace(/\.tsx?$/, '.spec.tsx')
|
||||
|
||||
return `
|
||||
╔════════════════════════════════════════════════════════════════════════════╗
|
||||
║ 📋 GENERATE TEST FOR DIFY COMPONENT ║
|
||||
╚════════════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
📍 Component: ${analysis.name}
|
||||
📂 Path: ${analysis.path}
|
||||
🎯 Test File: ${testPath}
|
||||
|
||||
📊 Component Analysis:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Type: ${analysis.type}
|
||||
Total Complexity: ${analysis.complexity}/100 ${this.getComplexityLevel(analysis.complexity)}
|
||||
Max Func Complexity: ${analysis.maxComplexity}/100 ${this.getComplexityLevel(analysis.maxComplexity)}
|
||||
Lines: ${analysis.lineCount}
|
||||
Usage: ${analysis.usageCount} reference${analysis.usageCount !== 1 ? 's' : ''}
|
||||
Test Priority: ${analysis.priority.score} ${analysis.priority.level}
|
||||
|
||||
Features Detected:
|
||||
${analysis.hasProps ? '✓' : '✗'} Props/TypeScript interfaces
|
||||
${analysis.hasState ? '✓' : '✗'} Local state (useState/useReducer)
|
||||
${analysis.hasEffects ? '✓' : '✗'} Side effects (useEffect)
|
||||
${analysis.hasCallbacks ? '✓' : '✗'} Callbacks (useCallback)
|
||||
${analysis.hasMemo ? '✓' : '✗'} Memoization (useMemo)
|
||||
${analysis.hasEvents ? '✓' : '✗'} Event handlers
|
||||
${analysis.hasRouter ? '✓' : '✗'} Next.js routing
|
||||
${analysis.hasAPI ? '✓' : '✗'} API calls
|
||||
${analysis.hasSWR ? '✓' : '✗'} SWR data fetching
|
||||
${analysis.hasReactQuery ? '✓' : '✗'} React Query
|
||||
${analysis.hasAhooks ? '✓' : '✗'} ahooks
|
||||
${analysis.hasForwardRef ? '✓' : '✗'} Ref forwarding (forwardRef)
|
||||
${analysis.hasComponentMemo ? '✓' : '✗'} Component memoization (React.memo)
|
||||
${analysis.hasImperativeHandle ? '✓' : '✗'} Imperative handle
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
📝 TASK:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
Please generate a comprehensive test file for this component at:
|
||||
${testPath}
|
||||
|
||||
The component is located at:
|
||||
${analysis.path}
|
||||
|
||||
${this.getSpecificGuidelines(analysis)}
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
📋 PROMPT FOR AI ASSISTANT (COPY THIS TO YOUR AI ASSISTANT):
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
Generate a comprehensive test file for @${analysis.path}
|
||||
|
||||
Including but not limited to:
|
||||
${this.buildFocusPoints(analysis)}
|
||||
|
||||
Create the test file at: ${testPath}
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
`
|
||||
}
|
||||
|
||||
getComplexityLevel(score) {
|
||||
// Normalized complexity thresholds (0-100 scale)
|
||||
if (score <= 25) return '🟢 Simple'
|
||||
if (score <= 50) return '🟡 Medium'
|
||||
if (score <= 75) return '🟠 Complex'
|
||||
return '🔴 Very Complex'
|
||||
}
|
||||
|
||||
buildFocusPoints(analysis) {
|
||||
const points = []
|
||||
|
||||
if (analysis.hasState) points.push('- Testing state management and updates')
|
||||
if (analysis.hasEffects) points.push('- Testing side effects and cleanup')
|
||||
if (analysis.hasCallbacks) points.push('- Testing callback stability and memoization')
|
||||
if (analysis.hasMemo) points.push('- Testing memoization logic and dependencies')
|
||||
if (analysis.hasEvents) points.push('- Testing user interactions and event handlers')
|
||||
if (analysis.hasRouter) points.push('- Mocking Next.js router hooks')
|
||||
if (analysis.hasAPI) points.push('- Mocking API calls')
|
||||
if (analysis.hasForwardRef) points.push('- Testing ref forwarding behavior')
|
||||
if (analysis.hasComponentMemo) points.push('- Testing component memoization')
|
||||
if (analysis.hasSuspense) points.push('- Testing Suspense boundaries and lazy loading')
|
||||
if (analysis.hasPortal) points.push('- Testing Portal rendering')
|
||||
if (analysis.hasImperativeHandle) points.push('- Testing imperative handle methods')
|
||||
points.push('- Testing edge cases and error handling')
|
||||
points.push('- Testing all prop variations')
|
||||
|
||||
return points.join('\n')
|
||||
}
|
||||
|
||||
getSpecificGuidelines(analysis) {
|
||||
const guidelines = []
|
||||
|
||||
// ===== Test Priority Guidance =====
|
||||
if (analysis.priority.level.includes('CRITICAL')) {
|
||||
guidelines.push('🔴 CRITICAL PRIORITY component:')
|
||||
guidelines.push(` - Used in ${analysis.usageCount} places across the codebase`)
|
||||
guidelines.push(' - Changes will have WIDE impact')
|
||||
guidelines.push(' - Require comprehensive test coverage')
|
||||
guidelines.push(' - Add regression tests for all use cases')
|
||||
guidelines.push(' - Consider integration tests with dependent components')
|
||||
}
|
||||
else if (analysis.usageCount > 50) {
|
||||
guidelines.push('🟠 VERY HIGH USAGE component:')
|
||||
guidelines.push(` - Referenced ${analysis.usageCount} times in the codebase`)
|
||||
guidelines.push(' - Changes may affect many parts of the application')
|
||||
guidelines.push(' - Comprehensive test coverage is CRITICAL')
|
||||
guidelines.push(' - Add tests for all common usage patterns')
|
||||
guidelines.push(' - Consider regression tests')
|
||||
}
|
||||
else if (analysis.usageCount > 20) {
|
||||
guidelines.push('🟡 HIGH USAGE component:')
|
||||
guidelines.push(` - Referenced ${analysis.usageCount} times in the codebase`)
|
||||
guidelines.push(' - Test coverage is important to prevent widespread bugs')
|
||||
guidelines.push(' - Add tests for common usage patterns')
|
||||
}
|
||||
|
||||
// ===== Complexity Warning =====
|
||||
if (analysis.complexity > 75) {
|
||||
guidelines.push(`🔴 HIGH Total Complexity (${analysis.complexity}/100). Consider:`)
|
||||
guidelines.push(' - Splitting component into smaller pieces before testing')
|
||||
guidelines.push(' - Creating integration tests for complex workflows')
|
||||
guidelines.push(' - Using test.each() for data-driven tests')
|
||||
}
|
||||
else if (analysis.complexity > 50) {
|
||||
guidelines.push(`⚠️ MODERATE Total Complexity (${analysis.complexity}/100). Consider:`)
|
||||
guidelines.push(' - Breaking tests into multiple describe blocks')
|
||||
guidelines.push(' - Testing integration scenarios')
|
||||
guidelines.push(' - Grouping related test cases')
|
||||
}
|
||||
|
||||
// ===== Max Function Complexity Warning =====
|
||||
if (analysis.maxComplexity > 75) {
|
||||
guidelines.push(`🔴 HIGH Single Function Complexity (max: ${analysis.maxComplexity}/100). Consider:`)
|
||||
guidelines.push(' - Breaking down the complex function into smaller helpers')
|
||||
guidelines.push(' - Extracting logic into custom hooks or utility functions')
|
||||
}
|
||||
else if (analysis.maxComplexity > 50) {
|
||||
guidelines.push(`⚠️ MODERATE Single Function Complexity (max: ${analysis.maxComplexity}/100). Consider:`)
|
||||
guidelines.push(' - Simplifying conditional logic')
|
||||
guidelines.push(' - Using early returns to reduce nesting')
|
||||
}
|
||||
|
||||
// ===== State Management =====
|
||||
if (analysis.hasState && analysis.hasEffects) {
|
||||
guidelines.push('🔄 State + Effects detected:')
|
||||
guidelines.push(' - Test state initialization and updates')
|
||||
guidelines.push(' - Test useEffect dependencies array')
|
||||
guidelines.push(' - Test cleanup functions (return from useEffect)')
|
||||
guidelines.push(' - Use waitFor() for async state changes')
|
||||
}
|
||||
else if (analysis.hasState) {
|
||||
guidelines.push('📊 State management detected:')
|
||||
guidelines.push(' - Test initial state values')
|
||||
guidelines.push(' - Test all state transitions')
|
||||
guidelines.push(' - Test state reset/cleanup scenarios')
|
||||
}
|
||||
else if (analysis.hasEffects) {
|
||||
guidelines.push('⚡ Side effects detected:')
|
||||
guidelines.push(' - Test effect execution conditions')
|
||||
guidelines.push(' - Verify dependencies array correctness')
|
||||
guidelines.push(' - Test cleanup on unmount')
|
||||
}
|
||||
|
||||
// ===== Performance Optimization =====
|
||||
if (analysis.hasCallbacks || analysis.hasMemo || analysis.hasComponentMemo) {
|
||||
const features = []
|
||||
if (analysis.hasCallbacks) features.push('useCallback')
|
||||
if (analysis.hasMemo) features.push('useMemo')
|
||||
if (analysis.hasComponentMemo) features.push('React.memo')
|
||||
|
||||
guidelines.push(`🚀 Performance optimization (${features.join(', ')}):`)
|
||||
guidelines.push(' - Verify callbacks maintain referential equality')
|
||||
guidelines.push(' - Test memoization dependencies')
|
||||
guidelines.push(' - Ensure expensive computations are cached')
|
||||
if (analysis.hasComponentMemo) {
|
||||
guidelines.push(' - Test component re-render behavior with prop changes')
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Ref Forwarding =====
|
||||
if (analysis.hasForwardRef || analysis.hasImperativeHandle) {
|
||||
guidelines.push('🔗 Ref forwarding detected:')
|
||||
guidelines.push(' - Test ref attachment to DOM elements')
|
||||
if (analysis.hasImperativeHandle) {
|
||||
guidelines.push(' - Test all exposed imperative methods')
|
||||
guidelines.push(' - Verify method behavior with different ref types')
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Suspense and Lazy Loading =====
|
||||
if (analysis.hasSuspense) {
|
||||
guidelines.push('⏳ Suspense/Lazy loading detected:')
|
||||
guidelines.push(' - Test fallback UI during loading')
|
||||
guidelines.push(' - Test component behavior after lazy load completes')
|
||||
guidelines.push(' - Test error boundaries with failed loads')
|
||||
}
|
||||
|
||||
// ===== Portal =====
|
||||
if (analysis.hasPortal) {
|
||||
guidelines.push('🚪 Portal rendering detected:')
|
||||
guidelines.push(' - Test content renders in portal target')
|
||||
guidelines.push(' - Test portal cleanup on unmount')
|
||||
guidelines.push(' - Verify event bubbling through portal')
|
||||
}
|
||||
|
||||
// ===== API Calls =====
|
||||
if (analysis.hasAPI) {
|
||||
guidelines.push('🌐 API calls detected:')
|
||||
guidelines.push(' - Mock API calls/hooks (useSWR, useQuery, fetch, etc.)')
|
||||
guidelines.push(' - Test loading, success, and error states')
|
||||
guidelines.push(' - Focus on component behavior, not the data fetching lib')
|
||||
}
|
||||
|
||||
// ===== ahooks =====
|
||||
if (analysis.hasAhooks) {
|
||||
guidelines.push('🪝 ahooks detected (mock only, no need to test the lib):')
|
||||
guidelines.push(' - Mock ahooks utilities (useBoolean, useRequest, etc.)')
|
||||
guidelines.push(' - Focus on testing how your component uses the hooks')
|
||||
guidelines.push(' - Use fake timers if debounce/throttle is involved')
|
||||
}
|
||||
|
||||
// ===== Routing =====
|
||||
if (analysis.hasRouter) {
|
||||
guidelines.push('🔀 Next.js routing detected:')
|
||||
guidelines.push(' - Mock useRouter, usePathname, useSearchParams')
|
||||
guidelines.push(' - Test navigation behavior and parameters')
|
||||
guidelines.push(' - Test query string handling')
|
||||
guidelines.push(' - Verify route guards/redirects if any')
|
||||
}
|
||||
|
||||
// ===== Event Handlers =====
|
||||
if (analysis.hasEvents) {
|
||||
guidelines.push('🎯 Event handlers detected:')
|
||||
guidelines.push(' - Test all onClick, onChange, onSubmit handlers')
|
||||
guidelines.push(' - Test keyboard events (Enter, Escape, etc.)')
|
||||
guidelines.push(' - Verify event.preventDefault() calls if needed')
|
||||
guidelines.push(' - Test event bubbling/propagation')
|
||||
}
|
||||
|
||||
// ===== Domain-Specific Components =====
|
||||
if (analysis.path.includes('workflow')) {
|
||||
guidelines.push('⚙️ Workflow component:')
|
||||
guidelines.push(' - Test node configuration and validation')
|
||||
guidelines.push(' - Test data flow and variable passing')
|
||||
guidelines.push(' - Test edge connections and graph structure')
|
||||
guidelines.push(' - Verify error handling for invalid configs')
|
||||
}
|
||||
|
||||
if (analysis.path.includes('dataset')) {
|
||||
guidelines.push('📚 Dataset component:')
|
||||
guidelines.push(' - Test file upload and validation')
|
||||
guidelines.push(' - Test pagination and data loading')
|
||||
guidelines.push(' - Test search and filtering')
|
||||
guidelines.push(' - Verify data format handling')
|
||||
}
|
||||
|
||||
if (analysis.path.includes('app/configuration') || analysis.path.includes('config')) {
|
||||
guidelines.push('⚙️ Configuration component:')
|
||||
guidelines.push(' - Test form validation thoroughly')
|
||||
guidelines.push(' - Test save/reset functionality')
|
||||
guidelines.push(' - Test required vs optional fields')
|
||||
guidelines.push(' - Verify configuration persistence')
|
||||
}
|
||||
|
||||
// ===== File Size Warning =====
|
||||
if (analysis.lineCount > 500) {
|
||||
guidelines.push('📏 Large component (500+ lines):')
|
||||
guidelines.push(' - Consider splitting into smaller components')
|
||||
guidelines.push(' - Test major sections separately')
|
||||
guidelines.push(' - Use helper functions to reduce test complexity')
|
||||
}
|
||||
|
||||
return guidelines.length > 0 ? `\n${guidelines.join('\n')}\n` : ''
|
||||
}
|
||||
}
|
||||
|
||||
class TestReviewPromptBuilder {
|
||||
build({ analysis, testPath, testCode, originalPromptSection }) {
|
||||
const formattedOriginalPrompt = originalPromptSection
|
||||
? originalPromptSection
|
||||
.split('\n')
|
||||
.map(line => (line.trim().length > 0 ? ` ${line}` : ''))
|
||||
.join('\n')
|
||||
.trimEnd()
|
||||
: ' (original generation prompt unavailable)'
|
||||
|
||||
return `
|
||||
╔════════════════════════════════════════════════════════════════════════════╗
|
||||
║ ✅ REVIEW TEST FOR DIFY COMPONENT ║
|
||||
╚════════════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
📂 Component Path: ${analysis.path}
|
||||
🧪 Test File: ${testPath}
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
📝 REVIEW TASK:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
📋 PROMPT FOR AI ASSISTANT (COPY THIS TO YOUR AI ASSISTANT):
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
You are reviewing the frontend test coverage for @${analysis.path}.
|
||||
|
||||
Original generation requirements:
|
||||
${formattedOriginalPrompt}
|
||||
|
||||
Test file under review:
|
||||
${testPath}
|
||||
|
||||
Checklist (ensure every item is addressed in your review):
|
||||
- Confirm the tests satisfy all requirements listed above and in web/testing/TESTING.md.
|
||||
- Verify Arrange → Act → Assert structure, mocks, and cleanup follow project conventions.
|
||||
- Ensure all detected component features (state, effects, routing, API, events, etc.) are exercised, including edge cases and error paths.
|
||||
- Check coverage of prop variations, null/undefined inputs, and high-priority workflows implied by usage score.
|
||||
- Validate mocks/stubs interact correctly with Next.js router, network calls, and async updates.
|
||||
- Ensure naming, describe/it structure, and placement match repository standards.
|
||||
|
||||
Output format:
|
||||
1. Start with a single word verdict: PASS or FAIL.
|
||||
2. If FAIL, list each missing requirement or defect as a separate bullet with actionable fixes.
|
||||
3. Highlight any optional improvements or refactors after mandatory issues.
|
||||
4. Mention any additional tests or tooling steps (e.g., pnpm lint/test) the developer should run.
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
function extractCopyContent(prompt) {
|
||||
const marker = '📋 PROMPT FOR AI ASSISTANT'
|
||||
const markerIndex = prompt.indexOf(marker)
|
||||
if (markerIndex === -1) return ''
|
||||
|
||||
const section = prompt.slice(markerIndex)
|
||||
const lines = section.split('\n')
|
||||
const firstDivider = lines.findIndex(line => line.includes('━━━━━━━━'))
|
||||
if (firstDivider === -1) return ''
|
||||
|
||||
const startIdx = firstDivider + 1
|
||||
let endIdx = lines.length
|
||||
|
||||
for (let i = startIdx; i < lines.length; i++) {
|
||||
if (lines[i].includes('━━━━━━━━')) {
|
||||
endIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (startIdx >= endIdx) return ''
|
||||
|
||||
return lines.slice(startIdx, endIdx).join('\n').trim()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Function
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Resolve directory to entry file
|
||||
* Priority: index files > common entry files (node.tsx, panel.tsx, etc.)
|
||||
*/
|
||||
function resolveDirectoryEntry(absolutePath, componentPath) {
|
||||
// Entry files in priority order: index files first, then common entry files
|
||||
const entryFiles = [
|
||||
'index.tsx', 'index.ts', // Priority 1: index files
|
||||
'node.tsx', 'panel.tsx', 'component.tsx', 'main.tsx', 'container.tsx', // Priority 2: common entry files
|
||||
]
|
||||
for (const entryFile of entryFiles) {
|
||||
const entryPath = path.join(absolutePath, entryFile)
|
||||
if (fs.existsSync(entryPath)) {
|
||||
return {
|
||||
absolutePath: entryPath,
|
||||
componentPath: path.join(componentPath, entryFile),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* List analyzable files in directory (for user guidance)
|
||||
*/
|
||||
function listAnalyzableFiles(dirPath) {
|
||||
try {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
||||
return entries
|
||||
.filter(entry => !entry.isDirectory() && /\.(tsx?|jsx?)$/.test(entry.name) && !entry.name.endsWith('.d.ts'))
|
||||
.map(entry => entry.name)
|
||||
.sort((a, b) => {
|
||||
// Prioritize common entry files
|
||||
const priority = ['index.tsx', 'index.ts', 'node.tsx', 'panel.tsx', 'component.tsx', 'main.tsx', 'container.tsx']
|
||||
const aIdx = priority.indexOf(a)
|
||||
const bIdx = priority.indexOf(b)
|
||||
if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx
|
||||
if (aIdx !== -1) return -1
|
||||
if (bIdx !== -1) return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
📋 Component Analyzer - Generate test prompts for AI assistants
|
||||
|
||||
Usage:
|
||||
node analyze-component.js <component-path> [options]
|
||||
pnpm analyze-component <component-path> [options]
|
||||
|
||||
Options:
|
||||
--help Show this help message
|
||||
--json Output analysis result as JSON (for programmatic use)
|
||||
--review Generate a review prompt for existing test file
|
||||
|
||||
Examples:
|
||||
# Analyze a component and generate test prompt
|
||||
pnpm analyze-component app/components/base/button/index.tsx
|
||||
|
||||
# Output as JSON
|
||||
pnpm analyze-component app/components/base/button/index.tsx --json
|
||||
|
||||
# Review existing test
|
||||
pnpm analyze-component app/components/base/button/index.tsx --review
|
||||
|
||||
For complete testing guidelines, see: web/testing/testing.md
|
||||
`)
|
||||
}
|
||||
|
||||
function main() {
|
||||
const rawArgs = process.argv.slice(2)
|
||||
|
||||
let isReviewMode = false
|
||||
let isJsonMode = false
|
||||
const args = []
|
||||
|
||||
rawArgs.forEach(arg => {
|
||||
if (arg === '--review') {
|
||||
isReviewMode = true
|
||||
return
|
||||
}
|
||||
if (arg === '--json') {
|
||||
isJsonMode = true
|
||||
return
|
||||
}
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
showHelp()
|
||||
process.exit(0)
|
||||
}
|
||||
args.push(arg)
|
||||
})
|
||||
|
||||
if (args.length === 0) {
|
||||
showHelp()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let componentPath = args[0]
|
||||
let absolutePath = path.resolve(process.cwd(), componentPath)
|
||||
|
||||
// Check if path exists
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
console.error(`❌ Error: Path not found: ${componentPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// If directory, try to find entry file
|
||||
if (fs.statSync(absolutePath).isDirectory()) {
|
||||
const resolvedFile = resolveDirectoryEntry(absolutePath, componentPath)
|
||||
if (resolvedFile) {
|
||||
absolutePath = resolvedFile.absolutePath
|
||||
componentPath = resolvedFile.componentPath
|
||||
}
|
||||
else {
|
||||
// List available files for user to choose
|
||||
const availableFiles = listAnalyzableFiles(absolutePath)
|
||||
console.error(`❌ Error: Directory does not contain a recognizable entry file: ${componentPath}`)
|
||||
if (availableFiles.length > 0) {
|
||||
console.error(`\n Available files to analyze:`)
|
||||
availableFiles.forEach(f => console.error(` - ${path.join(componentPath, f)}`))
|
||||
console.error(`\n Please specify the exact file path, e.g.:`)
|
||||
console.error(` pnpm analyze-component ${path.join(componentPath, availableFiles[0])}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Read source code
|
||||
const sourceCode = fs.readFileSync(absolutePath, 'utf-8')
|
||||
|
||||
// Analyze
|
||||
const analyzer = new ComponentAnalyzer()
|
||||
const analysis = analyzer.analyze(sourceCode, componentPath, absolutePath)
|
||||
|
||||
// Check if component is too complex - suggest refactoring instead of testing
|
||||
// Skip this check in JSON mode to always output analysis result
|
||||
if (!isReviewMode && !isJsonMode && (analysis.complexity > 75 || analysis.lineCount > 300)) {
|
||||
console.log(`
|
||||
╔════════════════════════════════════════════════════════════════════════════╗
|
||||
║ ⚠️ COMPONENT TOO COMPLEX TO TEST ║
|
||||
╚════════════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
📍 Component: ${analysis.name}
|
||||
📂 Path: ${analysis.path}
|
||||
|
||||
📊 Component Metrics:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Total Complexity: ${analysis.complexity}/100 ${analysis.complexity > 75 ? '🔴 TOO HIGH' : analysis.complexity > 50 ? '⚠️ WARNING' : '🟢 OK'}
|
||||
Max Func Complexity: ${analysis.maxComplexity}/100 ${analysis.maxComplexity > 75 ? '🔴 TOO HIGH' : analysis.maxComplexity > 50 ? '⚠️ WARNING' : '🟢 OK'}
|
||||
Lines: ${analysis.lineCount} ${analysis.lineCount > 300 ? '🔴 TOO LARGE' : '🟢 OK'}
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
🚫 RECOMMENDATION: REFACTOR BEFORE TESTING
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
This component is too complex to test effectively. Please consider:
|
||||
|
||||
1️⃣ **Split into smaller components**
|
||||
- Extract reusable UI sections into separate components
|
||||
- Separate business logic from presentation
|
||||
- Create smaller, focused components (< 300 lines each)
|
||||
|
||||
2️⃣ **Extract custom hooks**
|
||||
- Move state management logic to custom hooks
|
||||
- Extract complex data transformation logic
|
||||
- Separate API calls into dedicated hooks
|
||||
|
||||
3️⃣ **Simplify logic**
|
||||
- Reduce nesting depth
|
||||
- Break down complex conditions
|
||||
- Extract helper functions
|
||||
|
||||
4️⃣ **After refactoring**
|
||||
- Run this tool again on each smaller component
|
||||
- Generate tests for the refactored components
|
||||
- Tests will be easier to write and maintain
|
||||
|
||||
💡 TIP: Aim for components with:
|
||||
- Cognitive Complexity < 50/100 (preferably < 25/100)
|
||||
- Line count < 300 (preferably < 200)
|
||||
- Single responsibility principle
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Build prompt for AI assistant
|
||||
const builder = new TestPromptBuilder()
|
||||
const generationPrompt = builder.build(analysis)
|
||||
|
||||
let prompt = generationPrompt
|
||||
|
||||
if (isReviewMode) {
|
||||
const providedTestPath = args[1]
|
||||
const inferredTestPath = inferTestPath(componentPath)
|
||||
const testPath = providedTestPath ?? inferredTestPath
|
||||
const absoluteTestPath = path.resolve(process.cwd(), testPath)
|
||||
|
||||
if (!fs.existsSync(absoluteTestPath)) {
|
||||
console.error(`❌ Error: Test file not found: ${testPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const testCode = fs.readFileSync(absoluteTestPath, 'utf-8')
|
||||
const reviewBuilder = new TestReviewPromptBuilder()
|
||||
const originalPromptSection = extractCopyContent(generationPrompt)
|
||||
const normalizedTestPath = path.relative(process.cwd(), absoluteTestPath) || testPath
|
||||
|
||||
prompt = reviewBuilder.build({
|
||||
analysis,
|
||||
testPath: normalizedTestPath,
|
||||
testCode,
|
||||
originalPromptSection,
|
||||
})
|
||||
}
|
||||
|
||||
// JSON output mode
|
||||
if (isJsonMode) {
|
||||
console.log(JSON.stringify(analysis, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
// Output
|
||||
console.log(prompt)
|
||||
|
||||
try {
|
||||
const { spawnSync } = require('node:child_process')
|
||||
|
||||
const checkPbcopy = spawnSync('which', ['pbcopy'], { stdio: 'pipe' })
|
||||
if (checkPbcopy.status !== 0) return
|
||||
const copyContent = extractCopyContent(prompt)
|
||||
if (!copyContent) return
|
||||
|
||||
const result = spawnSync('pbcopy', [], {
|
||||
input: copyContent,
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
|
||||
if (result.status === 0) {
|
||||
console.log('\n📋 Prompt copied to clipboard!')
|
||||
console.log(' Paste it in your AI assistant:')
|
||||
console.log(' - Cursor: Cmd+L (Chat) or Cmd+I (Composer)')
|
||||
console.log(' - GitHub Copilot Chat: Cmd+I')
|
||||
console.log(' - Or any other AI coding tool\n')
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// pbcopy failed, but don't break the script
|
||||
}
|
||||
}
|
||||
|
||||
function inferTestPath(componentPath) {
|
||||
const ext = path.extname(componentPath)
|
||||
if (!ext) return `${componentPath}.spec.ts`
|
||||
return componentPath.replace(ext, `.spec${ext}`)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Run
|
||||
// ============================================================================
|
||||
|
||||
main()
|
||||
453
dify_1.11.1/web/testing/testing.md
Normal file
453
dify_1.11.1/web/testing/testing.md
Normal file
@@ -0,0 +1,453 @@
|
||||
# Frontend Testing Guide
|
||||
|
||||
This document is the complete testing specification for the Dify frontend project.
|
||||
Goal: Readable, change-friendly, reusable, and debuggable tests.
|
||||
When I ask you to write/refactor/fix tests, follow these rules by default.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Next.js 15 + React 19 + TypeScript
|
||||
- **Testing Tools**: Jest 29.7 + React Testing Library 16.0
|
||||
- **Test Environment**: @happy-dom/jest-environment
|
||||
- **File Naming**: `ComponentName.spec.tsx` (same directory as component)
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pnpm test
|
||||
|
||||
# Watch mode
|
||||
pnpm test -- --watch
|
||||
|
||||
# Generate coverage report
|
||||
pnpm test -- --coverage
|
||||
|
||||
# Run specific file
|
||||
pnpm test -- path/to/file.spec.tsx
|
||||
```
|
||||
|
||||
## Project Test Setup
|
||||
|
||||
- **Configuration**: `jest.config.ts` loads the Testing Library presets, sets the `@happy-dom/jest-environment`, and respects our path aliases (`@/...`). Check this file before adding new transformers or module name mappers.
|
||||
- **Global setup**: `jest.setup.ts` already imports `@testing-library/jest-dom` and runs `cleanup()` after every test. Add any environment-level mocks (for example `ResizeObserver`, `matchMedia`, `IntersectionObserver`, `TextEncoder`, `crypto`) here so they are shared consistently.
|
||||
- **Manual mocks**: Place reusable mocks inside `web/__mocks__/`. Use `jest.mock('module-name')` to point to these helpers rather than redefining mocks in every spec.
|
||||
- **Script utilities**: `web/testing/analyze-component.js` analyzes component complexity and generates test prompts for AI assistants. Commands:
|
||||
- `pnpm analyze-component <path>` - Analyze and generate test prompt
|
||||
- `pnpm analyze-component <path> --json` - Output analysis as JSON
|
||||
- `pnpm analyze-component <path> --review` - Generate test review prompt
|
||||
- `pnpm analyze-component --help` - Show help
|
||||
- **Integration suites**: Files in `web/__tests__/` exercise cross-component flows. Prefer adding new end-to-end style specs there rather than mixing them into component directories.
|
||||
|
||||
## Test Authoring Principles
|
||||
|
||||
- **Single behavior per test**: Each test verifies one user-observable behavior.
|
||||
- **Black-box first**: Assert external behavior and observable outputs, avoid internal implementation details.
|
||||
- **Semantic naming**: Use `should <behavior> when <condition>` and group related cases with `describe(<subject or scenario>)`.
|
||||
- **AAA / Given–When–Then**: Separate Arrange, Act, and Assert clearly with code blocks or comments.
|
||||
- **Minimal but sufficient assertions**: Keep only the expectations that express the essence of the behavior.
|
||||
- **Reusable test data**: Prefer test data builders or factories over hard-coded masses of data.
|
||||
- **De-flake**: Control time, randomness, network, concurrency, and ordering.
|
||||
- **Fast & stable**: Keep unit tests running in milliseconds; reserve integration tests for cross-module behavior with isolation.
|
||||
- **Structured describe blocks**: Organize tests with `describe` sections and add a brief comment before each block to explain the scenario it covers so readers can quickly understand the scope.
|
||||
|
||||
## Component Complexity Guidelines
|
||||
|
||||
Use `pnpm analyze-component <path>` to analyze component complexity and adopt different testing strategies based on the results.
|
||||
|
||||
### 🔴 Very Complex Components (Complexity > 50)
|
||||
|
||||
- **Refactor first**: Break component into smaller pieces
|
||||
- **Integration tests**: Test complex workflows end-to-end
|
||||
- **Data-driven tests**: Use `test.each()` for multiple scenarios
|
||||
- **Performance benchmarks**: Add performance tests for critical paths
|
||||
|
||||
### ⚠️ Complex Components (Complexity 30-50)
|
||||
|
||||
- **Multiple describe blocks**: Group related test cases
|
||||
- **Integration scenarios**: Test feature combinations
|
||||
- **Organized structure**: Keep tests maintainable
|
||||
|
||||
### 📏 Large Components (500+ lines)
|
||||
|
||||
- **Consider refactoring**: Split into smaller components if possible
|
||||
- **Section testing**: Test major sections separately
|
||||
- **Helper functions**: Reduce test complexity with utilities
|
||||
|
||||
## Basic Guidelines
|
||||
|
||||
- ✅ AAA pattern: Arrange (setup) → Act (execute) → Assert (verify)
|
||||
- ✅ Descriptive test names: `"should [behavior] when [condition]"`
|
||||
- ✅ TypeScript: No `any` types
|
||||
- ✅ **Cleanup**: `jest.clearAllMocks()` should be in `beforeEach()`, not `afterEach()`. This ensures mock call history is reset before each test, preventing test pollution when using assertions like `toHaveBeenCalledWith()` or `toHaveBeenCalledTimes()`.
|
||||
|
||||
**⚠️ Mock components must accurately reflect actual component behavior**, especially conditional rendering based on props or state.
|
||||
|
||||
**Rules**:
|
||||
|
||||
1. **Match actual conditional rendering**: If the real component returns `null` or doesn't render under certain conditions, the mock must do the same. Always check the actual component implementation before creating mocks.
|
||||
1. **Use shared state variables when needed**: When mocking components that depend on shared context or state (e.g., `PortalToFollowElem` with `PortalToFollowElemContent`), use module-level variables to track state and reset them in `beforeEach`.
|
||||
1. **Always reset shared mock state in beforeEach**: Module-level variables used in mocks must be reset in `beforeEach` to ensure test isolation, even if you set default values elsewhere.
|
||||
1. **Use fake timers only when needed**: Only use `jest.useFakeTimers()` if:
|
||||
- Testing components that use real `setTimeout`/`setInterval` (not mocked)
|
||||
- Testing time-based behavior (delays, animations)
|
||||
- If you mock all time-dependent functions, fake timers are unnecessary
|
||||
1. **Prefer importing over mocking project components**: When tests need other components from the project, import them directly instead of mocking them. Only mock external dependencies, APIs, or complex context providers that are difficult to set up.
|
||||
|
||||
**Why this matters**: Mocks that don't match actual behavior can lead to:
|
||||
|
||||
- **False positives**: Tests pass but code would fail in production
|
||||
- **Missed bugs**: Tests don't catch real conditional rendering issues
|
||||
- **Maintenance burden**: Tests become misleading documentation
|
||||
- **State leakage**: Tests interfere with each other when shared state isn't reset
|
||||
|
||||
## Testing Components with Dedicated Dependencies
|
||||
|
||||
When a component has dedicated dependencies (custom hooks, managers, utilities) that are **only used by that component**, use the following strategy to balance integration testing and unit testing.
|
||||
|
||||
### Summary Checklist
|
||||
|
||||
When testing components with dedicated dependencies:
|
||||
|
||||
- **Identify** which dependencies are dedicated vs. reusable
|
||||
- **Write integration tests** for component + dedicated dependencies together
|
||||
- **Write unit tests** for complex edge cases in dependencies
|
||||
- **Avoid mocking** dedicated dependencies in integration tests
|
||||
- **Use fake timers** if timing logic is involved
|
||||
- **Test user behavior**, not implementation details
|
||||
- **Document** the testing strategy in code comments
|
||||
- **Ensure** integration tests cover 100% of user-facing scenarios
|
||||
- **Reserve** unit tests for edge cases not practical in integration tests
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
Apply the following test scenarios based on component features:
|
||||
|
||||
### 1. Rendering Tests (REQUIRED - All Components)
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- Verify component renders properly
|
||||
- Check key elements exist
|
||||
- Use semantic queries (getByRole, getByLabelText)
|
||||
|
||||
### 2. Props Testing (REQUIRED - All Components)
|
||||
|
||||
Exercise the prop combinations that change observable behavior. Show how required props gate functionality, how optional props fall back to their defaults, and how invalid combinations surface through user-facing safeguards. Let TypeScript catch structural issues; keep runtime assertions focused on what the component renders or triggers.
|
||||
|
||||
### 3. State Management
|
||||
|
||||
Treat component state as part of the public behavior: confirm the initial render in context, execute the interactions or prop updates that move the state machine, and assert the resulting UI or side effects. Use `waitFor()`/async queries whenever transitions resolve asynchronously, and only check cleanup paths when they change what a user sees or experiences (duplicate events, lingering timers, etc.).
|
||||
|
||||
#### Context, Providers, and Stores
|
||||
|
||||
- ✅ Wrap components with the actual provider from `web/context` or `app/components/.../context` whenever practical.
|
||||
- ✅ When creating lightweight provider stubs, mirror the real default values and surface helper builders (for example `createMockWorkflowContext`).
|
||||
- ✅ Reset shared stores (React context, Zustand, TanStack Query cache) between tests to avoid leaking state. Prefer helper factory functions over module-level singletons in specs.
|
||||
- ✅ For hooks that read from context, use `renderHook` with a custom wrapper that supplies required providers.
|
||||
- ✅ **Use factory functions for mock data**: Import actual types and create factory functions with complete defaults (see [Test Data Builders](#9-test-data-builders-anti-hardcoding) section).
|
||||
- ✅ If it's need to mock some common context provider used across many components (for example, `ProviderContext`), put it in __mocks__/context(for example, `__mocks__/context/provider-context`). To dynamically control the mock behavior (for example, toggling plan type), use module-level variables to track state and change them(for example, `context/provider-context-mock.spec.tsx`).
|
||||
- ✅ Use factory functions to create mock data with TypeScript types. This ensures type safety and makes tests more maintainable.
|
||||
|
||||
**Rules**:
|
||||
|
||||
1. **Import actual types**: Always import types from the source (`@/models/`, `@/types/`, etc.) instead of defining inline types.
|
||||
1. **Provide complete defaults**: Factory functions should return complete objects with all required fields filled with sensible defaults.
|
||||
1. **Allow partial overrides**: Accept `Partial<T>` to enable flexible customization for specific test cases.
|
||||
1. **Create list factories**: For array data, create a separate factory function that composes item factories.
|
||||
1. **Reference**: See `__mocks__/provider-context.ts` for reusable context mock factories used across multiple test files.
|
||||
|
||||
### 4. Performance Optimization
|
||||
|
||||
Cover memoized callbacks or values only when they influence observable behavior—memoized children, subscription updates, expensive computations. Trigger realistic re-renders and assert the outcomes (avoided rerenders, reused results) instead of inspecting hook internals.
|
||||
|
||||
### 5. Event Handlers
|
||||
|
||||
Simulate the interactions that matter to users—primary clicks, change events, submits, and relevant keyboard shortcuts—and confirm the resulting behavior. When handlers prevent defaults or rely on bubbling, cover the scenarios where that choice affects the UI or downstream flows.
|
||||
|
||||
### 6. API Calls and Async Operations
|
||||
|
||||
**Must Test**:
|
||||
|
||||
- ✅ Mock all API calls using `jest.mock`
|
||||
- ✅ Test retry logic (if applicable)
|
||||
- ✅ Verify error handling and user feedback
|
||||
- ✅ Use `waitFor()` for async operations
|
||||
- ✅ For `@tanstack/react-query`, instantiate a fresh `QueryClient` per spec and wrap with `QueryClientProvider`
|
||||
- ✅ Clear timers, intervals, and pending promises between tests when using fake timers
|
||||
|
||||
**Guidelines**:
|
||||
|
||||
- Prefer spying on `global.fetch`/`axios`/`ky` and returning deterministic responses over reaching out to the network.
|
||||
- Use MSW (`msw` is already installed) when you need declarative request handlers across multiple specs.
|
||||
- Keep async assertions inside `await waitFor(...)` blocks or the async `findBy*` queries to avoid race conditions.
|
||||
|
||||
### 7. Next.js Routing
|
||||
|
||||
Mock the specific Next.js navigation hooks your component consumes (`useRouter`, `usePathname`, `useSearchParams`) and drive realistic routing flows—query parameters, redirects, guarded routes, URL updates—while asserting the rendered outcome or navigation side effects.
|
||||
|
||||
### 8. Edge Cases (REQUIRED - All Components)
|
||||
|
||||
**Must Test**:
|
||||
|
||||
- ✅ null/undefined/empty values
|
||||
- ✅ Boundary conditions
|
||||
- ✅ Error states
|
||||
- ✅ Loading states
|
||||
- ✅ Unexpected inputs
|
||||
|
||||
### 9. Test Data Builders (Anti-hardcoding)
|
||||
|
||||
For complex inputs/entities, use Builders with solid defaults and chainable overrides.
|
||||
|
||||
### 10. Accessibility Testing (Optional)
|
||||
|
||||
- Test keyboard navigation
|
||||
- Verify ARIA attributes
|
||||
- Test focus management
|
||||
- Ensure screen reader compatibility
|
||||
|
||||
### 11. Snapshot Testing (Use Sparingly)
|
||||
|
||||
Reserve snapshots for static, deterministic fragments (icons, badges, layout chrome). Keep them tight, prefer explicit assertions for behavior, and review any snapshot updates deliberately instead of accepting them wholesale.
|
||||
|
||||
**Note**: Dify is a desktop application. **No need for** responsive/mobile testing.
|
||||
|
||||
### 12. Mock API
|
||||
|
||||
Use Nock to mock API calls. Example:
|
||||
|
||||
```ts
|
||||
const mockGithubStar = (status: number, body: Record<string, unknown>, delayMs = 0) => {
|
||||
return nock(GITHUB_HOST).get(GITHUB_PATH).delay(delayMs).reply(status, body)
|
||||
}
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
### Example Structure
|
||||
|
||||
```typescript
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import Component from './index'
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@/service/api')
|
||||
|
||||
// Shared state for mocks (if needed)
|
||||
let mockSharedState = false
|
||||
|
||||
describe('ComponentName', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks() // ✅ Reset mocks before each test
|
||||
mockSharedState = false // ✅ Reset shared state if used in mocks
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange
|
||||
const props = { title: 'Test' }
|
||||
|
||||
// Act
|
||||
render(<Component {...props} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Test')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should handle click events', () => {
|
||||
const handleClick = jest.fn()
|
||||
render(<Component onClick={handleClick} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle null data', () => {
|
||||
render(<Component data={null} />)
|
||||
expect(screen.getByText(/no data/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Dify-Specific Components
|
||||
|
||||
### General
|
||||
|
||||
1. **i18n**: Always return key
|
||||
|
||||
```typescript
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
```
|
||||
|
||||
1. **Forms**: Test validation logic thoroughly
|
||||
|
||||
1. **Example - Correct mock with conditional rendering**:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT: Matches actual component behavior
|
||||
let mockPortalOpenState = false
|
||||
|
||||
jest.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open, ...props }: any) => {
|
||||
mockPortalOpenState = open || false // Update shared state
|
||||
return <div data-open={open}>{children}</div>
|
||||
},
|
||||
PortalToFollowElemContent: ({ children }: any) => {
|
||||
// ✅ Matches actual: returns null when open is false
|
||||
if (!mockPortalOpenState) return null
|
||||
return <div>{children}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
describe('Component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks() // ✅ Reset mock call history
|
||||
mockPortalOpenState = false // ✅ Reset shared state
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Workflow Components (`workflow/`)
|
||||
|
||||
**Must Test**:
|
||||
|
||||
- ⚙️ **Node configuration**: Test all node configuration options
|
||||
- ✔️ **Data validation**: Verify input/output validation rules
|
||||
- 🔄 **Variable passing**: Test data flow between nodes
|
||||
- 🔗 **Edge connections**: Test graph structure and connections
|
||||
- ❌ **Error handling**: Verify invalid configuration handling
|
||||
- 🧪 **Integration**: Test complete workflow execution paths
|
||||
|
||||
### Dataset Components (`dataset/`)
|
||||
|
||||
**Must Test**:
|
||||
|
||||
- 📤 **File upload**: Test file upload and validation
|
||||
- 📄 **File types**: Verify supported format handling
|
||||
- 📃 **Pagination**: Test data loading and pagination
|
||||
- 🔍 **Search & filtering**: Test query functionality
|
||||
- 📊 **Data format handling**: Test various data formats
|
||||
- ⚠️ **Error states**: Test upload failures and invalid data
|
||||
|
||||
### Configuration Components (`app/configuration`, `config/`)
|
||||
|
||||
**Must Test**:
|
||||
|
||||
- ✅ **Form validation**: Test all validation rules thoroughly
|
||||
- 💾 **Save/reset functionality**: Test data persistence
|
||||
- 🔒 **Required vs optional fields**: Verify field validation
|
||||
- 📌 **Configuration persistence**: Test state preservation
|
||||
- 💬 **Error feedback**: Verify user error messages
|
||||
- 🎯 **Default values**: Test initial configuration state
|
||||
|
||||
## Testing Strategy Quick Reference
|
||||
|
||||
### Required (All Components)
|
||||
|
||||
- ✅ Renders without crashing
|
||||
- ✅ Props (required, optional, defaults)
|
||||
- ✅ Edge cases (null, undefined, empty values)
|
||||
|
||||
### Conditional (When Present in Component)
|
||||
|
||||
- 🔄 **useState** → State initialization, transitions, cleanup
|
||||
- ⚡ **useEffect** → Execution, dependencies, cleanup
|
||||
- 🎯 **Event Handlers** → All onClick, onChange, onSubmit, keyboard events
|
||||
- 🌐 **API Calls** → Loading, success, error states
|
||||
- 🔀 **Routing** → Navigation, params, query strings
|
||||
- 🚀 **useCallback/useMemo** → Referential equality, dependencies
|
||||
- ⚙️ **Workflow** → Node config, data flow, validation
|
||||
- 📚 **Dataset** → Upload, pagination, search
|
||||
- 🎛️ **Configuration** → Form validation, persistence
|
||||
|
||||
### Complex Components (Complexity 30+)
|
||||
|
||||
- Group tests in multiple `describe` blocks
|
||||
- Test integration scenarios
|
||||
- Consider splitting component before testing
|
||||
|
||||
## Coverage Goals
|
||||
|
||||
### ⚠️ MANDATORY: Complete Coverage in Single Generation
|
||||
|
||||
Aim for 100% coverage:
|
||||
|
||||
- ✅ 100% function coverage (every exported function/method tested)
|
||||
- ✅ 100% statement coverage (every line executed)
|
||||
- ✅ >95% branch coverage (every if/else, switch case, ternary tested)
|
||||
- ✅ >95% line coverage
|
||||
|
||||
Generate comprehensive tests covering **all** code paths and scenarios.
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
### View Rendered DOM
|
||||
|
||||
```typescript
|
||||
import { screen } from '@testing-library/react'
|
||||
|
||||
// Print entire DOM
|
||||
screen.debug()
|
||||
|
||||
// Print specific element
|
||||
screen.debug(screen.getByRole('button'))
|
||||
```
|
||||
|
||||
### Finding Elements
|
||||
|
||||
Priority order (recommended top to bottom):
|
||||
|
||||
1. `getByRole` - Most recommended, follows accessibility standards
|
||||
1. `getByLabelText` - Form fields
|
||||
1. `getByPlaceholderText` - Only when no label
|
||||
1. `getByText` - Non-interactive elements
|
||||
1. `getByDisplayValue` - Current form value
|
||||
1. `getByAltText` - Images
|
||||
1. `getByTitle` - Last choice
|
||||
1. `getByTestId` - Only as last resort
|
||||
|
||||
### Async Debugging
|
||||
|
||||
```typescript
|
||||
// Wait for element to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Loaded')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Wait for element to disappear
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find async element
|
||||
const element = await screen.findByText('Async Content')
|
||||
```
|
||||
|
||||
## Reference Examples
|
||||
|
||||
Test examples in the project:
|
||||
|
||||
- [classnames.spec.ts](../utils/classnames.spec.ts) - Utility function tests
|
||||
- [index.spec.tsx](../app/components/base/button/index.spec.tsx) - Component tests
|
||||
|
||||
## Resources
|
||||
|
||||
- [Jest Documentation](https://jestjs.io/docs/getting-started)
|
||||
- [React Testing Library Documentation](https://testing-library.com/docs/react-testing-library/intro/)
|
||||
- [Testing Library Best Practices](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library)
|
||||
- [Jest Mock Functions](https://jestjs.io/docs/mock-functions)
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
**Remember**: Writing tests is not just about coverage, but ensuring code quality and maintainability. Good tests should be clear, concise, and meaningful.
|
||||
Reference in New Issue
Block a user