diff --git a/web/package.json b/web/package.json index abc0914469..6fc7ba0421 100644 --- a/web/package.json +++ b/web/package.json @@ -37,6 +37,7 @@ "check:i18n-types": "node ./i18n-config/check-i18n-sync.js", "test": "jest", "test:watch": "jest --watch", + "test:gen": "node scripts/analyze-component.js", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "preinstall": "npx only-allow pnpm", diff --git a/web/scripts/analyze-component.js b/web/scripts/analyze-component.js new file mode 100755 index 0000000000..a07b6665c6 --- /dev/null +++ b/web/scripts/analyze-component.js @@ -0,0 +1,353 @@ +#!/usr/bin/env node +/** + * Component Analyzer for Cursor AI + * + * Analyzes a component and generates a structured prompt for Cursor AI. + * Copy the output and paste into Cursor Chat (Cmd+L) or Composer (Cmd+I). + * + * Usage: + * node scripts/analyze-component.js + * + * Examples: + * node scripts/analyze-component.js app/components/base/button/index.tsx + * node scripts/analyze-component.js app/components/workflow/nodes/llm/panel.tsx + */ + +const fs = require('fs'); +const path = require('path'); + +// ============================================================================ +// Simple Analyzer +// ============================================================================ + +class ComponentAnalyzer { + analyze(code, filePath) { + const fileName = path.basename(filePath, path.extname(filePath)); + + 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), + hasI18n: code.includes('useTranslation') || code.includes('react-i18next'), + hasRouter: code.includes('useRouter') || code.includes('usePathname'), + hasAPI: code.includes('service/') || code.includes('fetch('), + isClientComponent: code.includes("'use client'") || code.includes('"use client"'), + complexity: this.calculateComplexity(code), + lineCount: code.split('\n').length, + }; + } + + 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; + } +} + +// ============================================================================ +// Prompt Builder for Cursor +// ============================================================================ + +class CursorPromptBuilder { + 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} + +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.hasI18n ? 'āœ“' : 'āœ—'} Internationalization + ${analysis.hasRouter ? 'āœ“' : 'āœ—'} Next.js routing + ${analysis.hasAPI ? 'āœ“' : 'āœ—'} API calls + ${analysis.isClientComponent ? 'āœ“' : 'āœ—'} Client component +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +šŸ“ TASK FOR CURSOR AI: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Please generate a comprehensive test file for this component at: + ${testPath} + +The component is located at: + ${analysis.path} + +Follow the testing guidelines in .cursorrules file. + +${this.getSpecificGuidelines(analysis)} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +šŸ“‹ COPY THIS TO CURSOR: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Generate a comprehensive test file for @${analysis.path} following the project's testing guidelines in .cursorrules. + +Focus on: +${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.hasProps) points.push('- Testing all prop variations'); + if (analysis.hasState) points.push('- Testing state management and updates'); + if (analysis.hasEffects) points.push('- Testing side effects and cleanup'); + if (analysis.hasEvents) points.push('- Testing user interactions and event handlers'); + if (analysis.hasI18n) points.push('- Mocking i18n (useTranslation)'); + if (analysis.hasRouter) points.push('- Mocking Next.js router hooks'); + if (analysis.hasAPI) points.push('- Mocking API calls'); + + points.push('- Testing accessibility (ARIA attributes)'); + points.push('- Testing edge cases and error handling'); + + return points.join('\n'); + } + + getSpecificGuidelines(analysis) { + const guidelines = []; + + 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(' - Adding performance tests'); + } + + if (analysis.hasAPI) { + guidelines.push('🌐 This component makes API calls:'); + guidelines.push(' - Mock all API calls using jest.mock'); + guidelines.push(' - Test loading, success, and error states'); + guidelines.push(' - Test retry logic if applicable'); + } + + if (analysis.hasRouter) { + guidelines.push('šŸ”€ This component uses routing:'); + guidelines.push(' - Mock useRouter, usePathname, useSearchParams'); + guidelines.push(' - Test navigation behavior'); + guidelines.push(' - Test URL parameter handling'); + } + + if (analysis.path.includes('workflow')) { + guidelines.push('āš™ļø This is a Workflow component:'); + guidelines.push(' - Test node configuration'); + guidelines.push(' - Test data validation'); + guidelines.push(' - Test variable passing'); + } + + 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 + +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 Cursor AI. +Copy the output and use it in Cursor Chat (Cmd+L) or Composer (Cmd+I). + `); + 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); + + // Build prompt for Cursor + const builder = new CursorPromptBuilder(); + const prompt = builder.build(analysis, sourceCode); + + // Output + console.log(prompt); + + // Also save to clipboard if pbcopy is available (macOS) + try { + const { execSync } = require('child_process'); + execSync('which pbcopy', { stdio: 'ignore' }); + + // Save prompt to clipboard + execSync('pbcopy', { input: prompt.split('šŸ“‹ COPY THIS TO CURSOR:')[1].split('━━━')[0].trim() }); + console.log('\nšŸ“‹ Prompt copied to clipboard! Paste it in Cursor Chat (Cmd+L).\n'); + } catch { + // pbcopy not available, skip + } +} + +// ============================================================================ +// Run +// ============================================================================ + +main(); +