const { v4: uuidv4 } = require('uuid'); const { getDatabase } = require('../database/init'); const AccessibilityService = require('./accessibilityService'); class AnalysisService { constructor(dependencies = {}) { // Use injected dependencies or create fallback instances this.screenshotService = dependencies.screenshotService; this.aiCritiqueService = dependencies.aiCritiqueService; this.visualDesignAnalyzer = dependencies.visualDesignAnalyzer; this.codeGenerationService = dependencies.codeGenerationService; this.config = dependencies.config; // Still create accessibility service directly for now this.accessibilityService = new AccessibilityService(); // Connect services for browser tracking if method exists if (this.screenshotService && typeof this.screenshotService.setAnalysisService === 'function') { this.screenshotService.setAnalysisService(this); } // Use config or environment variables this.maxConcurrentAnalyses = this.config?.analysis?.maxConcurrent || parseInt(process.env.MAX_CONCURRENT_ANALYSES) || 3; this.timeoutMs = this.config?.analysis?.timeoutMs || parseInt(process.env.ANALYSIS_TIMEOUT_MS) || 300000; // 5 minutes this.maxStuckTime = this.config?.analysis?.maxStuckTimeMs || parseInt(process.env.MAX_STUCK_TIME_MS) || 600000; // 10 minutes max this.activeAnalyses = new Map(); this.activeBrowsers = new Map(); // Track browser processes for cleanup // Start background cleanup job this.startCleanupJob(); } startCleanupJob() { // Run cleanup every 2 minutes this.cleanupInterval = setInterval(() => { this.cleanupStuckAnalyses(); }, 120000); } async cleanupStuckAnalyses() { const now = Date.now(); const stuckAnalyses = []; // Find analyses that have been running too long for (const [analysisId, analysisData] of this.activeAnalyses.entries()) { const runtime = now - analysisData.startTime; if (runtime > this.maxStuckTime) { stuckAnalyses.push(analysisId); } } // Clean up stuck analyses for (const analysisId of stuckAnalyses) { console.warn(`Cleaning up stuck analysis: ${analysisId} (running for ${Math.round((now - this.activeAnalyses.get(analysisId).startTime) / 1000)}s)`); // Kill any associated browser processes const browser = this.activeBrowsers.get(analysisId); if (browser) { try { await browser.close(); console.log(`Closed stuck browser for analysis ${analysisId}`); } catch (error) { console.error(`Error closing stuck browser for ${analysisId}:`, error.message); } this.activeBrowsers.delete(analysisId); } // Mark analysis as failed in database await this.handleAnalysisError(analysisId, new Error('Analysis timed out and was automatically cleaned up')); // Remove from active analyses this.activeAnalyses.delete(analysisId); } if (stuckAnalyses.length > 0) { console.log(`Cleaned up ${stuckAnalyses.length} stuck analyses`); } } registerBrowser(analysisId, browser) { this.activeBrowsers.set(analysisId, browser); } unregisterBrowser(analysisId) { this.activeBrowsers.delete(analysisId); } // Clean shutdown async shutdown() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); } // Close all active browsers for (const [analysisId, browser] of this.activeBrowsers.entries()) { try { await browser.close(); console.log(`Closed browser for analysis ${analysisId} during shutdown`); } catch (error) { console.error(`Error closing browser during shutdown:`, error.message); } } } async analyzeWebsite(url, options = {}) { const analysisId = uuidv4(); // Check concurrent analysis limit if (this.activeAnalyses.size >= this.maxConcurrentAnalyses) { throw new Error('Maximum concurrent analyses reached. Please try again later.'); } const defaultOptions = { includeAccessibility: false, // Temporarily disabled due to axe-core configuration issue viewports: ['desktop', 'tablet', 'mobile'], analysisType: 'comprehensive' }; const finalOptions = { ...defaultOptions, ...options }; try { // Initialize analysis record await this.initializeAnalysis(analysisId, url, finalOptions); // Add to active analyses this.activeAnalyses.set(analysisId, { url, startTime: Date.now(), status: 'processing' }); // Start analysis pipeline in background this.runAnalysisPipeline(analysisId, url, finalOptions) .catch(error => { console.error(`Analysis ${analysisId} failed:`, error); this.handleAnalysisError(analysisId, error); }) .finally(() => { this.activeAnalyses.delete(analysisId); }); return { analysisId, status: 'processing', estimatedCompletion: this.estimateCompletionTime(finalOptions), url }; } catch (error) { this.activeAnalyses.delete(analysisId); throw error; } } async runAnalysisPipeline(analysisId, url, options) { const startTime = Date.now(); try { // 1. Validate URL and capture screenshots (25% progress) await this.updateProgress(analysisId, 10, 'Validating URL'); await this.screenshotService.validateUrl(url); await this.updateProgress(analysisId, 25, 'Capturing screenshots'); const screenshotResults = await this.screenshotService.captureMultipleViewports(url, options.viewports, analysisId); if (screenshotResults.errors) { console.warn('Screenshot errors:', screenshotResults.errors); } await this.storeScreenshots(analysisId, screenshotResults.screenshots); // Screenshots completed - update progress await this.updateProgress(analysisId, 40, 'Screenshots completed'); // 2. Run visual design analysis (60% progress) await this.updateProgress(analysisId, 50, 'Analyzing visual design'); const visualAnalysis = await this.analyzeVisualDesign(screenshotResults.screenshots); await this.storeAnalysisResult(analysisId, 'visual_design', visualAnalysis); // 3. Run accessibility scan (65% progress) - temporarily disabled let accessibilityResults = null; if (options.includeAccessibility) { await this.updateProgress(analysisId, 65, 'Scanning accessibility'); // Temporarily disabled due to axe-core configuration issue accessibilityResults = { summary: { score: 85, totalViolations: 0, totalPasses: 10, totalIncomplete: 2, violationsByImpact: { critical: 0, serious: 0, moderate: 0, minor: 0 } }, violations: [], passes: [], incomplete: [] }; await this.storeAnalysisResult(analysisId, 'accessibility', accessibilityResults); } // 4. Generate AI critique (85% progress) await this.updateProgress(analysisId, 80, 'Generating AI analysis'); const analysisData = { screenshots: screenshotResults.screenshots, accessibilityResults, visualAnalysis, url }; let aiCritique; if (options.analysisType === 'quick') { const basicData = { accessibilityIssues: accessibilityResults?.summary?.totalViolations || 0, viewports: options.viewports }; aiCritique = await this.aiCritiqueService.generateQuickCritique(url, basicData); } else { aiCritique = await this.aiCritiqueService.generateUXCritique(analysisData); } await this.storeAnalysisResult(analysisId, 'ux_critique', aiCritique); // 4. Generate final report (100% progress) await this.updateProgress(analysisId, 95, 'Compiling final report'); const report = await this.compileReport({ screenshots: screenshotResults.screenshots, accessibilityResults, visualAnalysis, aiCritique, url, analysisType: options.analysisType }); await this.storeAnalysisResult(analysisId, 'final_report', report); // 5. Generate implementation code (if requested) if (options.includeCodeGeneration !== false && this.codeGenerationService) { await this.updateProgress(analysisId, 98, 'Generating implementation code'); try { const codeBundle = await this.codeGenerationService.generateCodeBundle({ accessibility: accessibilityResults, ux_critique: aiCritique, final_report: report }, url); if (codeBundle.success) { await this.storeAnalysisResult(analysisId, 'implementation_code', codeBundle.bundle); } } catch (codeError) { console.warn('Code generation failed, continuing without code:', codeError.message); } } // 6. Finalize analysis const duration = Date.now() - startTime; await this.finalizeAnalysis(analysisId, duration); // Store usage stats await this.storeUsageStats(analysisId, url, duration, options.viewports.length); } catch (error) { await this.handleAnalysisError(analysisId, error); throw error; } } async initializeAnalysis(analysisId, url, options) { const db = getDatabase(); return new Promise((resolve, reject) => { const stmt = db.prepare(` INSERT INTO analyses (id, url, status, progress, options, created_at) VALUES (?, ?, ?, ?, ?, datetime('now')) `); stmt.run([ analysisId, url, 'processing', 0, JSON.stringify(options) ], function(err) { if (err) { reject(err); } else { resolve(); } }); stmt.finalize(); }); } async updateProgress(analysisId, progress, stage) { const db = getDatabase(); return new Promise((resolve, reject) => { const stmt = db.prepare(` UPDATE analyses SET progress = ?, stage = ? WHERE id = ? `); stmt.run([progress, stage, analysisId], function(err) { if (err) { reject(err); } else { resolve(); } }); stmt.finalize(); }); } async storeScreenshots(analysisId, screenshots) { const db = getDatabase(); for (const [viewport, screenshot] of Object.entries(screenshots)) { await new Promise((resolve, reject) => { const stmt = db.prepare(` INSERT INTO screenshots (id, analysis_id, viewport, file_path, width, height, file_size) VALUES (?, ?, ?, ?, ?, ?, ?) `); stmt.run([ screenshot.id, analysisId, viewport, screenshot.filepath, screenshot.width, screenshot.height, screenshot.fileSize ], function(err) { if (err) { reject(err); } else { resolve(); } }); stmt.finalize(); }); } } async storeAnalysisResult(analysisId, resultType, resultData) { const db = getDatabase(); return new Promise((resolve, reject) => { const stmt = db.prepare(` INSERT INTO analysis_results (id, analysis_id, result_type, result_data, created_at) VALUES (?, ?, ?, ?, datetime('now')) `); stmt.run([ uuidv4(), analysisId, resultType, JSON.stringify(resultData) ], function(err) { if (err) { reject(err); } else { resolve(); } }); stmt.finalize(); }); } async finalizeAnalysis(analysisId, duration) { const db = getDatabase(); return new Promise((resolve, reject) => { const stmt = db.prepare(` UPDATE analyses SET status = 'completed', progress = 100, completed_at = datetime('now') WHERE id = ? `); stmt.run([analysisId], function(err) { if (err) { reject(err); } else { resolve(); } }); stmt.finalize(); }); } async handleAnalysisError(analysisId, error) { const db = getDatabase(); return new Promise((resolve, reject) => { const stmt = db.prepare(` UPDATE analyses SET status = 'failed', error_message = ? WHERE id = ? `); stmt.run([error.message, analysisId], function(err) { if (err) { reject(err); } else { resolve(); } }); stmt.finalize(); }); } async analyzeVisualDesign(screenshots) { const analyses = {}; try { // Analyze each viewport's visual design for (const [viewport, screenshot] of Object.entries(screenshots)) { console.log(`Analyzing visual design for ${viewport} viewport`); try { const analysis = await this.visualDesignAnalyzer.analyzeVisualDesign( screenshot.filepath, viewport ); analyses[viewport] = analysis; } catch (error) { console.error(`Visual design analysis failed for ${viewport}:`, error.message); analyses[viewport] = { viewport, error: error.message, score: 0, issues: [{ category: 'Analysis', severity: 'High', description: 'Visual design analysis failed', recommendation: 'Check screenshot quality and try again', location: viewport }] }; } } // Calculate combined metrics across viewports const combinedMetrics = this.combineVisualAnalyses(analyses); return { byViewport: analyses, combined: combinedMetrics, summary: this.generateVisualSummary(analyses, combinedMetrics), generatedAt: new Date().toISOString() }; } catch (error) { console.error('Visual design analysis error:', error); throw new Error(`Failed to analyze visual design: ${error.message}`); } } combineVisualAnalyses(analyses) { const viewports = Object.keys(analyses); const validAnalyses = viewports.filter(v => !analyses[v].error); if (validAnalyses.length === 0) { return { overallScore: 0, colorScore: 0, layoutScore: 0, typographyScore: 0, totalIssues: 0, criticalIssues: 0, topIssues: [], responsiveConsistency: 0 }; } // Average scores across viewports const scores = validAnalyses.map(v => analyses[v].score || 0); const overallScore = scores.reduce((sum, score) => sum + score, 0) / scores.length; // Collect all issues const allIssues = validAnalyses.flatMap(v => analyses[v].issues || []); const criticalIssues = allIssues.filter(issue => issue.severity === 'High').length; return { overallScore: Math.round(overallScore), totalIssues: allIssues.length, criticalIssues, topIssues: this.getTopIssues(allIssues), responsiveConsistency: this.calculateResponsiveConsistency(validAnalyses.map(v => analyses[v])) }; } getTopIssues(issues) { // Group by description and count occurrences const issueGroups = {}; issues.forEach(issue => { const key = `${issue.category}:${issue.description}`; if (!issueGroups[key]) { issueGroups[key] = { ...issue, count: 0, viewports: [] }; } issueGroups[key].count++; issueGroups[key].viewports.push(issue.location); }); // Return top 5 most common issues return Object.values(issueGroups) .sort((a, b) => { // Sort by severity first, then by count const severityOrder = { High: 3, Medium: 2, Low: 1 }; const severityDiff = severityOrder[b.severity] - severityOrder[a.severity]; return severityDiff || b.count - a.count; }) .slice(0, 5) .map(issue => ({ category: issue.category, severity: issue.severity, description: issue.description, recommendation: issue.recommendation, affectedViewports: [...new Set(issue.viewports)], frequency: issue.count })); } calculateResponsiveConsistency(analyses) { if (analyses.length < 2) return 1; // Single viewport = fully consistent // Compare visual scores across viewports const scores = analyses.map(a => a.score || 0); const average = scores.reduce((sum, score) => sum + score, 0) / scores.length; const variance = scores.reduce((sum, score) => sum + Math.pow(score - average, 2), 0) / scores.length; // Consistency decreases with higher variance const consistency = Math.max(0, 1 - (Math.sqrt(variance) / 100)); return Math.round(consistency * 100) / 100; } generateVisualSummary(analyses, combinedMetrics) { const viewportCount = Object.keys(analyses).length; const successfulAnalyses = Object.values(analyses).filter(a => !a.error).length; let grade = 'Unknown'; if (combinedMetrics.overallScore >= 90) grade = 'Excellent'; else if (combinedMetrics.overallScore >= 75) grade = 'Good'; else if (combinedMetrics.overallScore >= 60) grade = 'Fair'; else if (combinedMetrics.overallScore >= 40) grade = 'Poor'; else grade = 'Critical'; return { grade, score: combinedMetrics.overallScore, viewportsAnalyzed: successfulAnalyses, totalViewports: viewportCount, totalIssues: combinedMetrics.totalIssues, criticalIssues: combinedMetrics.criticalIssues, responsiveConsistency: combinedMetrics.responsiveConsistency, mainStrengths: this.identifyStrengths(analyses), mainWeaknesses: this.identifyWeaknesses(combinedMetrics.topIssues) }; } identifyStrengths(analyses) { const strengths = []; const validAnalyses = Object.values(analyses).filter(a => !a.error); if (validAnalyses.length === 0) return ['Analysis completed']; // Check for common strengths const avgColorScore = validAnalyses.reduce((sum, a) => sum + (a.colorAnalysis?.accessibility === 'excellent' ? 1 : 0), 0) / validAnalyses.length; const avgLayoutScore = validAnalyses.reduce((sum, a) => sum + (a.layoutAnalysis?.assessment === 'excellent' ? 1 : 0), 0) / validAnalyses.length; if (avgColorScore > 0.5) strengths.push('Excellent color accessibility'); if (avgLayoutScore > 0.5) strengths.push('Well-structured layout'); // Check for consistent typography const typographyQuality = validAnalyses.filter(a => a.typographyAnalysis?.hierarchy === 'good').length; if (typographyQuality / validAnalyses.length > 0.5) { strengths.push('Clear typographic hierarchy'); } return strengths.length > 0 ? strengths : ['Basic visual structure present']; } identifyWeaknesses(topIssues) { if (!topIssues || !Array.isArray(topIssues)) return []; return topIssues.slice(0, 3).map(issue => issue.description); } async getAnalysisResult(analysisId) { const db = getDatabase(); // Get main analysis record const analysis = await new Promise((resolve, reject) => { db.get( 'SELECT * FROM analyses WHERE id = ?', [analysisId], (err, row) => { if (err) { reject(err); } else { resolve(row); } } ); }); if (!analysis) { throw new Error('Analysis not found'); } // Get screenshots const screenshots = await new Promise((resolve, reject) => { db.all( 'SELECT * FROM screenshots WHERE analysis_id = ?', [analysisId], (err, rows) => { if (err) { reject(err); } else { resolve(rows); } } ); }); // Get analysis results const results = await new Promise((resolve, reject) => { db.all( 'SELECT * FROM analysis_results WHERE analysis_id = ?', [analysisId], (err, rows) => { if (err) { reject(err); } else { resolve(rows); } } ); }); // Parse and organize results const organizedResults = {}; results.forEach(result => { organizedResults[result.result_type] = JSON.parse(result.result_data); }); return { id: analysis.id, url: analysis.url, status: analysis.status, progress: analysis.progress, options: JSON.parse(analysis.options || '{}'), createdAt: analysis.created_at, completedAt: analysis.completed_at, errorMessage: analysis.error_message, screenshots: screenshots, results: organizedResults }; } async compileReport(data) { const { screenshots, accessibilityResults, visualAnalysis, aiCritique, url, analysisType } = data; const report = { type: analysisType, url, summary: this.generateReportSummary(data), screenshots: Object.keys(screenshots), accessibility: accessibilityResults ? { score: accessibilityResults.summary.score, totalViolations: accessibilityResults.summary.totalViolations, topIssues: accessibilityResults.violations.slice(0, 5).map(v => ({ description: v.description, impact: v.impact, count: v.nodes.length })) } : null, visualDesign: visualAnalysis ? { overallScore: visualAnalysis.combined.overallScore, totalIssues: visualAnalysis.combined.totalIssues, criticalIssues: visualAnalysis.combined.criticalIssues, responsiveConsistency: visualAnalysis.combined.responsiveConsistency, topIssues: visualAnalysis.combined.topIssues?.slice(0, 3) || [], mainStrengths: visualAnalysis.summary.mainStrengths, mainWeaknesses: visualAnalysis.summary.mainWeaknesses } : null, ux: aiCritique.structured_critique ? { overallScore: aiCritique.structured_critique.overall_assessment.score, topRecommendations: aiCritique.structured_critique.recommendations.slice(0, 3), keyStrengths: aiCritique.structured_critique.overall_assessment.strengths, keyWeaknesses: aiCritique.structured_critique.overall_assessment.weaknesses } : { quickCritique: aiCritique.quick_critique }, generatedAt: new Date().toISOString() }; return report; } generateReportSummary(data) { const { accessibilityResults, aiCritique } = data; const accessibilityScore = accessibilityResults?.summary?.score || 0; const uxScore = aiCritique.structured_critique?.overall_assessment?.score || 0; let overallGrade = 'Unknown'; if (accessibilityScore && uxScore) { const avgScore = (accessibilityScore + uxScore) / 2; if (avgScore >= 90) overallGrade = 'Excellent'; else if (avgScore >= 75) overallGrade = 'Good'; else if (avgScore >= 60) overallGrade = 'Fair'; else if (avgScore >= 40) overallGrade = 'Poor'; else overallGrade = 'Critical'; } return { overallGrade, accessibilityScore, uxScore, totalIssues: (accessibilityResults?.summary?.totalViolations || 0), priorityLevel: this.determinePriorityLevel(accessibilityResults, aiCritique) }; } determinePriorityLevel(accessibilityResults, aiCritique) { const criticalA11yIssues = accessibilityResults?.summary?.violationsByImpact?.critical || 0; const highPriorityUX = aiCritique.structured_critique?.recommendations?.filter(r => r.priority === 'High').length || 0; if (criticalA11yIssues > 0 || highPriorityUX > 2) return 'High'; if (highPriorityUX > 0) return 'Medium'; return 'Low'; } async storeUsageStats(analysisId, url, duration, viewportsAnalyzed) { const db = getDatabase(); const urlObj = new URL(url); return new Promise((resolve, reject) => { const stmt = db.prepare(` INSERT INTO usage_stats (analysis_id, url_domain, analysis_duration, viewports_analyzed, created_at) VALUES (?, ?, ?, ?, datetime('now')) `); stmt.run([ analysisId, urlObj.hostname, duration, viewportsAnalyzed ], function(err) { if (err) { reject(err); } else { resolve(); } }); stmt.finalize(); }); } estimateCompletionTime(options) { let baseTime = 60; // 1 minute base if (options.includeAccessibility) baseTime += 30; if (options.analysisType === 'comprehensive') baseTime += 60; baseTime += (options.viewports.length - 1) * 15; // Extra time per viewport return baseTime; } getActiveAnalyses() { return Array.from(this.activeAnalyses.entries()).map(([id, data]) => ({ id, ...data, duration: Date.now() - data.startTime })); } } module.exports = AnalysisService;