dify/web/scripts/analyze-component.js

649 lines
26 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* Component Analyzer for Test Generation
*
* Analyzes a component and generates a structured prompt for AI assistants.
* Works with Cursor, GitHub Copilot, and other AI coding tools.
*
* Usage:
* node scripts/analyze-component.js <component-path>
*
* Examples:
* node scripts/analyze-component.js app/components/base/button/index.tsx
* node scripts/analyze-component.js app/components/workflow/nodes/llm/panel.tsx
*
* For complete testing guidelines, see: web/scripts/TESTING.md
*/
const fs = require('node:fs')
const path = require('node:path')
// ============================================================================
// Simple Analyzer
// ============================================================================
class ComponentAnalyzer {
analyze(code, filePath) {
const fileName = path.basename(filePath, path.extname(filePath))
const complexity = this.calculateComplexity(code)
const lineCount = code.split('\n').length
// Count usage references (may take a few seconds)
const usageCount = this.countUsageReferences(filePath)
// 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'),
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('),
complexity,
lineCount,
usageCount,
priority,
}
}
detectType(filePath, code) {
if (filePath.includes('/hooks/')) return 'hook'
if (filePath.includes('/utils/')) return 'util'
if (filePath.includes('/page.tsx')) return 'page'
if (code.includes('useState') || code.includes('useEffect')) return 'component'
return 'component'
}
/**
* Calculate component complexity score
* Based on Cognitive Complexity + React-specific metrics
*
* Score Ranges:
* 0-10: 🟢 Simple (5-10 min to test)
* 11-30: 🟡 Medium (15-30 min to test)
* 31-50: 🟠 Complex (30-60 min to test)
* 51+: 🔴 Very Complex (60+ min, consider splitting)
*/
calculateComplexity(code) {
let score = 0
// ===== React Hooks (State Management Complexity) =====
const stateHooks = (code.match(/useState/g) || []).length
const effectHooks = (code.match(/useEffect/g) || []).length
const callbackHooks = (code.match(/useCallback/g) || []).length
const memoHooks = (code.match(/useMemo/g) || []).length
const refHooks = (code.match(/useRef/g) || []).length
const customHooks = (code.match(/use[A-Z]\w+/g) || []).length
- (stateHooks + effectHooks + callbackHooks + memoHooks + refHooks)
score += stateHooks * 5 // Each state +5 (need to test state changes)
score += effectHooks * 6 // Each effect +6 (need to test deps & cleanup)
score += callbackHooks * 2 // Each callback +2
score += memoHooks * 2 // Each memo +2
score += refHooks * 1 // Each ref +1
score += customHooks * 3 // Each custom hook +3
// ===== Control Flow Complexity (Cyclomatic Complexity) =====
score += (code.match(/if\s*\(/g) || []).length * 2 // if statement
score += (code.match(/else\s+if/g) || []).length * 2 // else if
score += (code.match(/\?\s*[^:]+\s*:/g) || []).length * 1 // ternary operator
score += (code.match(/switch\s*\(/g) || []).length * 3 // switch
score += (code.match(/case\s+/g) || []).length * 1 // case branch
score += (code.match(/&&/g) || []).length * 1 // logical AND
score += (code.match(/\|\|/g) || []).length * 1 // logical OR
score += (code.match(/\?\?/g) || []).length * 1 // nullish coalescing
// ===== Loop Complexity =====
score += (code.match(/\.map\(/g) || []).length * 2 // map
score += (code.match(/\.filter\(/g) || []).length * 1 // filter
score += (code.match(/\.reduce\(/g) || []).length * 3 // reduce (complex)
score += (code.match(/for\s*\(/g) || []).length * 2 // for loop
score += (code.match(/while\s*\(/g) || []).length * 3 // while loop
// ===== Props and Events Complexity =====
const propsMatches = code.match(/(\w+)\s*:\s*\w+/g) || []
const propsCount = Math.min(propsMatches.length, 20) // Max 20 props
score += Math.floor(propsCount / 2) // Every 2 props +1
const eventHandlers = (code.match(/on[A-Z]\w+/g) || []).length
score += eventHandlers * 2 // Each event handler +2
// ===== API Call Complexity =====
score += (code.match(/fetch\(/g) || []).length * 4 // fetch
score += (code.match(/axios\./g) || []).length * 4 // axios
score += (code.match(/useSWR/g) || []).length * 4 // SWR
score += (code.match(/useQuery/g) || []).length * 4 // React Query
score += (code.match(/\.then\(/g) || []).length * 2 // Promise
score += (code.match(/await\s+/g) || []).length * 2 // async/await
// ===== Third-party Library Integration =====
const hasReactFlow = /reactflow|ReactFlow/.test(code)
const hasMonaco = /@monaco-editor/.test(code)
const hasEcharts = /echarts/.test(code)
const hasLexical = /lexical/.test(code)
if (hasReactFlow) score += 15 // ReactFlow is very complex
if (hasMonaco) score += 12 // Monaco Editor
if (hasEcharts) score += 8 // Echarts
if (hasLexical) score += 10 // Lexical Editor
// ===== Code Size Complexity =====
const lines = code.split('\n').length
if (lines > 500) score += 10
else if (lines > 300) score += 6
else if (lines > 150) score += 3
// ===== Nesting Depth (deep nesting reduces readability) =====
const maxNesting = this.calculateNestingDepth(code)
score += Math.max(0, (maxNesting - 3)) * 2 // Over 3 levels, +2 per level
// ===== Context and Global State =====
score += (code.match(/useContext/g) || []).length * 3
score += (code.match(/useStore|useAppStore/g) || []).length * 4
score += (code.match(/zustand|redux/g) || []).length * 3
return Math.min(score, 100) // Max 100 points
}
/**
* Calculate maximum nesting depth
*/
calculateNestingDepth(code) {
let maxDepth = 0
let currentDepth = 0
for (let i = 0; i < code.length; i++) {
if (code[i] === '{') {
currentDepth++
maxDepth = Math.max(maxDepth, currentDepth)
}
else if (code[i] === '}') {
currentDepth--
}
}
return maxDepth
}
/**
* Count how many times a component is referenced in the codebase
* Uses grep for searching import statements
*/
countUsageReferences(filePath) {
try {
const { execSync } = require('node:child_process')
// Get component name from file path
const fileName = path.basename(filePath, path.extname(filePath))
// If the file is index.tsx, use the parent directory name as the component name
// e.g., app/components/base/avatar/index.tsx -> search for 'avatar'
// Otherwise use the file name
// e.g., app/components/base/button.tsx -> search for 'button'
let searchName = fileName
if (fileName === 'index') {
const parentDir = path.dirname(filePath)
searchName = path.basename(parentDir)
}
// Build search pattern for import statements
// Match: from '@/app/components/base/avatar'
// Match: from './avatar'
// Match: from '../avatar'
// Simplified pattern to avoid shell quote issues
const searchPattern = `/${searchName}'`
// Use grep to search across all TypeScript files
// -r: recursive
// -l: list files with matches only
// --include: only .ts and .tsx files
// --exclude: exclude test and story files
const grepCommand = `grep -rl --include="*.ts" --include="*.tsx" --exclude="*.spec.ts" --exclude="*.spec.tsx" --exclude="*.test.ts" --exclude="*.test.tsx" --exclude="*.stories.tsx" "${searchPattern}" app/ 2>/dev/null | wc -l`
// eslint-disable-next-line sonarjs/os-command
const result = execSync(grepCommand, {
cwd: process.cwd(),
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore'],
})
return Number.parseInt(result.trim(), 10) || 0
}
catch {
// If command fails, return 0
return 0
}
}
/**
* Calculate test priority based on complexity and usage
*
* Priority Score = Complexity Score + Usage Score
* - Complexity: 0-100
* - Usage: 0-50
* - Total: 0-150
*
* Priority Levels:
* - 0-30: Low
* - 31-70: Medium
* - 71-100: High
* - 100+: Critical
*/
calculateTestPriority(complexity, usageCount) {
const complexityScore = complexity
// Usage score calculation
let usageScore
if (usageCount === 0)
usageScore = 0
else if (usageCount <= 5)
usageScore = 10
else if (usageCount <= 20)
usageScore = 20
else if (usageCount <= 50)
usageScore = 35
else
usageScore = 50
const totalScore = complexityScore + usageScore
return {
score: totalScore,
level: this.getPriorityLevel(totalScore),
usageScore,
complexityScore,
}
}
/**
* Get priority level based on score
*/
getPriorityLevel(score) {
if (score > 100) return '🔴 CRITICAL'
if (score > 70) return '🟠 HIGH'
if (score > 30) return '🟡 MEDIUM'
return '🟢 LOW'
}
}
// ============================================================================
// Prompt Builder for AI Assistants
// ============================================================================
class TestPromptBuilder {
build(analysis, _sourceCode) {
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}
Complexity: ${analysis.complexity} ${this.getComplexityLevel(analysis.complexity)}
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)
${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
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📝 TASK:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Please generate a comprehensive test file for this component at:
${testPath}
The component is located at:
${analysis.path}
Follow the testing guidelines in:
- web/scripts/TESTING.md (complete testing guide)
- .cursorrules (quick reference for Cursor users)
${this.getSpecificGuidelines(analysis)}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📋 PROMPT FOR AI ASSISTANT (COPY THIS TO YOUR AI ASSISTANT):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Generate a comprehensive test file for @${analysis.path} following the project's testing guidelines in web/scripts/TESTING.md.
Including but not limited to:
${this.buildFocusPoints(analysis)}
Create the test file at: ${testPath}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
`
}
getComplexityLevel(score) {
if (score < 10) return '🟢 Simple'
if (score < 30) return '🟡 Medium'
return '🔴 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')
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 > 50) {
guidelines.push('🔴 VERY COMPLEX component detected. 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')
guidelines.push(' - Adding performance benchmarks')
}
else if (analysis.complexity > 30) {
guidelines.push('⚠️ This is a COMPLEX component. Consider:')
guidelines.push(' - Breaking tests into multiple describe blocks')
guidelines.push(' - Testing integration scenarios')
guidelines.push(' - Grouping related test cases')
}
// ===== 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) {
const features = []
if (analysis.hasCallbacks) features.push('useCallback')
if (analysis.hasMemo) features.push('useMemo')
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')
}
// ===== API Calls =====
if (analysis.hasAPI) {
guidelines.push('🌐 API calls detected:')
guidelines.push(' - Mock all API calls using jest.mock')
guidelines.push(' - Test retry logic if applicable')
guidelines.push(' - Verify error handling and user feedback')
}
// ===== 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` : ''
}
}
// ============================================================================
// Main Function
// ============================================================================
function main() {
const args = process.argv.slice(2)
if (args.length === 0) {
console.error(`
❌ Error: Component path is required
Usage:
node scripts/analyze-component.js <component-path>
Examples:
node scripts/analyze-component.js app/components/base/button/index.tsx
node scripts/analyze-component.js app/components/workflow/nodes/llm/panel.tsx
This tool analyzes your component and generates a prompt for AI assistants.
Copy the output and use it with:
- Cursor (Cmd+L for Chat, Cmd+I for Composer)
- GitHub Copilot Chat (Cmd+I)
- Claude, ChatGPT, or any other AI coding tool
For complete testing guidelines, see: web/scripts/TESTING.md
`)
process.exit(1)
}
const componentPath = args[0]
const absolutePath = path.resolve(process.cwd(), componentPath)
// Check if file exists
if (!fs.existsSync(absolutePath)) {
console.error(`❌ Error: File not found: ${componentPath}`)
process.exit(1)
}
// Read source code
const sourceCode = fs.readFileSync(absolutePath, 'utf-8')
// Analyze
const analyzer = new ComponentAnalyzer()
const analysis = analyzer.analyze(sourceCode, componentPath)
// Check if component is too complex - suggest refactoring instead of testing
if (analysis.complexity > 50 || analysis.lineCount > 300) {
console.log(`
╔════════════════════════════════════════════════════════════════════════════╗
║ ⚠️ COMPONENT TOO COMPLEX TO TEST ║
╚════════════════════════════════════════════════════════════════════════════╝
📍 Component: ${analysis.name}
📂 Path: ${analysis.path}
📊 Component Metrics:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Complexity: ${analysis.complexity} ${analysis.complexity > 50 ? '🔴 TOO HIGH' : '⚠️ WARNING'}
Lines: ${analysis.lineCount} ${analysis.lineCount > 300 ? '🔴 TOO LARGE' : '⚠️ WARNING'}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🚫 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:
- Complexity score < 30 (preferably < 20)
- Line count < 300 (preferably < 200)
- Single responsibility principle
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
`)
process.exit(0)
}
// Build prompt for AI assistant
const builder = new TestPromptBuilder()
const prompt = builder.build(analysis, sourceCode)
// Output
console.log(prompt)
try {
const { spawnSync } = require('node:child_process')
const checkPbcopy = spawnSync('which', ['pbcopy'], { stdio: 'pipe' })
if (checkPbcopy.status !== 0) return
const parts = prompt.split('📋 COPY THIS TO CURSOR:')
if (parts.length < 2) return
const afterMarker = parts[1]
const lines = afterMarker.split('\n')
const startIdx = lines.findIndex(line => line.includes('━━━')) + 1
const endIdx = lines.findIndex((line, idx) => idx > startIdx && line.includes('━━━'))
if (startIdx === 0 || endIdx === -1) return
const copyContent = lines.slice(startIdx, endIdx).join('\n').trim()
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
}
}
// ============================================================================
// Run
// ============================================================================
main()