UNPKG

ctrlshiftleft

Version:

AI-powered toolkit for embedding QA and security testing into development workflows

356 lines (309 loc) 15.2 kB
import chokidar from 'chokidar'; import path from 'path'; import chalk from 'chalk'; import fs from 'fs/promises'; import { EventEmitter } from 'events'; import { TestGenerator } from './testGenerator'; import { TestRunner, TestResults } from './testRunner'; import { ChecklistGenerator, ChecklistResult } from './checklistGenerator'; import { diff, DiffChange, highlightSecurityChanges } from '../utils/diffUtils'; import { formatSeverityBadge, generateSecurityRiskReport } from '../utils/securityRiskUtils'; import { ChecklistItem } from '../types/checklistTypes'; /** * Options for configuring the Watcher */ export interface WatcherOptions { /** Debounce delay in milliseconds */ debounceMs: number; /** File patterns to ignore */ ignorePatterns: string[]; /** Whether to automatically run tests after generation */ runTests?: boolean; /** Whether to generate security and QA checklists */ generateChecklists?: boolean; /** Whether to show diffs between file changes */ showDiffs?: boolean; /** Browser to use for running tests */ browser?: string; /** Whether to run browser in headless mode */ headless?: boolean; /** Number of parallel test workers */ workers?: number; } /** * File watcher for auto-running tests and security analysis * Watches source files for changes and automatically generates tests, * runs those tests, and performs security analysis. */ export class Watcher extends EventEmitter { private options: WatcherOptions; private watcher: chokidar.FSWatcher | null = null; private testGenerator: TestGenerator; private testRunner: TestRunner; private checklistGenerator: ChecklistGenerator; private isProcessing = false; private fileContents: Map<string, string> = new Map(); private securityIssues: Map<string, string[]> = new Map(); /** * Create a new watcher instance * @param options Configuration options */ constructor(options: WatcherOptions) { super(); this.options = { debounceMs: options.debounceMs || 1000, ignorePatterns: options.ignorePatterns || ['node_modules', 'dist', 'build', '*.test.*'], runTests: options.runTests ?? true, generateChecklists: options.generateChecklists ?? true, showDiffs: options.showDiffs ?? true, browser: options.browser || 'chromium', headless: options.headless ?? true, workers: options.workers || 1 }; this.testGenerator = new TestGenerator({ format: 'playwright', timeout: 60 }); this.testRunner = new TestRunner({ browser: this.options.browser as string, headless: this.options.headless as boolean, timeout: 60, reporter: 'list', workers: this.options.workers as number }); this.checklistGenerator = new ChecklistGenerator(); // Setup test runner events for real-time feedback this.testRunner.on('test:end', (result) => { this.emit('test:end', result); }); this.testRunner.on('error', (error) => { this.emit('error', error); }); } /** * Watch source files and generate tests on changes * @param sourcePath Source path to watch * @param outputDir Output directory for generated tests */ async watch(sourcePath: string, outputDir: string): Promise<void> { // Initialize watcher this.watcher = chokidar.watch(sourcePath, { ignored: this.options.ignorePatterns, ignoreInitial: false, persistent: true }); // Setup event handlers this.watcher .on('add', (filePath) => this.handleFileChange('add', filePath, outputDir)) .on('change', (filePath) => this.handleFileChange('change', filePath, outputDir)) .on('unlink', (filePath) => this.handleFileChange('unlink', filePath, outputDir)) .on('ready', () => { console.log(chalk.green('Initial scan complete. Watching for changes...')); }) .on('error', (error) => { console.error(chalk.red(`Watcher error: ${error}`)); }); } /** * Stop watching files and clean up resources */ stop(): void { if (this.watcher) { this.watcher.close(); this.watcher = null; } } /** * Handle file change events * @param event File event type (add, change, unlink) * @param filePath Path to the changed file * @param outputDir Output directory for generated tests */ private handleFileChange(event: 'add' | 'change' | 'unlink', filePath: string, outputDir: string): void { // Skip non-source files if (!this.isSourceFile(filePath)) { return; } console.log(chalk.blue(`${event}: ${filePath}`)); // Debounce multiple changes setTimeout(async () => { if (this.isProcessing) { return; } this.isProcessing = true; this.emit('processing:start', { filePath, event }); try { if (event === 'add' || event === 'change') { // Store file content for diff analysis let previousContent = null; if (this.options.showDiffs && event === 'change') { previousContent = this.fileContents.get(filePath) || null; } // Read and store new content const newContent = await fs.readFile(filePath, 'utf8'); this.fileContents.set(filePath, newContent); // Show diff if enabled and we have previous content if (this.options.showDiffs && previousContent !== null) { const changes = diff(previousContent, newContent); if (changes.length > 1) { // More than just the unchanged content console.log(chalk.yellow('Code changes:')); changes.forEach((change: DiffChange) => { if (change.added) { console.log(chalk.green(`+ ${change.value}`)); } else if (change.removed) { console.log(chalk.red(`- ${change.value}`)); } }); this.emit('diff', { filePath, changes }); } } // Generate tests console.log(chalk.yellow(`Generating tests for: ${filePath}`)); try { const result = await this.testGenerator.generateTests(filePath, outputDir); if (result.testCount > 0) { console.log(chalk.green(`✓ Generated ${result.testCount} tests for ${filePath}`)); console.log(chalk.gray(` Output: ${result.files.join(', ')}`)); this.emit('tests:generated', { filePath, testFiles: result.files, testCount: result.testCount }); // Run tests if enabled if (this.options.runTests && result.files.length > 0) { console.log(chalk.yellow(`Running tests for: ${filePath}`)); try { const testResults = await this.testRunner.runTests(result.files[0]); this.emit('tests:run', { filePath, results: testResults }); // Report test results if (testResults.passed === testResults.total) { console.log(chalk.green(`✓ All tests passed (${testResults.passed}/${testResults.total})`)); } else { console.log(chalk.red(`✗ Tests failed: ${testResults.failed}/${testResults.total}`)); // Log failed tests if (testResults.errors && testResults.errors.length > 0) { console.log(chalk.red('Failures:')); testResults.errors.forEach(error => { console.log(chalk.red(` - ${error.message}`)); }); } } } catch (testError) { console.error(chalk.red(`Error running tests: ${(testError as Error).message}`)); } } // Generate security and QA checklist if enabled if (this.options.generateChecklists) { console.log(chalk.yellow(`Generating security checklist for: ${filePath}`)); try { const checklistResult = await this.checklistGenerator.generateChecklist(filePath, path.join(outputDir, '../checklists')); if (checklistResult.itemCount > 0) { console.log(chalk.green(`✓ Generated checklist with ${checklistResult.itemCount} items`)); console.log(chalk.gray(` Output: ${checklistResult.file}`)); const securityItems = checklistResult.items.filter(item => item.category === 'Security' && (item.status === 'failed')); const previousIssues = this.securityIssues.get(filePath) || []; const currentIssues = securityItems.map(item => item.description); this.securityIssues.set(filePath, currentIssues); // Find new security issues const newIssues = currentIssues.filter(issue => !previousIssues.includes(issue)); if (newIssues.length > 0) { console.log(chalk.red(`⚠️ ${newIssues.length} new security issues detected`)); // Get full security items with severity and risk scores const newSecurityItems = securityItems.filter(item => item.description && newIssues.includes(item.description)); // Display detailed security risk information if (newSecurityItems.length > 0) { // Count by severity const criticalCount = newSecurityItems.filter(item => item.severity === 'critical').length; const highCount = newSecurityItems.filter(item => item.severity === 'high').length; const mediumCount = newSecurityItems.filter(item => item.severity === 'medium').length; const lowCount = newSecurityItems.filter(item => item.severity === 'low').length; // Show security severity breakdown if (criticalCount > 0) console.log(` ${formatSeverityBadge('critical')} ${criticalCount}`); if (highCount > 0) console.log(` ${formatSeverityBadge('high')} ${highCount}`); if (mediumCount > 0) console.log(` ${formatSeverityBadge('medium')} ${mediumCount}`); if (lowCount > 0) console.log(` ${formatSeverityBadge('low')} ${lowCount}`); // Generate concise risk report for detected issues newSecurityItems.forEach(item => { const score = item.riskScore?.score ? chalk.yellow(item.riskScore.score.toFixed(1)) : ''; const severity = formatSeverityBadge(item.severity || 'medium'); console.log(chalk.red(` - ${severity} ${item.title || 'Security Issue'} ${score}`)); console.log(chalk.gray(` ${item.description}`)); if (item.remediation?.description) { console.log(chalk.green(` ✓ Remediation: ${item.remediation.description}`)); if (item.remediation.codeExample) { console.log(chalk.blue(` Example: ${item.remediation.codeExample}`)); } } // Show security references if (item.references && item.references.length > 0) { const ref = item.references[0]; const refText = []; if (ref.cwe) refText.push(ref.cwe); if (ref.owasp) refText.push(ref.owasp); if (refText.length > 0) { console.log(chalk.gray(` References: ${refText.join(', ')}`)); } } console.log(); // Add spacing between issues }); } else { // Fallback to simple issue list if no detailed items newIssues.forEach((issue: string) => { console.log(chalk.red(` - ${issue}`)); }); } this.emit('security:new-issues', { filePath, issues: newIssues }); } // Find resolved security issues const resolvedIssues = previousIssues.filter((issue: string) => !currentIssues.includes(issue)); if (resolvedIssues.length > 0) { console.log(chalk.green(`✓ Security issues resolved:`)); resolvedIssues.forEach((issue: string) => { console.log(chalk.green(` - ${issue}`)); }); this.emit('security:resolved-issues', { filePath, issues: resolvedIssues }); } } else { console.log(chalk.gray(`No checklist items generated for ${filePath}`)); } } catch (checklistError) { console.error(chalk.red(`Error generating checklist: ${(checklistError as Error).message}`)); } } } else { console.log(chalk.gray(`No tests generated for ${filePath}`)); } } catch (genError) { console.error(chalk.red(`Error generating tests: ${(genError as Error).message}`)); } } } catch (error) { console.error(chalk.red(`Error processing ${filePath}: ${(error as Error).message}`)); this.emit('error', { filePath, error }); } finally { this.isProcessing = false; this.emit('processing:end', { filePath }); } }, this.options.debounceMs); } /** * Check if file is a source file that should trigger test generation * @param filePath Path to file to check * @returns True if the file is a source file that should trigger test generation */ private isSourceFile(filePath: string): boolean { const ext = path.extname(filePath).toLowerCase(); const validExtensions = ['.js', '.jsx', '.ts', '.tsx']; if (!validExtensions.includes(ext)) { return false; } const fileName = path.basename(filePath).toLowerCase(); // Skip test files if (fileName.includes('.test.') || fileName.includes('.spec.')) { return false; } return true; } }