UNPKG

erosolar-cli

Version:

Unified AI agent framework for the command line - Multi-provider support with schema-driven tools, code intelligence, and transparent reasoning

1,143 lines (1,119 loc) โ€ข 57 kB
/** * Frontend Testing Tools - Comprehensive frontend testing and verification capabilities * * Provides tools for: * - E2E Testing (Cypress, Playwright, Selenium) * - Build Verification (bundle analysis, build output validation) * - Deployment Verification (URL health checks, asset verification, smoke tests) * - Accessibility Testing (axe-core, pa11y integration) * - Performance Testing (Lighthouse, Web Vitals) * - Visual Regression Testing (screenshot comparison) * * @license MIT * @author Bo Shang */ import { spawn } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; const E2E_FRAMEWORKS = { cypress: { id: 'cypress', name: 'Cypress', cliCommand: 'cypress', installCommand: 'npm install -D cypress', configFile: 'cypress.config.js', runCommand: 'npx cypress run', headlessFlag: '--headless', }, playwright: { id: 'playwright', name: 'Playwright', cliCommand: 'playwright', installCommand: 'npm install -D @playwright/test && npx playwright install', configFile: 'playwright.config.ts', runCommand: 'npx playwright test', headlessFlag: '--headed=false', }, puppeteer: { id: 'puppeteer', name: 'Puppeteer', cliCommand: 'puppeteer', installCommand: 'npm install -D puppeteer', configFile: 'puppeteer.config.js', runCommand: 'npx jest --config=jest-puppeteer.config.js', headlessFlag: '', }, }; const BUILD_CONFIGS = { react: { framework: 'React', outputDir: 'build', indexFile: 'index.html', expectedAssets: ['static/js', 'static/css'], }, nextjs: { framework: 'Next.js', outputDir: '.next', indexFile: '', expectedAssets: ['static', 'server'], }, angular: { framework: 'Angular', outputDir: 'dist', indexFile: 'index.html', expectedAssets: ['main.js', 'polyfills.js', 'styles.css'], }, vue: { framework: 'Vue', outputDir: 'dist', indexFile: 'index.html', expectedAssets: ['js', 'css'], }, svelte: { framework: 'Svelte', outputDir: 'build', indexFile: 'index.html', expectedAssets: ['_app'], }, vite: { framework: 'Vite', outputDir: 'dist', indexFile: 'index.html', expectedAssets: ['assets'], }, }; async function runCommand(command, cwd, timeout = 300000) { return new Promise((resolve) => { let killed = false; let stdout = ''; let stderr = ''; const child = spawn('sh', ['-c', command], { cwd, env: { ...process.env }, }); // Set up timeout with proper cleanup const timeoutId = setTimeout(() => { killed = true; child.kill('SIGTERM'); // Force kill if not terminated after 5 seconds setTimeout(() => { if (!child.killed) { child.kill('SIGKILL'); } }, 5000); }, timeout); child.stdout?.on('data', (data) => { stdout += data.toString(); }); child.stderr?.on('data', (data) => { stderr += data.toString(); }); child.on('close', (code) => { clearTimeout(timeoutId); if (killed) { resolve({ stdout: stdout.trim(), stderr: `${stderr.trim()}\n[Process timed out and was terminated]`, exitCode: code ?? 1, timedOut: true, }); } else { resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? 1 }); } }); child.on('error', (err) => { clearTimeout(timeoutId); resolve({ stdout: '', stderr: err.message, exitCode: 1 }); }); }); } /** * Safely parse JSON with helpful error messages */ function safeJsonParse(content, source) { try { return { data: JSON.parse(content), error: null }; } catch (e) { const preview = content.slice(0, 100).replace(/\n/g, '\\n'); return { data: null, error: `Failed to parse ${source}: ${e instanceof Error ? e.message : 'Invalid JSON'}. Content preview: "${preview}..."`, }; } } /** * Validate URL format and protocol */ function validateUrl(url) { if (!url || typeof url !== 'string') { return { valid: false, error: 'URL is required and must be a string' }; } try { const parsed = new URL(url); if (!['http:', 'https:'].includes(parsed.protocol)) { return { valid: false, error: `Invalid protocol "${parsed.protocol}". Only http: and https: are supported` }; } return { valid: true, normalized: parsed.toString() }; } catch { return { valid: false, error: `Invalid URL format: "${url}"` }; } } /** * Validate file path for path traversal attacks */ function validateFilePath(filePath, baseDir) { if (!filePath || typeof filePath !== 'string') { return { valid: false, error: 'File path is required and must be a string' }; } const resolved = path.resolve(baseDir, filePath); const normalizedBase = path.resolve(baseDir); if (!resolved.startsWith(normalizedBase)) { return { valid: false, error: 'Path traversal not allowed - path must be within working directory' }; } return { valid: true, resolved }; } /** * Format output with truncation warning */ function truncateOutput(output, maxLength, label) { if (output.length <= maxLength) { return output; } return `${output.slice(0, maxLength)}\n\n[${label} truncated - ${output.length - maxLength} more characters not shown]`; } function detectFramework(workingDir) { const packageJsonPath = path.join(workingDir, 'package.json'); if (fs.existsSync(packageJsonPath)) { const content = fs.readFileSync(packageJsonPath, 'utf-8'); const { data: pkg } = safeJsonParse(content, 'package.json'); if (pkg) { const deps = { ...pkg.dependencies, ...pkg.devDependencies }; if (deps['next']) return 'nextjs'; if (deps['@angular/core']) return 'angular'; if (deps['vue']) return 'vue'; if (deps['svelte']) return 'svelte'; if (deps['vite']) return 'vite'; if (deps['react']) return 'react'; } } return 'unknown'; } function detectE2EFramework(workingDir) { const packageJsonPath = path.join(workingDir, 'package.json'); if (fs.existsSync(packageJsonPath)) { const content = fs.readFileSync(packageJsonPath, 'utf-8'); const { data: pkg } = safeJsonParse(content, 'package.json'); if (pkg) { const deps = { ...pkg.dependencies, ...pkg.devDependencies }; if (deps['cypress']) return 'cypress'; if (deps['@playwright/test'] || deps['playwright']) return 'playwright'; if (deps['puppeteer']) return 'puppeteer'; } } // Check for config files if (fs.existsSync(path.join(workingDir, 'cypress.config.js')) || fs.existsSync(path.join(workingDir, 'cypress.config.ts'))) { return 'cypress'; } if (fs.existsSync(path.join(workingDir, 'playwright.config.ts')) || fs.existsSync(path.join(workingDir, 'playwright.config.js'))) { return 'playwright'; } return null; } async function fetchUrl(url, timeout = 10000) { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); const response = await fetch(url, { signal: controller.signal, headers: { 'User-Agent': 'erosolar-cli/frontend-testing' }, }); clearTimeout(timeoutId); const body = await response.text(); return { status: response.status, body: body.slice(0, 5000) }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); let errorType = 'UNKNOWN'; let userFriendlyError = errorMessage; // Categorize common network errors if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('getaddrinfo')) { errorType = 'DNS_FAILURE'; userFriendlyError = 'DNS lookup failed - check if the domain exists and your internet connection'; } else if (errorMessage.includes('ECONNREFUSED')) { errorType = 'CONNECTION_REFUSED'; userFriendlyError = 'Connection refused - is the server running?'; } else if (errorMessage.includes('ETIMEDOUT') || errorMessage.includes('timeout') || errorMessage.includes('aborted')) { errorType = 'TIMEOUT'; userFriendlyError = `Request timed out after ${timeout}ms - server may be slow or unreachable`; } else if (errorMessage.includes('EHOSTUNREACH')) { errorType = 'HOST_UNREACHABLE'; userFriendlyError = 'Host unreachable - check your network connection'; } else if (errorMessage.includes('certificate') || errorMessage.includes('SSL') || errorMessage.includes('TLS')) { errorType = 'SSL_ERROR'; userFriendlyError = 'SSL/TLS certificate error - the site may have an invalid certificate'; } return { status: 0, body: '', error: userFriendlyError, errorType, }; } } function analyzeBundle(dir) { const result = { totalSize: 0, jsSize: 0, cssSize: 0, imageSize: 0, files: [], }; function walk(currentDir) { if (!fs.existsSync(currentDir)) return; const entries = fs.readdirSync(currentDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(currentDir, entry.name); if (entry.isDirectory()) { walk(fullPath); } else if (entry.isFile()) { const stats = fs.statSync(fullPath); const size = stats.size; const ext = path.extname(entry.name).toLowerCase(); result.totalSize += size; result.files.push({ name: path.relative(dir, fullPath), size }); if (['.js', '.mjs', '.cjs'].includes(ext)) { result.jsSize += size; } else if (['.css', '.scss', '.sass', '.less'].includes(ext)) { result.cssSize += size; } else if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.avif'].includes(ext)) { result.imageSize += size; } } } } walk(dir); // Sort by size descending result.files.sort((a, b) => b.size - a.size); return result; } function formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; } export function createFrontendTestingTools(workingDir = process.cwd()) { return [ { name: 'run_e2e_tests', description: `Run end-to-end tests using Cypress, Playwright, or Puppeteer. Automatically detects the E2E framework from your project and runs tests. Supports: - Cypress (cypress run) - Playwright (npx playwright test) - Puppeteer (jest with puppeteer) Features: - Headless mode for CI/CD - Spec/test file filtering - Browser selection - Screenshot capture on failure - Video recording (Cypress/Playwright)`, parameters: { type: 'object', properties: { framework: { type: 'string', enum: ['cypress', 'playwright', 'puppeteer', 'auto'], description: 'E2E framework to use (default: auto-detect)', }, spec: { type: 'string', description: 'Specific test file or pattern to run', }, browser: { type: 'string', enum: ['chrome', 'chromium', 'firefox', 'webkit', 'electron'], description: 'Browser to use for testing', }, headed: { type: 'boolean', description: 'Run in headed mode (show browser window)', }, baseUrl: { type: 'string', description: 'Base URL for the application under test', }, timeout: { type: 'number', description: 'Timeout in milliseconds (default: 300000)', }, }, }, handler: async (args) => { let frameworkId = args['framework'] || 'auto'; const spec = args['spec']; const browser = args['browser']; const headed = args['headed'] === true; const baseUrl = args['baseUrl']; const timeout = args['timeout'] || 300000; // Auto-detect framework if (frameworkId === 'auto') { const detected = detectE2EFramework(workingDir); if (!detected) { return `No E2E testing framework detected in ${workingDir} To get started, install one of the supported frameworks: - Cypress: npm install -D cypress && npx cypress open - Playwright: npm install -D @playwright/test && npx playwright install Then run this tool again.`; } frameworkId = detected; } const framework = E2E_FRAMEWORKS[frameworkId]; if (!framework) { return `Unknown E2E framework: ${frameworkId}\n\nSupported: cypress, playwright, puppeteer`; } // Build command let command = framework.runCommand; if (spec) { if (frameworkId === 'cypress') { command += ` --spec "${spec}"`; } else if (frameworkId === 'playwright') { command += ` "${spec}"`; } } if (browser) { if (frameworkId === 'cypress') { command += ` --browser ${browser}`; } else if (frameworkId === 'playwright') { command += ` --project=${browser}`; } } if (!headed && framework.headlessFlag) { command += ` ${framework.headlessFlag}`; } else if (headed && frameworkId === 'playwright') { command += ' --headed'; } if (baseUrl && frameworkId === 'cypress') { command += ` --config baseUrl=${baseUrl}`; } const results = [`๐Ÿงช Running ${framework.name} E2E Tests\n`]; results.push(`Command: ${command}\n`); const result = await runCommand(command, workingDir, timeout); if (result.exitCode === 0) { results.push(`โœ… All E2E tests passed!\n`); } else { results.push(`โŒ E2E tests failed (exit code: ${result.exitCode})\n`); } if (result.stdout) { results.push(`Output:\n${truncateOutput(result.stdout, 10000, 'output')}\n`); } if (result.stderr) { results.push(`Errors:\n${truncateOutput(result.stderr, 5000, 'errors')}\n`); } return results.join('\n'); }, }, { name: 'verify_build_output', description: `Verify frontend build output and analyze bundle. Checks: - Build directory exists and contains expected files - Bundle size analysis (JS, CSS, images) - Source map presence - Index.html integrity - Large file detection - Missing asset detection Works with: React, Next.js, Angular, Vue, Svelte, Vite`, parameters: { type: 'object', properties: { buildDir: { type: 'string', description: 'Build output directory (auto-detected if not specified)', }, framework: { type: 'string', enum: ['react', 'nextjs', 'angular', 'vue', 'svelte', 'vite', 'auto'], description: 'Frontend framework (default: auto-detect)', }, maxBundleSize: { type: 'number', description: 'Maximum total bundle size in bytes (default: 5MB)', }, warnLargeFiles: { type: 'number', description: 'Warn about files larger than this (bytes, default: 500KB)', }, }, }, handler: async (args) => { let frameworkId = args['framework'] || 'auto'; const maxBundleSize = args['maxBundleSize'] || 5 * 1024 * 1024; // 5MB const warnLargeFiles = args['warnLargeFiles'] || 500 * 1024; // 500KB // Auto-detect framework if (frameworkId === 'auto') { frameworkId = detectFramework(workingDir); } const config = BUILD_CONFIGS[frameworkId] ?? BUILD_CONFIGS['react']; const buildDir = args['buildDir'] || path.join(workingDir, config.outputDir); const results = [`๐Ÿ“ฆ Build Output Verification\n`]; results.push(`Framework: ${config.framework}`); results.push(`Build directory: ${buildDir}\n`); // Check if build directory exists if (!fs.existsSync(buildDir)) { return `โŒ Build directory not found: ${buildDir} Run your build command first: - React: npm run build - Next.js: npm run build - Angular: ng build - Vue: npm run build - Vite: npm run build`; } results.push(`โœ… Build directory exists\n`); // Check for index.html (if applicable) if (config.indexFile) { const indexPath = path.join(buildDir, config.indexFile); if (fs.existsSync(indexPath)) { results.push(`โœ… ${config.indexFile} found`); } else { results.push(`โš ๏ธ ${config.indexFile} not found`); } } // Check for expected assets const missingAssets = []; for (const asset of config.expectedAssets) { const assetPath = path.join(buildDir, asset); if (!fs.existsSync(assetPath)) { missingAssets.push(asset); } } if (missingAssets.length > 0) { results.push(`โš ๏ธ Missing expected assets: ${missingAssets.join(', ')}`); } else { results.push(`โœ… All expected assets present`); } // Analyze bundle const analysis = analyzeBundle(buildDir); results.push(`\n๐Ÿ“Š Bundle Analysis:`); results.push(`Total size: ${formatBytes(analysis.totalSize)}`); results.push(`JavaScript: ${formatBytes(analysis.jsSize)}`); results.push(`CSS: ${formatBytes(analysis.cssSize)}`); results.push(`Images: ${formatBytes(analysis.imageSize)}`); // Check bundle size limit if (analysis.totalSize > maxBundleSize) { results.push(`\nโŒ Bundle exceeds size limit (${formatBytes(maxBundleSize)})`); } else { results.push(`\nโœ… Bundle within size limit`); } // Large files warning const largeFiles = analysis.files.filter((f) => f.size > warnLargeFiles); if (largeFiles.length > 0) { results.push(`\nโš ๏ธ Large files detected (>${formatBytes(warnLargeFiles)}):`); largeFiles.slice(0, 10).forEach((f) => { results.push(` - ${f.name}: ${formatBytes(f.size)}`); }); } // Top 10 largest files results.push(`\n๐Ÿ“ Largest files:`); analysis.files.slice(0, 10).forEach((f, i) => { results.push(` ${i + 1}. ${f.name}: ${formatBytes(f.size)}`); }); return results.join('\n'); }, }, { name: 'verify_deployment', description: `Verify a frontend deployment by checking the deployed URL. Performs: - URL health check (HTTP status) - HTML content validation - Asset loading verification - Response time measurement - Basic smoke tests - SSL certificate check Use this after deploying to verify the deployment was successful.`, parameters: { type: 'object', properties: { url: { type: 'string', description: 'URL of the deployed frontend (required)', }, checkAssets: { type: 'boolean', description: 'Check if CSS/JS assets are accessible (default: true)', }, expectedContent: { type: 'string', description: 'String that should be present in the HTML', }, expectedTitle: { type: 'string', description: 'Expected page title', }, timeout: { type: 'number', description: 'Request timeout in milliseconds (default: 10000)', }, }, required: ['url'], }, handler: async (args) => { const rawUrl = args['url']; const checkAssets = args['checkAssets'] !== false; const expectedContent = args['expectedContent']; const expectedTitle = args['expectedTitle']; const timeout = args['timeout'] || 10000; // Validate URL const urlValidation = validateUrl(rawUrl); if (!urlValidation.valid) { return `โŒ Invalid URL: ${urlValidation.error}`; } const url = urlValidation.normalized; const results = [`๐ŸŒ Deployment Verification\n`]; results.push(`URL: ${url}\n`); const startTime = Date.now(); const response = await fetchUrl(url, timeout); const responseTime = Date.now() - startTime; results.push(`โฑ๏ธ Response time: ${responseTime}ms`); if (response.error) { results.push(`\nโŒ Failed to reach URL: ${response.error}`); if (response.errorType) { results.push(`Error type: ${response.errorType}`); } return results.join('\n'); } // HTTP status check with helpful context if (response.status >= 200 && response.status < 300) { results.push(`โœ… HTTP Status: ${response.status}`); } else { let statusContext = ''; if (response.status === 404) statusContext = ' (page not found - check URL path)'; else if (response.status === 403) statusContext = ' (forbidden - authentication may be required)'; else if (response.status === 500) statusContext = ' (server error - check server logs)'; else if (response.status === 502) statusContext = ' (bad gateway - upstream server issue)'; else if (response.status === 503) statusContext = ' (service unavailable - server may be overloaded)'; results.push(`โŒ HTTP Status: ${response.status}${statusContext}`); } // SSL check if (url.startsWith('https://')) { results.push(`โœ… HTTPS enabled`); } else { results.push(`โš ๏ธ Not using HTTPS`); } // Content checks const body = response.body; // Check for HTML doctype if (body.toLowerCase().includes('<!doctype html')) { results.push(`โœ… Valid HTML document`); } else { results.push(`โš ๏ธ HTML doctype not found`); } // Check for expected content if (expectedContent) { if (body.includes(expectedContent)) { results.push(`โœ… Expected content found: "${expectedContent.slice(0, 50)}..."`); } else { results.push(`โŒ Expected content not found: "${expectedContent.slice(0, 50)}..."`); } } // Check title const titleMatch = body.match(/<title>([^<]*)<\/title>/i); const actualTitle = titleMatch?.[1] || ''; if (expectedTitle) { if (actualTitle.includes(expectedTitle)) { results.push(`โœ… Title matches: "${actualTitle}"`); } else { results.push(`โŒ Title mismatch. Expected: "${expectedTitle}", Got: "${actualTitle}"`); } } else if (actualTitle) { results.push(`๐Ÿ“„ Page title: "${actualTitle}"`); } // Check for common SPA indicators if (body.includes('id="root"') || body.includes('id="app"') || body.includes('id="__next"')) { results.push(`โœ… SPA mount point detected`); } // Extract and check assets if (checkAssets) { results.push(`\n๐Ÿ” Asset Verification:`); // Find JS files const jsMatches = body.match(/src="([^"]+\.js[^"]*)"/g) || []; const jsFiles = jsMatches.map((m) => m.match(/src="([^"]+)"/)?.[1]).filter(Boolean); // Find CSS files const cssMatches = body.match(/href="([^"]+\.css[^"]*)"/g) || []; const cssFiles = cssMatches.map((m) => m.match(/href="([^"]+)"/)?.[1]).filter(Boolean); results.push(`Found ${jsFiles.length} JavaScript file(s)`); results.push(`Found ${cssFiles.length} CSS file(s)`); // Check first few assets const assetsToCheck = [...jsFiles.slice(0, 2), ...cssFiles.slice(0, 2)]; for (const assetPath of assetsToCheck) { if (!assetPath) continue; const assetUrl = assetPath.startsWith('http') ? assetPath : new URL(assetPath, url).toString(); const assetResponse = await fetchUrl(assetUrl, 5000); if (assetResponse.status === 200) { results.push(` โœ… ${assetPath.slice(0, 50)}...`); } else { results.push(` โŒ ${assetPath.slice(0, 50)}... (${assetResponse.status || assetResponse.error})`); } } } // Summary results.push(`\n๐Ÿ“‹ Summary:`); if (response.status >= 200 && response.status < 300 && responseTime < 3000) { results.push(`โœ… Deployment appears healthy`); } else if (response.status >= 200 && response.status < 300) { results.push(`โš ๏ธ Deployment working but slow (${responseTime}ms)`); } else { results.push(`โŒ Deployment issues detected`); } return results.join('\n'); }, }, { name: 'run_accessibility_tests', description: `Run accessibility tests on a URL or HTML file using axe-core. Checks for: - WCAG 2.1 Level A and AA compliance - Color contrast issues - Missing alt text - Form label issues - Keyboard navigation problems - ARIA attribute errors Requires: axe-core (will offer to install if not found)`, parameters: { type: 'object', properties: { url: { type: 'string', description: 'URL to test (can be local dev server)', }, htmlFile: { type: 'string', description: 'Path to HTML file to test (alternative to URL)', }, rules: { type: 'string', enum: ['wcag2a', 'wcag2aa', 'wcag2aaa', 'best-practice', 'all'], description: 'Accessibility rules to check (default: wcag2aa)', }, timeout: { type: 'number', description: 'Timeout in milliseconds (default: 60000)', }, }, }, handler: async (args) => { const rawUrl = args['url']; const rawHtmlFile = args['htmlFile']; const rules = args['rules'] || 'wcag2aa'; const timeout = args['timeout'] || 60000; if (!rawUrl && !rawHtmlFile) { return `Please provide either a URL or HTML file path to test. Examples: - URL: http://localhost:3000 - HTML file: ./build/index.html`; } // Validate URL if provided let validatedUrl; if (rawUrl) { const urlValidation = validateUrl(rawUrl); if (!urlValidation.valid) { return `โŒ Invalid URL: ${urlValidation.error}`; } validatedUrl = urlValidation.normalized; } // Validate file path if provided (with path traversal protection) let validatedHtmlFile; if (rawHtmlFile) { const fileValidation = validateFilePath(rawHtmlFile, workingDir); if (!fileValidation.valid) { return `โŒ Invalid file path: ${fileValidation.error}`; } validatedHtmlFile = fileValidation.resolved; } // Check for axe-core in project const packageJsonPath = path.join(workingDir, 'package.json'); let hasAxe = false; if (fs.existsSync(packageJsonPath)) { const content = fs.readFileSync(packageJsonPath, 'utf-8'); const { data: pkg } = safeJsonParse(content, 'package.json'); if (pkg) { const deps = { ...pkg.dependencies, ...pkg.devDependencies }; hasAxe = !!(deps['axe-core'] || deps['@axe-core/puppeteer'] || deps['@axe-core/playwright']); } } // Try to use pa11y or axe-cli if available const result = await runCommand('which pa11y || which axe', workingDir, 5000); const hasCli = result.exitCode === 0; if (!hasAxe && !hasCli) { return `Accessibility testing tools not found. Install one of the following: Option 1 - pa11y (recommended for CLI): npm install -g pa11y Option 2 - axe-cli: npm install -g @axe-core/cli Option 3 - Add to project: npm install -D axe-core @axe-core/playwright After installation, run this tool again.`; } const target = validatedUrl || validatedHtmlFile; const results = [`โ™ฟ Accessibility Testing\n`]; results.push(`Target: ${target}`); results.push(`Rules: ${rules}\n`); // Try pa11y first if (hasCli) { let command = `pa11y "${target}" --standard ${rules.toUpperCase().replace('WCAG2', 'WCAG2').replace('aa', 'AA').replace('a', 'A')} --reporter json 2>&1`; if (validatedUrl) { command = `pa11y "${validatedUrl}" --reporter cli --standard WCAG2AA 2>&1`; } const testResult = await runCommand(command, workingDir, timeout); if (testResult.exitCode === 0 && !testResult.stdout.includes('Error:')) { results.push(`โœ… No accessibility violations found!\n`); } else if (testResult.stdout.includes('Error:')) { results.push(`โš ๏ธ Could not run pa11y: ${truncateOutput(testResult.stdout, 500, 'output')}`); } else { results.push(`โŒ Accessibility issues found:\n`); results.push(truncateOutput(testResult.stdout, 8000, 'output')); } if (testResult.stderr && !testResult.stderr.includes('Deprecation')) { results.push(`\nWarnings:\n${truncateOutput(testResult.stderr, 2000, 'warnings')}`); } } else { // Fallback to basic HTML analysis results.push(`Using basic HTML analysis (install pa11y for comprehensive testing)\n`); if (validatedHtmlFile && fs.existsSync(validatedHtmlFile)) { const html = fs.readFileSync(validatedHtmlFile, 'utf-8'); // Basic checks const issues = []; // Check for images without alt const imgWithoutAlt = html.match(/<img(?![^>]*\balt=)[^>]*>/gi); if (imgWithoutAlt?.length) { issues.push(`- ${imgWithoutAlt.length} image(s) missing alt attribute`); } // Check for inputs without labels const inputsWithoutId = html.match(/<input(?![^>]*\bid=)[^>]*>/gi); if (inputsWithoutId?.length) { issues.push(`- ${inputsWithoutId.length} input(s) may be missing associated labels`); } // Check for lang attribute if (!html.includes('lang=')) { issues.push(`- Missing lang attribute on <html> element`); } // Check for viewport if (!html.includes('viewport')) { issues.push(`- Missing viewport meta tag`); } if (issues.length > 0) { results.push(`Found ${issues.length} potential issue(s):\n${issues.join('\n')}`); } else { results.push(`โœ… No basic accessibility issues detected`); } } else if (validatedUrl) { const response = await fetchUrl(validatedUrl); if (response.status === 200) { // Basic analysis of fetched HTML const html = response.body; const issues = []; if (!html.includes('lang=')) { issues.push(`- Missing lang attribute`); } const imgWithoutAlt = html.match(/<img(?![^>]*\balt=)[^>]*>/gi); if (imgWithoutAlt?.length) { issues.push(`- ${imgWithoutAlt.length} image(s) missing alt attribute`); } if (issues.length > 0) { results.push(`Found ${issues.length} potential issue(s):\n${issues.join('\n')}`); } else { results.push(`โœ… No basic accessibility issues detected`); } } } } return results.join('\n'); }, }, { name: 'run_lighthouse', description: `Run Lighthouse performance audit on a URL. Generates reports for: - Performance (LCP, FID, CLS, etc.) - Accessibility - Best Practices - SEO - PWA (optional) Requires: lighthouse CLI or Chrome browser`, parameters: { type: 'object', properties: { url: { type: 'string', description: 'URL to audit (required)', }, categories: { type: 'array', items: { type: 'string' }, description: 'Categories to audit: performance, accessibility, best-practices, seo, pwa', }, output: { type: 'string', enum: ['json', 'html', 'csv'], description: 'Output format (default: json)', }, outputPath: { type: 'string', description: 'Path to save report (optional)', }, device: { type: 'string', enum: ['mobile', 'desktop'], description: 'Device emulation (default: mobile)', }, timeout: { type: 'number', description: 'Timeout in milliseconds (default: 120000)', }, }, required: ['url'], }, handler: async (args) => { const rawUrl = args['url']; const categories = args['categories'] || ['performance', 'accessibility', 'best-practices', 'seo']; const output = args['output'] || 'json'; const rawOutputPath = args['outputPath']; const device = args['device'] || 'mobile'; const timeout = args['timeout'] || 120000; // Validate URL const urlValidation = validateUrl(rawUrl); if (!urlValidation.valid) { return `โŒ Invalid URL: ${urlValidation.error}`; } const url = urlValidation.normalized; // Validate output path if provided (with path traversal protection) let outputPath; if (rawOutputPath) { const pathValidation = validateFilePath(rawOutputPath, workingDir); if (!pathValidation.valid) { return `โŒ Invalid output path: ${pathValidation.error}`; } outputPath = pathValidation.resolved; } // Check for lighthouse const checkResult = await runCommand('which lighthouse', workingDir, 5000); if (checkResult.exitCode !== 0) { return `Lighthouse CLI not found. Install with: npm install -g lighthouse Or use Chrome DevTools Lighthouse panel for manual audits.`; } const results = [`๐Ÿ”ฆ Lighthouse Audit\n`]; results.push(`URL: ${url}`); results.push(`Device: ${device}`); results.push(`Categories: ${categories.join(', ')}\n`); // Build command let command = `lighthouse "${url}" --output=${output} --quiet`; command += ` --only-categories=${categories.join(',')}`; command += device === 'desktop' ? ' --preset=desktop' : ''; command += ' --chrome-flags="--headless --no-sandbox"'; if (outputPath) { command += ` --output-path="${outputPath}"`; } const auditResult = await runCommand(command, workingDir, timeout); if (auditResult.exitCode !== 0) { results.push(`โŒ Lighthouse audit failed:\n${truncateOutput(auditResult.stderr || auditResult.stdout, 3000, 'error output')}`); return results.join('\n'); } // Parse JSON output if (output === 'json') { const { data: report, error: parseError } = safeJsonParse(auditResult.stdout, 'Lighthouse report'); if (parseError || !report) { results.push(`โš ๏ธ Could not parse Lighthouse results: ${parseError}`); results.push(`Raw output:\n${truncateOutput(auditResult.stdout, 5000, 'output')}`); return results.join('\n'); } const cats = report.categories || {}; results.push(`๐Ÿ“Š Scores:\n`); for (const [_catId, cat] of Object.entries(cats)) { const score = Math.round((cat.score || 0) * 100); const emoji = score >= 90 ? '๐ŸŸข' : score >= 50 ? '๐ŸŸก' : '๐Ÿ”ด'; results.push(`${emoji} ${cat.title}: ${score}/100`); } // Show key metrics if available const metrics = report.audits; if (metrics) { results.push(`\n๐Ÿ“ˆ Key Metrics:`); const keyMetrics = [ { id: 'first-contentful-paint', label: 'First Contentful Paint' }, { id: 'largest-contentful-paint', label: 'Largest Contentful Paint' }, { id: 'total-blocking-time', label: 'Total Blocking Time' }, { id: 'cumulative-layout-shift', label: 'Cumulative Layout Shift' }, { id: 'speed-index', label: 'Speed Index' }, ]; for (const metric of keyMetrics) { const audit = metrics[metric.id]; if (audit) { const score = Math.round((audit.score || 0) * 100); const emoji = score >= 90 ? 'โœ…' : score >= 50 ? 'โš ๏ธ' : 'โŒ'; results.push(`${emoji} ${metric.label}: ${audit.displayValue || 'N/A'}`); } } } } else { results.push(`โœ… Report generated`); if (outputPath) { results.push(`Saved to: ${outputPath}`); } } return results.join('\n'); }, }, { name: 'frontend_test_workflow', description: `Run a complete frontend testing workflow. Executes a series of tests in order: 1. Build verification 2. Unit tests (if present) 3. E2E tests (if present) 4. Accessibility tests 5. Performance audit (optional) Perfect for CI/CD pipelines or pre-deployment verification.`, parameters: { type: 'object', properties: { skipBuild: { type: 'boolean', description: 'Skip build verification (default: false)', }, skipUnit: { type: 'boolean', description: 'Skip unit tests (default: false)', }, skipE2e: { type: 'boolean', description: 'Skip E2E tests (default: false)', }, skipA11y: { type: 'boolean', description: 'Skip accessibility tests (default: false)', }, runLighthouse: { type: 'boolean', description: 'Run Lighthouse performance audit (default: false)', }, baseUrl: { type: 'string', description: 'Base URL for tests (uses local server if not specified)', }, timeout: { type: 'number', description: 'Timeout per step in milliseconds (default: 300000)', }, }, }, handler: async (args) => { const skipBuild = args['skipBuild'] === true; const skipUnit = args['skipUnit'] === true; const skipE2e = args['skipE2e'] === true; const skipA11y = args['skipA11y'] === true; const runLighthouseArg = args['runLighthouse'] === true; const rawBaseUrl = args['baseUrl']; const timeout = args['timeout'] || 300000; // Validate baseUrl if provided let baseUrl; if (rawBaseUrl) { const urlValidation = validateUrl(rawBaseUrl); if (!urlValidation.valid) { return `โŒ Invalid baseUrl: ${urlValidation.error}`; } baseUrl = urlValidation.normalized; } const results = [`๐Ÿš€ Frontend Testing Workflow\n`]; const summary = []; // Step 1: Build Verification if (!skipBuild) { results.push(`\nโ”โ”โ” Step 1: Build Verification โ”โ”โ”\n`); const startTime = Date.now(); // Run build first const buildResult = await runCommand('npm run build', workingDir, timeout); const buildTime = Date.now() - startTime; if (buildResult.exitCode === 0) { results.push(`โœ… Build succeeded (${buildTime}ms)\n`); summary.push({ step: 'Build', status: 'PASS', time: buildTime }); // Verify output const detectedFramework = detectFramework(workingDir); const buildConfig = BUILD_CONFIGS[detectedFramework] ?? BUILD_CONFIGS['react']; const buildDir = path.join(workingDir, buildConfig.outputDir); if (fs.existsSync(buildDir)) { const analysis = analyzeBundle(buildDir); results.push(`Bundle size: ${formatBytes(analysis.totalSize)}`); } } else { results.push(`โŒ Build failed\n${truncateOutput(buildResult.stderr || buildResult.stdout, 3000, 'build output')}`); summary.push({ step: 'Build', status: 'FAIL', time: buildTime }); } } // Step 2: Unit Tests if (!skipUnit) { results.push(`\nโ”โ”โ” Step 2: Unit Tests โ”โ”โ”\n`); const startTime = Date.now(); const testResult = await runCommand('npm test -- --watchAll=false --passWithNoTests', workingDir, timeout); const testTime = Date.now() - startTime; if (testResult.exitCode === 0) { results.push(`โœ… Unit tests passed (${testTime}ms)\n`); summary.push({ step: 'Unit Tests', status: 'PASS', time: testTime }); } else { results.push(`โŒ Unit tests failed\n${truncateOutput(testResult.stdout, 3000, 'test output')}`);