From 23bb7df43e363e68bab40cead46ab96295d527a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9C=E6=B6=B5=E7=85=A6?= Date: Thu, 30 Oct 2025 14:31:35 +0800 Subject: [PATCH] docs: update frontend testing guidelines and add new testing resources including Copilot instructions and Windsurf testing rules --- .cursorrules | 76 +--- .github/copilot-instructions.md | 13 + .windsurf/rules/testing.md | 11 + CONTRIBUTING.md | 2 +- web/package.json | 2 +- web/scripts/README.md | 111 +---- web/{scripts => testing}/analyze-component.js | 391 ++++++++++++------ .../TESTING.md => testing/testing.md} | 35 +- 8 files changed, 346 insertions(+), 295 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .windsurf/rules/testing.md rename web/{scripts => testing}/analyze-component.js (69%) rename web/{scripts/TESTING.md => testing/testing.md} (83%) diff --git a/.cursorrules b/.cursorrules index ef53fc5d09..680c1816f5 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,69 +1,13 @@ # Cursor Rules for Dify Project -## Frontend Testing Guidelines +## Automated Test Generation -> **📖 Complete Testing Documentation**: For detailed testing specifications and guidelines, see [`web/scripts/TESTING.md`](./web/scripts/TESTING.md) - -When generating tests for React components: - -### Key Requirements - -1. **Tech Stack**: Next.js 15 + React 19 + TypeScript + Jest + React Testing Library -2. **File Naming**: `ComponentName.spec.tsx` (same directory as component) -3. **Code Style**: - - Use `fireEvent` (not `userEvent`) - - AAA pattern (Arrange → Act → Assert) - - Test names: `"should [behavior] when [condition]"` - - No `any` types - - Cleanup after each test: `afterEach(() => jest.clearAllMocks())` - -### Required Tests (All Components) - -- ✅ Renders without crashing -- ✅ Props (required, optional, defaults) -- ✅ Edge cases (null, undefined, empty values) - -### Conditional Tests (When Present in Component) - -- 🔄 **useState** → State initialization, transitions, cleanup -- ⚡ **useEffect** → Execution, dependencies, cleanup -- 🎯 **Event Handlers** → onClick, onChange, onSubmit, keyboard events -- 🌐 **API Calls** → Loading, success, error states -- 🔀 **Next.js Routing** → Navigation, params, query strings -- 🚀 **useCallback/useMemo** → Referential equality, dependencies -- ⚙️ **Workflow Components** → Node config, data flow, validation -- 📚 **Dataset Components** → Upload, pagination, search -- 🎛️ **Configuration Components** → Form validation, persistence - -### Component Complexity Strategy - -- **Complexity > 50**: Refactor before testing, use integration tests and `test.each()` -- **Complexity 30-50**: Use multiple `describe` blocks to group tests -- **500+ lines**: Consider splitting component - -### Coverage Goals - -Aim for 100% coverage: -- Line coverage: >95% -- Branch coverage: >95% -- Function coverage: 100% -- Statement coverage: 100% - -### Dify-Specific Mocks - -```typescript - -// Toast -jest.mock('@/app/components/base/toast') - -// Next.js Router -jest.mock('next/navigation', () => ({ - useRouter: jest.fn(), - usePathname: jest.fn(), - useSearchParams: jest.fn(), -})) -``` - ---- - -**Important**: The above is a quick reference only. When generating tests, strictly follow the complete specifications in [`web/scripts/TESTING.md`](./web/scripts/TESTING.md), including detailed examples, best practices, and FAQs. +- Use `web/testing/TESTING.md` as the canonical instruction set for generating frontend automated tests. +- When proposing or saving tests, re-read that document and follow every requirement. +- Quick reference: + - Tech stack: Next.js 15, React 19, TypeScript, Jest 29.7, React Testing Library 16.0, `@happy-dom/jest-environment` + - Test files: `ComponentName.spec.tsx` in the same directory as the component + - Style: Arrange → Act → Assert, descriptive names (`"should [behavior] when [condition]"`), use `fireEvent`, clear mocks in `afterEach` + - Baseline coverage: rendering, all prop permutations, `null`/`undefined`/empty edge cases + - Conditional coverage: hook state/effects, event handlers, async flows, routing, performance hooks, and domain-specific workflows/configuration/dataset behaviors + - Target coverage: line & branch >95%, function & statement 100% diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..adc53b253c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,13 @@ +# Copilot Instructions + +GitHub Copilot must follow the unified frontend testing requirements documented in `web/testing/TESTING.md`. + +Key reminders: + +- Generate tests using the mandated tech stack, naming, and code style (AAA pattern, `fireEvent`, descriptive test names, cleans up mocks). +- Cover rendering, prop combinations, and edge cases by default; extend coverage for hooks, routing, async flows, and domain-specific components when applicable. +- Target >95% line and branch coverage and 100% function/statement coverage. +- Apply the project's mocking conventions for i18n, toast notifications, and Next.js utilities. + +Any suggestions from Copilot that conflict with `web/testing/TESTING.md` should be revised before acceptance. + diff --git a/.windsurf/rules/testing.md b/.windsurf/rules/testing.md new file mode 100644 index 0000000000..c1800049d5 --- /dev/null +++ b/.windsurf/rules/testing.md @@ -0,0 +1,11 @@ +# Windsurf Testing Rules + +- Use `web/testing/TESTING.md` as the single source of truth for frontend automated testing. +- Honor every requirement in that document when generating or accepting tests. +- Key reminders: + - Tech stack: Next.js 15, React 19, TypeScript, Jest 29.7, React Testing Library 16.0, `@happy-dom/jest-environment` + - File naming: `ComponentName.spec.tsx` next to the component + - Structure: Arrange → Act → Assert, descriptive test names, `fireEvent`, `afterEach(() => jest.clearAllMocks())` + - Mandatory coverage: rendering, prop variants, `null`/`undefined`/empty edge cases + - Conditional coverage: hooks, handlers, async/API flows, routing, performance hooks, workflow/dataset/config components + - Coverage goals: line & branch >95%, function & statement 100% diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 570bd15bc3..20a7d6c6f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,7 +77,7 @@ How we prioritize: For setting up the frontend service, please refer to our comprehensive [guide](https://github.com/langgenius/dify/blob/main/web/README.md) in the `web/README.md` file. This document provides detailed instructions to help you set up the frontend environment properly. -**Testing**: All React components must have comprehensive test coverage. See [web/scripts/TESTING.md](https://github.com/langgenius/dify/blob/main/web/scripts/TESTING.md) for detailed testing guidelines. +**Testing**: All React components must have comprehensive test coverage. See [web/testing/testing.md](https://github.com/langgenius/dify/blob/main/web/testing/testing.md) for the canonical frontend testing guidelines and follow every requirement described there. #### Backend diff --git a/web/package.json b/web/package.json index af4338990f..7ede206ca7 100644 --- a/web/package.json +++ b/web/package.json @@ -37,7 +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", + "test:gen": "node testing/analyze-component.js", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "preinstall": "npx only-allow pnpm", diff --git a/web/scripts/README.md b/web/scripts/README.md index 1e33f80fba..2c575a244c 100644 --- a/web/scripts/README.md +++ b/web/scripts/README.md @@ -1,107 +1,38 @@ -# Web Scripts +# Production Build Optimization Scripts -Frontend development utility scripts. +## optimize-standalone.js -## 📋 Scripts +This script removes unnecessary development dependencies from the Next.js standalone build output to reduce the production Docker image size. -- `generate-icons.js` - Generate PWA icons -- `optimize-standalone.js` - Optimize build output -- `analyze-component.js` - **Test generation helper** ⭐ +### What it does -______________________________________________________________________ +The script specifically targets and removes `jest-worker` packages that are bundled with Next.js but not needed in production. These packages are included because: -## 🚀 Generate Tests (Using AI Assistants) +1. Next.js includes jest-worker in its compiled dependencies +1. terser-webpack-plugin (used by Next.js for minification) depends on jest-worker +1. pnpm's dependency resolution creates symlinks to jest-worker in various locations -### Quick Start +### Usage + +The script is automatically run during Docker builds via the `build:docker` npm script: ```bash -# 1. Analyze component -pnpm test:gen app/components/base/button/index.tsx - -# Output: Component analysis + AI prompt (auto-copied to clipboard) - -# 2. Paste in your AI assistant: -# - Cursor: Cmd+L (Chat) or Cmd+I (Composer) → Cmd+V → Enter -# - GitHub Copilot Chat: Cmd+I → Cmd+V → Enter -# - Claude/ChatGPT: Paste the prompt directly - -# 3. Apply the generated test and verify -pnpm test app/components/base/button/index.spec.tsx +# Docker build (removes jest-worker after build) +pnpm build:docker ``` -**Done in < 1 minute!** ✅ - -______________________________________________________________________ - -## 📊 How It Works - -### Component Complexity - -Script analyzes and scores components: - -- **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 refactoring) - -### Test Scenarios - -Defined in `TESTING.md`: - -**Must test**: Rendering, Props, Edge Cases\ -**Conditional**: State, Effects, Events, API calls, Routing\ -**Optional**: Accessibility, Performance, Snapshots - -AI assistant auto-selects scenarios based on component features. - -______________________________________________________________________ - -## 💡 Daily Workflow +To run the optimization manually: ```bash -# New component -pnpm test:gen app/components/new-feature/index.tsx -# → Paste in AI assistant → Apply → Done - -# Quick shortcuts: -# Cursor users: Cmd+I → "Generate test for [file]" → Apply -# Copilot users: Cmd+I → Paste prompt → Accept -# Others: Copy prompt → Paste in your AI tool +node scripts/optimize-standalone.js ``` -______________________________________________________________________ +### What gets removed -## 📋 Commands +- `node_modules/.pnpm/next@*/node_modules/next/dist/compiled/jest-worker` +- `node_modules/.pnpm/terser-webpack-plugin@*/node_modules/jest-worker` (symlinks) +- `node_modules/.pnpm/jest-worker@*` (actual packages) -```bash -pnpm test:gen # Generate test -pnpm test [file] # Run tests -pnpm test --coverage # View coverage -pnpm lint # Code check -pnpm type-check # Type check -``` +### Impact -______________________________________________________________________ - -## 🎯 Customize - -Edit testing standards for your team: - -```bash -# Complete testing guide (for all team members) -code web/scripts/TESTING.md - -# Quick reference for Cursor users -code .cursorrules - -# Commit your changes -git commit -m "docs: update test standards" -``` - -______________________________________________________________________ - -## 📚 Resources - -- **Testing Guide**: [TESTING.md](./TESTING.md) - Complete testing specifications -- **Quick Reference**: [.cursorrules](../../.cursorrules) - For Cursor users -- **Examples**: [classnames.spec.ts](../utils/classnames.spec.ts), [button/index.spec.tsx](../app/components/base/button/index.spec.tsx) +Removing jest-worker saves approximately 36KB per instance from the production image. While this may seem small, it helps ensure production images only contain necessary runtime dependencies. diff --git a/web/scripts/analyze-component.js b/web/testing/analyze-component.js similarity index 69% rename from web/scripts/analyze-component.js rename to web/testing/analyze-component.js index 83d046ce7a..8dc1fc9f1c 100755 --- a/web/scripts/analyze-component.js +++ b/web/testing/analyze-component.js @@ -1,19 +1,4 @@ #!/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 - * - * 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') @@ -23,13 +8,14 @@ const path = require('node:path') // ============================================================================ class ComponentAnalyzer { - analyze(code, filePath) { + analyze(code, filePath, absolutePath) { + const resolvedPath = absolutePath ?? path.resolve(process.cwd(), filePath) const fileName = path.basename(filePath, path.extname(filePath)) - const complexity = this.calculateComplexity(code) 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) + const usageCount = this.countUsageReferences(filePath, resolvedPath) // Calculate test priority const priority = this.calculateTestPriority(complexity, usageCount) @@ -54,10 +40,13 @@ class ComponentAnalyzer { } 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' + 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' + if (/use[A-Z]\w+/.test(code)) return 'component' return 'component' } @@ -71,17 +60,19 @@ class ComponentAnalyzer { * 31-50: 🟠 Complex (30-60 min to test) * 51+: 🔴 Very Complex (60+ min, consider splitting) */ - calculateComplexity(code) { + calculateComplexity(code, lineCount) { let score = 0 + const count = pattern => this.countMatches(code, pattern) + // ===== 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) + const stateHooks = count(/useState/g) + const effectHooks = count(/useEffect/g) + const callbackHooks = count(/useCallback/g) + const memoHooks = count(/useMemo/g) + const refHooks = count(/useRef/g) + const totalHooks = count(/use[A-Z]\w+/g) + const customHooks = Math.max(0, totalHooks - (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) @@ -91,63 +82,63 @@ class ComponentAnalyzer { 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 + 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 += (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 + 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 ===== - const propsMatches = code.match(/(\w+)\s*:\s*\w+/g) || [] - const propsCount = Math.min(propsMatches.length, 20) // Max 20 props + const propsMatches = this.countMatches(code, /(\w+)\s*:\s*\w+/g) + const propsCount = Math.min(propsMatches, 20) // Max 20 props score += Math.floor(propsCount / 2) // Every 2 props +1 - const eventHandlers = (code.match(/on[A-Z]\w+/g) || []).length + const eventHandlers = count(/on[A-Z]\w+/g) 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 + 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 ===== - const hasReactFlow = /reactflow|ReactFlow/.test(code) - const hasMonaco = /@monaco-editor/.test(code) - const hasEcharts = /echarts/.test(code) - const hasLexical = /lexical/.test(code) + const integrations = [ + { pattern: /reactflow|ReactFlow/, weight: 15 }, + { pattern: /@monaco-editor/, weight: 12 }, + { pattern: /echarts/, weight: 8 }, + { pattern: /lexical/, weight: 10 }, + ] - 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 + integrations.forEach(({ pattern, weight }) => { + if (pattern.test(code)) score += weight + }) // ===== 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 + 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 += (code.match(/useContext/g) || []).length * 3 - score += (code.match(/useStore|useAppStore/g) || []).length * 4 - score += (code.match(/zustand|redux/g) || []).length * 3 + score += count(/useContext/g) * 3 + score += count(/useStore|useAppStore/g) * 4 + score += count(/zustand|redux/g) * 3 return Math.min(score, 100) // Max 100 points } @@ -158,14 +149,73 @@ class ComponentAnalyzer { 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++) { - if (code[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 } - else if (code[i] === '}') { - currentDepth-- + + if (char === '}') { + currentDepth = Math.max(currentDepth - 1, 0) } } @@ -174,47 +224,70 @@ class ComponentAnalyzer { /** * Count how many times a component is referenced in the codebase - * Uses grep for searching import statements + * Scans TypeScript sources for import statements referencing the component */ - countUsageReferences(filePath) { + countUsageReferences(filePath, absolutePath) { try { - const { execSync } = require('node:child_process') + const resolvedComponentPath = absolutePath ?? path.resolve(process.cwd(), filePath) + const fileName = path.basename(resolvedComponentPath, path.extname(resolvedComponentPath)) - // 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) + const parentDir = path.dirname(resolvedComponentPath) 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}'` + if (!searchName) return 0 - // 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` + const searchRoots = this.collectSearchRoots(resolvedComponentPath) + if (searchRoots.length === 0) return 0 - // eslint-disable-next-line sonarjs/os-command - const result = execSync(grepCommand, { - cwd: process.cwd(), - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'ignore'], - }) + 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}(?:['\"/]|$)`), + ] - return Number.parseInt(result.trim(), 10) || 0 + 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 @@ -222,6 +295,68 @@ class ComponentAnalyzer { } } + 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 + } + + static escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + } + /** * Calculate test priority based on complexity and usage * @@ -318,10 +453,6 @@ Please generate a comprehensive test file for this component at: 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)} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -329,7 +460,7 @@ ${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. +Generate a comprehensive test file for @${analysis.path} Including but not limited to: ${this.buildFocusPoints(analysis)} @@ -500,6 +631,31 @@ Create the test file at: ${testPath} } } +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 // ============================================================================ @@ -511,20 +667,13 @@ function main() { 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 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 +For complete testing guidelines, see: web/testing/TESTING.md `) process.exit(1) } @@ -543,7 +692,7 @@ For complete testing guidelines, see: web/scripts/TESTING.md // Analyze const analyzer = new ComponentAnalyzer() - const analysis = analyzer.analyze(sourceCode, componentPath) + const analysis = analyzer.analyze(sourceCode, componentPath, absolutePath) // Check if component is too complex - suggest refactoring instead of testing if (analysis.complexity > 50 || analysis.lineCount > 300) { @@ -608,19 +757,7 @@ This component is too complex to test effectively. Please consider: 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() - + const copyContent = extractCopyContent(prompt) if (!copyContent) return const result = spawnSync('pbcopy', [], { diff --git a/web/scripts/TESTING.md b/web/testing/testing.md similarity index 83% rename from web/scripts/TESTING.md rename to web/testing/testing.md index fa4cb95681..90ce962499 100644 --- a/web/scripts/TESTING.md +++ b/web/testing/testing.md @@ -25,6 +25,14 @@ pnpm test -- --coverage 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` reports component complexity; `pnpm analyze-component ` should be part of the planning step for non-trivial components. +- **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. + ## Component Complexity Guidelines Use `pnpm analyze-component ` to analyze component complexity and adopt different testing strategies based on the results. @@ -54,15 +62,6 @@ Apply the following test scenarios based on component features: ### 1. Rendering Tests (REQUIRED - All Components) -```typescript -describe('Rendering', () => { - it('should render without crashing', () => { - render() - expect(screen.getByRole('...')).toBeInTheDocument() - }) -}) -``` - **Key Points**: - Verify component renders properly @@ -103,6 +102,13 @@ When testing side effects: - ✅ Test cleanup functions (useEffect return value) - ✅ Use `waitFor()` for async state changes +#### 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. + ### 4. Performance Optimization #### useCallback @@ -136,6 +142,14 @@ When testing side effects: - ✅ 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 @@ -337,6 +351,7 @@ const mockSetState = jest.fn() jest.spyOn(React, 'useState').mockImplementation((init) => [init, mockSetState]) // Mock useContext +const mockUser = { name: 'Test User' }; jest.spyOn(React, 'useContext').mockReturnValue({ user: mockUser }) ``` @@ -373,7 +388,7 @@ jest.mock('next/navigation', () => ({ // next/image jest.mock('next/image', () => ({ __esModule: true, - default: (props: any) => , + default: (props: React.ImgHTMLAttributes) => , })) ```