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
JavaScript
/**
* 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')}`);