Files
urbanLifeline/dify/web/testing/analyze-component.js
2025-12-01 17:21:38 +08:00

1058 lines
40 KiB
JavaScript
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
const fs = require('node:fs')
const path = require('node:path')
// ============================================================================
// 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
const complexity = this.calculateComplexity(code, lineCount)
// 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,
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 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, lineCount) {
let score = 0
const count = pattern => this.countMatches(code, pattern)
// ===== React Hooks (State Management Complexity) =====
const stateHooks = count(/useState/g)
const reducerHooks = count(/useReducer/g)
const effectHooks = count(/useEffect/g)
const callbackHooks = count(/useCallback/g)
const memoHooks = count(/useMemo/g)
const refHooks = count(/useRef/g)
const imperativeHandleHooks = count(/useImperativeHandle/g)
const builtinHooks = stateHooks + reducerHooks + effectHooks
+ callbackHooks + memoHooks + refHooks + imperativeHandleHooks
const totalHooks = count(/use[A-Z]\w+/g)
const customHooks = Math.max(0, totalHooks - builtinHooks)
score += stateHooks * 5 // Each state +5 (need to test state changes)
score += reducerHooks * 6 // Each reducer +6 (complex state management)
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 += imperativeHandleHooks * 4 // Each imperative handle +4 (exposes methods)
score += customHooks * 3 // Each custom hook +3
// ===== Control Flow Complexity (Cyclomatic Complexity) =====
score += count(/if\s*\(/g) * 2 // if statement
score += count(/else\s+if/g) * 2 // else if
score += count(/\?\s*[^:]+\s*:/g) * 1 // ternary operator
score += count(/switch\s*\(/g) * 3 // switch
score += count(/case\s+/g) * 1 // case branch
score += count(/&&/g) * 1 // logical AND
score += count(/\|\|/g) * 1 // logical OR
score += count(/\?\?/g) * 1 // nullish coalescing
// ===== Loop Complexity =====
score += count(/\.map\(/g) * 2 // map
score += count(/\.filter\(/g) * 1 // filter
score += count(/\.reduce\(/g) * 3 // reduce (complex)
score += count(/for\s*\(/g) * 2 // for loop
score += count(/while\s*\(/g) * 3 // while loop
// ===== Props and Events Complexity =====
// Count unique props from interface/type definitions only (avoid duplicates)
const propsCount = this.countUniqueProps(code)
score += Math.floor(propsCount / 2) // Every 2 props +1
// Count unique event handler names (avoid duplicates from type defs, params, usage)
const uniqueEventHandlers = this.countUniqueEventHandlers(code)
score += uniqueEventHandlers * 2 // Each unique event handler +2
// ===== API Call Complexity =====
score += count(/fetch\(/g) * 4 // fetch
score += count(/axios\./g) * 4 // axios
score += count(/useSWR/g) * 4 // SWR
score += count(/useQuery/g) * 4 // React Query
score += count(/\.then\(/g) * 2 // Promise
score += count(/await\s+/g) * 2 // async/await
// ===== Third-party Library Integration =====
// Only count complex UI libraries that require integration testing
// Data fetching libs (swr, react-query, ahooks) don't add complexity
// because they are already well-tested; we only need to mock them
const complexUILibs = [
{ pattern: /reactflow|ReactFlow/, weight: 15 },
{ pattern: /@monaco-editor/, weight: 12 },
{ pattern: /echarts/, weight: 8 },
{ pattern: /lexical/, weight: 10 },
]
complexUILibs.forEach(({ pattern, weight }) => {
if (pattern.test(code)) score += weight
})
// ===== Code Size Complexity =====
if (lineCount > 500) score += 10
else if (lineCount > 300) score += 6
else if (lineCount > 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 += count(/useContext/g) * 3
score += count(/useStore|useAppStore/g) * 4
score += count(/zustand|redux/g) * 3
// ===== React Advanced Features =====
score += count(/React\.memo|memo\(/g) * 2 // Component memoization
score += count(/forwardRef/g) * 3 // Ref forwarding
score += count(/Suspense/g) * 4 // Suspense boundaries
score += count(/\blazy\(/g) * 3 // Lazy loading
score += count(/createPortal/g) * 3 // Portal rendering
return Math.min(score, 100) // Max 100 points
}
/**
* Calculate maximum nesting depth
*/
calculateNestingDepth(code) {
let maxDepth = 0
let currentDepth = 0
let inString = false
let stringChar = ''
let escapeNext = false
let inSingleLineComment = false
let inMultiLineComment = false
for (let i = 0; i < code.length; i++) {
const char = code[i]
const nextChar = code[i + 1]
if (inSingleLineComment) {
if (char === '\n') inSingleLineComment = false
continue
}
if (inMultiLineComment) {
if (char === '*' && nextChar === '/') {
inMultiLineComment = false
i++
}
continue
}
if (inString) {
if (escapeNext) {
escapeNext = false
continue
}
if (char === '\\') {
escapeNext = true
continue
}
if (char === stringChar) {
inString = false
stringChar = ''
}
continue
}
if (char === '/' && nextChar === '/') {
inSingleLineComment = true
i++
continue
}
if (char === '/' && nextChar === '*') {
inMultiLineComment = true
i++
continue
}
if (char === '"' || char === '\'' || char === '`') {
inString = true
stringChar = char
continue
}
if (char === '{') {
currentDepth++
maxDepth = Math.max(maxDepth, currentDepth)
continue
}
if (char === '}') {
currentDepth = Math.max(currentDepth - 1, 0)
}
}
return maxDepth
}
/**
* 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
}
countMatches(code, pattern) {
const matches = code.match(pattern)
return matches ? matches.length : 0
}
/**
* Count unique props from interface/type definitions
* Only counts props defined in type/interface blocks, not usage
*/
countUniqueProps(code) {
const uniqueProps = new Set()
// Match interface or type definition blocks
const typeBlockPattern = /(?:interface|type)\s+\w*Props[^{]*\{([^}]+)\}/g
let match
while ((match = typeBlockPattern.exec(code)) !== null) {
const blockContent = match[1]
// Match prop names (word followed by optional ? and :)
const propPattern = /(\w+)\s*\??:/g
let propMatch
while ((propMatch = propPattern.exec(blockContent)) !== null) {
uniqueProps.add(propMatch[1])
}
}
return Math.min(uniqueProps.size, 20) // Max 20 props
}
/**
* Count unique event handler names (on[A-Z]...)
* Avoids counting the same handler multiple times across type defs, params, and usage
*/
countUniqueEventHandlers(code) {
const uniqueHandlers = new Set()
const pattern = /on[A-Z]\w+/g
let match
while ((match = pattern.exec(code)) !== null) {
uniqueHandlers.add(match[0])
}
return uniqueHandlers.size
}
static escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* 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) {
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/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) {
// Aligned with testing.md guidelines
if (score <= 10) return '🟢 Simple'
if (score <= 30) return '🟡 Medium'
if (score <= 50) 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 > 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 || 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
// ============================================================================
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 index file
if (fs.statSync(absolutePath).isDirectory()) {
const indexFiles = ['index.tsx', 'index.ts', 'index.jsx', 'index.js']
let found = false
for (const indexFile of indexFiles) {
const indexPath = path.join(absolutePath, indexFile)
if (fs.existsSync(indexPath)) {
absolutePath = indexPath
componentPath = path.join(componentPath, indexFile)
found = true
break
}
}
if (!found) {
console.error(`❌ Error: Directory does not contain index file: ${componentPath}`)
console.error(` Expected one of: ${indexFiles.join(', ')}`)
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 > 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 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()