mirror of https://github.com/langgenius/dify.git
feat: add component analysis script and test generation command in package.json
This commit is contained in:
parent
f45c18ee35
commit
0831fc8aba
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 <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
|
||||
*/
|
||||
|
||||
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 <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 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();
|
||||
|
||||
Loading…
Reference in New Issue