docs: update frontend testing guidelines and add new testing resources including Copilot instructions and Windsurf testing rules

This commit is contained in:
姜涵煦 2025-10-30 14:31:35 +08:00
parent 69befd1916
commit 23bb7df43e
8 changed files with 346 additions and 295 deletions

View File

@ -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%

13
.github/copilot-instructions.md vendored Normal file
View File

@ -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.

View File

@ -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%

View File

@ -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

View File

@ -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",

View File

@ -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 <path> # 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.

View File

@ -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 <component-path>
*
* Examples:
* node scripts/analyze-component.js app/components/base/button/index.tsx
* node scripts/analyze-component.js app/components/workflow/nodes/llm/panel.tsx
*
* For complete testing guidelines, see: web/scripts/TESTING.md
*/
const fs = require('node:fs')
const path = require('node:path')
@ -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 <component-path>
Examples:
node scripts/analyze-component.js app/components/base/button/index.tsx
node scripts/analyze-component.js app/components/workflow/nodes/llm/panel.tsx
This tool analyzes your component and generates a prompt for AI assistants.
Copy the output and use it with:
- Cursor (Cmd+L for Chat, Cmd+I for Composer)
- GitHub Copilot Chat (Cmd+I)
- Claude, ChatGPT, or any other AI coding tool
For complete testing guidelines, see: web/scripts/TESTING.md
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', [], {

View File

@ -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 <path>` 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 <path>` 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(<Component />)
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) => <img {...props} />,
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
}))
```