UNPKG

@bschauer/webtools-mcp-server

Version:

MCP server providing web analysis tools including screenshot, debug, performance, security, accessibility, SEO, and asset optimization capabilities

587 lines (508 loc) 21.6 kB
/** * Performance Test Framework * Runs performance tests across different devices and network conditions */ import { logInfo, logError } from "../../../utils/logging.js"; import { BROWSER_HEADERS } from "../../../config/constants.js"; import { checkSiteAvailability } from "../../../utils/html.js"; import { fetchWithRetry } from "../../../utils/fetch.js"; import { getDeviceConfig } from "../../../config/devices.js"; import { getNetworkCondition } from "../../../config/network_conditions.js"; import { configureCdpSession } from "../../../utils/cdp_helpers.js"; import { runWebVitalsAnalysis } from "../web_vitals/index.js"; import { runNetworkMonitor } from "../network/index.js"; import { runCoverageAnalysis } from "../coverage/index.js"; /** * Run performance tests across different scenarios * @param {Object} args - The tool arguments * @returns {Promise<Object>} Performance test results */ export async function runPerformanceTest(args) { const { url, timeoutMs = 30000, waitAfterLoadMs = 3000, useProxy = false, ignoreSSLErrors = false, devices = ["desktop", "mobile"], networkConditions = ["WiFi", "4G", "3G"], tests = ["web_vitals", "network", "coverage"], compareResults = true, baselineDevice = "desktop", baselineNetwork = "WiFi", includeScreenshots = true, runMultipleTimes = false, numberOfRuns = 3, } = args; try { // Check site availability first const availability = await checkSiteAvailability(url, { ignoreSSLErrors }, fetchWithRetry); if (!availability.available) { return { content: [ { type: "text", text: JSON.stringify( { error: "Site unavailable", details: availability.error, recommendation: availability.recommendation, retryable: true, url, }, null, 2 ), }, ], }; } logInfo("performance_test", "Starting performance test framework", { url, devices, networkConditions, tests }); // Prepare test scenarios const scenarios = []; for (const deviceName of devices) { for (const networkName of networkConditions) { scenarios.push({ deviceName, networkName, isBaseline: deviceName === baselineDevice && networkName === baselineNetwork, }); } } // Run tests for each scenario const results = []; for (const scenario of scenarios) { logInfo("performance_test", `Running tests for scenario`, { device: scenario.deviceName, network: scenario.networkName, isBaseline: scenario.isBaseline, }); const scenarioResults = { device: scenario.deviceName, network: scenario.networkName, isBaseline: scenario.isBaseline, testResults: {}, }; // Run Web Vitals test if requested if (tests.includes("web_vitals")) { try { const webVitalsResult = await runWebVitalsAnalysis({ url, timeoutMs, waitAfterLoadMs, deviceName: scenario.deviceName, networkConditionName: scenario.networkName, useProxy, ignoreSSLErrors, runMultipleTimes, numberOfRuns, }); // Extract the JSON result from the content const webVitalsData = JSON.parse(webVitalsResult.content[0].text); scenarioResults.testResults.webVitals = webVitalsData; } catch (error) { logError("performance_test", "Web Vitals test failed", error, { device: scenario.deviceName, network: scenario.networkName, }); scenarioResults.testResults.webVitals = { error: "Test failed", details: error.message, }; } } // Run Network test if requested if (tests.includes("network")) { try { const networkResult = await runNetworkMonitor({ url, timeoutMs, waitAfterLoadMs, deviceName: scenario.deviceName, networkConditionName: scenario.networkName, useProxy, ignoreSSLErrors, captureHeaders: false, // Reduce result size }); // Extract the JSON result from the content const networkData = JSON.parse(networkResult.content[0].text); scenarioResults.testResults.network = networkData; } catch (error) { logError("performance_test", "Network test failed", error, { device: scenario.deviceName, network: scenario.networkName, }); scenarioResults.testResults.network = { error: "Test failed", details: error.message, }; } } // Run Coverage test if requested if (tests.includes("coverage")) { try { const coverageResult = await runCoverageAnalysis({ url, timeoutMs, waitAfterLoadMs, deviceName: scenario.deviceName, networkConditionName: scenario.networkName, useProxy, ignoreSSLErrors, }); // Extract the JSON result from the content const coverageData = JSON.parse(coverageResult.content[0].text); scenarioResults.testResults.coverage = coverageData; } catch (error) { logError("performance_test", "Coverage test failed", error, { device: scenario.deviceName, network: scenario.networkName, }); scenarioResults.testResults.coverage = { error: "Test failed", details: error.message, }; } } // Take screenshot if requested if (includeScreenshots) { try { const puppeteer = await import("puppeteer"); const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu", useProxy ? `--proxy-server=${process.env.PROXY_URL || ""}` : "", "--ignore-certificate-errors"].filter(Boolean), ignoreHTTPSErrors: ignoreSSLErrors, }); const page = await browser.newPage(); // Set HTTP headers await page.setExtraHTTPHeaders(BROWSER_HEADERS); // Get device configuration const deviceConfig = getDeviceConfig({ deviceName: scenario.deviceName }); // Set viewport based on device config await page.setViewport({ width: deviceConfig.width, height: deviceConfig.height, deviceScaleFactor: deviceConfig.deviceScaleFactor, isMobile: deviceConfig.isMobile, hasTouch: deviceConfig.hasTouch, isLandscape: deviceConfig.isLandscape, }); // Set custom user agent if provided if (deviceConfig.userAgent) { await page.setUserAgent(deviceConfig.userAgent); } // Create CDP session const client = await page.target().createCDPSession(); // Configure CDP session with network conditions const networkConditionConfig = getNetworkCondition({ networkConditionName: scenario.networkName }); await configureCdpSession(client, { networkCondition: networkConditionConfig, }); // Navigate to the page await page.goto(url, { waitUntil: "networkidle0", timeout: timeoutMs, }); // Wait for a moment await new Promise((resolve) => setTimeout(resolve, waitAfterLoadMs)); // Take screenshot const screenshot = await page.screenshot({ type: "jpeg", quality: 80, fullPage: false, }); // Convert to base64 scenarioResults.screenshot = `data:image/jpeg;base64,${screenshot.toString("base64")}`; // Close browser await browser.close(); } catch (error) { logError("performance_test", "Screenshot failed", error, { device: scenario.deviceName, network: scenario.networkName, }); scenarioResults.screenshot = null; } } results.push(scenarioResults); } // Compare results if requested let comparison = null; if (compareResults && results.length > 1) { const baseline = results.find((result) => result.isBaseline); if (baseline) { comparison = { baseline: { device: baseline.device, network: baseline.network, }, comparisons: [], }; for (const result of results) { if (result === baseline) continue; const comparisonResult = { device: result.device, network: result.network, metrics: {}, }; // Compare Web Vitals if (tests.includes("web_vitals") && baseline.testResults.webVitals && !baseline.testResults.webVitals.error && result.testResults.webVitals && !result.testResults.webVitals.error) { const baselineWebVitals = baseline.testResults.webVitals.webVitals; const currentWebVitals = result.testResults.webVitals.webVitals; comparisonResult.metrics.webVitals = { lcp: compareMetric(currentWebVitals.lcp?.value, baselineWebVitals.lcp?.value, false), cls: compareMetric(currentWebVitals.cls?.value, baselineWebVitals.cls?.value, false), fid: compareMetric(currentWebVitals.fid?.value, baselineWebVitals.fid?.value, false), inp: compareMetric(currentWebVitals.inp?.value, baselineWebVitals.inp?.value, false), ttfb: compareMetric(currentWebVitals.ttfb?.value, baselineWebVitals.ttfb?.value, false), overallScore: compareMetric(currentWebVitals.overallScore, baselineWebVitals.overallScore, true), }; } // Compare Network if (tests.includes("network") && baseline.testResults.network && !baseline.testResults.network.error && result.testResults.network && !result.testResults.network.error) { const baselineNetwork = baseline.testResults.network.summary; const currentNetwork = result.testResults.network.summary; comparisonResult.metrics.network = { totalRequests: compareMetric(currentNetwork.totalRequests, baselineNetwork.totalRequests, false), totalSize: compareMetric(currentNetwork.totalSize, baselineNetwork.totalSize, false), totalDuration: compareMetric(currentNetwork.totalDuration, baselineNetwork.totalDuration, false), }; } // Compare Coverage if (tests.includes("coverage") && baseline.testResults.coverage && !baseline.testResults.coverage.error && result.testResults.coverage && !result.testResults.coverage.error) { const baselineCoverage = baseline.testResults.coverage; const currentCoverage = result.testResults.coverage; comparisonResult.metrics.coverage = { unusedJsBytes: compareMetric(currentCoverage.summary?.unusedJsBytes, baselineCoverage.summary?.unusedJsBytes, false), unusedCssBytes: compareMetric(currentCoverage.summary?.unusedCssBytes, baselineCoverage.summary?.unusedCssBytes, false), totalJsBytes: compareMetric(currentCoverage.summary?.totalJsBytes, baselineCoverage.summary?.totalJsBytes, false), totalCssBytes: compareMetric(currentCoverage.summary?.totalCssBytes, baselineCoverage.summary?.totalCssBytes, false), }; } comparison.comparisons.push(comparisonResult); } } } // Generate recommendations based on all results const recommendations = generateRecommendations(results, tests); logInfo("performance_test", "Performance test framework completed successfully", { url }); return { content: [ { type: "text", text: JSON.stringify( { url, scenarios: scenarios.map((s) => ({ device: s.deviceName, network: s.networkName, isBaseline: s.isBaseline })), results, comparison, recommendations, }, null, 2 ), }, ], }; } catch (error) { logError("performance_test", "Performance test framework failed", error, { url }); return { content: [ { type: "text", text: JSON.stringify( { error: "Performance test framework failed", details: error.message, recommendation: "Please try again with different settings", retryable: true, url, errorType: error.name, }, null, 2 ), }, ], }; } } /** * Compare a metric between current and baseline values * @param {number} current - Current value * @param {number} baseline - Baseline value * @param {boolean} higherIsBetter - Whether higher values are better * @returns {Object} Comparison result */ function compareMetric(current, baseline, higherIsBetter) { if (current === undefined || baseline === undefined || current === null || baseline === null) { return { current, baseline, difference: null, percentChange: null, improvement: null, }; } const difference = current - baseline; const percentChange = baseline !== 0 ? (difference / baseline) * 100 : null; let improvement = false; if (percentChange !== null) { improvement = higherIsBetter ? percentChange > 0 : percentChange < 0; } return { current, baseline, difference, percentChange, improvement, }; } /** * Generate recommendations based on test results * @param {Array} results - Test results for all scenarios * @param {Array} tests - Tests that were run * @returns {Array} Recommendations */ function generateRecommendations(results, tests) { const recommendations = []; // Find the worst performing device/network combinations if (tests.includes("web_vitals")) { const worstLCP = findWorstScenario(results, "webVitals", "lcp", "value", false); const worstCLS = findWorstScenario(results, "webVitals", "cls", "value", false); const worstINP = findWorstScenario(results, "webVitals", "inp", "value", false); if (worstLCP) { recommendations.push({ type: "device_specific_lcp", title: `Optimize LCP for ${worstLCP.device} on ${worstLCP.network}`, description: `LCP is ${worstLCP.value.toFixed(0)}ms on ${worstLCP.device}/${worstLCP.network}, which is significantly worse than other scenarios`, impact: "high", suggestions: ["Optimize images specifically for this device type", "Implement responsive loading strategies", "Consider device-specific critical CSS", "Test with real devices of this type"], }); } if (worstCLS) { recommendations.push({ type: "device_specific_cls", title: `Reduce Layout Shifts for ${worstCLS.device} on ${worstCLS.network}`, description: `CLS is ${worstCLS.value.toFixed(3)} on ${worstCLS.device}/${worstCLS.network}, which is significantly worse than other scenarios`, impact: "high", suggestions: ["Set explicit dimensions for images and media", "Ensure responsive layouts maintain stability", "Test layout behavior specifically on this device type", "Reserve space for dynamic content"], }); } if (worstINP) { recommendations.push({ type: "device_specific_inp", title: `Improve Interactivity for ${worstINP.device} on ${worstINP.network}`, description: `INP is ${worstINP.value.toFixed(0)}ms on ${worstINP.device}/${worstINP.network}, which is significantly worse than other scenarios`, impact: "high", suggestions: ["Optimize JavaScript execution for lower-powered devices", "Reduce main thread blocking during interactions", "Implement progressive enhancement for interactions", "Test with real devices of this type"], }); } } // Find network-specific issues if (tests.includes("network")) { const slowNetworkScenarios = results.filter((result) => result.network === "3G" && result.testResults.network && !result.testResults.network.error); if (slowNetworkScenarios.length > 0) { const largeResources = []; for (const scenario of slowNetworkScenarios) { const network = scenario.testResults.network; if (network.requests) { const largeResourcesInScenario = network.requests .filter((req) => req.size > 100000) .map((req) => ({ url: req.url, size: req.size, type: req.resourceType, device: scenario.device, })); largeResources.push(...largeResourcesInScenario); } } if (largeResources.length > 0) { recommendations.push({ type: "slow_network_optimization", title: "Optimize for slow networks", description: `Found ${largeResources.length} large resources that significantly impact performance on slow networks`, impact: "high", items: largeResources.slice(0, 5), suggestions: ["Implement adaptive loading based on network conditions", "Reduce resource sizes for slow connections", "Prioritize critical resources", "Consider using service workers for offline support"], }); } } } // Find device-specific coverage issues if (tests.includes("coverage")) { const mobileScenarios = results.filter((result) => result.device === "mobile" && result.testResults.coverage && !result.testResults.coverage.error); if (mobileScenarios.length > 0) { const unusedJsPercentages = mobileScenarios .map((scenario) => { const coverage = scenario.testResults.coverage; if (coverage.summary) { return { device: scenario.device, network: scenario.network, unusedJsPercent: coverage.summary.unusedJsPercent, }; } return null; }) .filter(Boolean); if (unusedJsPercentages.length > 0 && unusedJsPercentages.some((item) => item.unusedJsPercent > 40)) { recommendations.push({ type: "mobile_code_splitting", title: "Implement code splitting for mobile", description: "Mobile devices are loading significant amounts of unused JavaScript", impact: "high", items: unusedJsPercentages, suggestions: ["Implement device-specific code splitting", "Use dynamic imports for features not needed on mobile", "Create separate entry points for different device types", "Consider a progressive web app approach"], }); } } } // Add general recommendations if no specific issues found if (recommendations.length === 0) { recommendations.push({ type: "general_optimization", title: "Continue monitoring performance across devices", description: "No significant device or network-specific issues found", impact: "low", suggestions: ["Implement continuous performance monitoring", "Test with real devices regularly", "Consider implementing user-centric performance metrics", "Set performance budgets for different device types"], }); } return recommendations; } /** * Find the worst performing scenario for a specific metric * @param {Array} results - Test results for all scenarios * @param {string} testType - Type of test (webVitals, network, coverage) * @param {string} metricName - Name of the metric * @param {string} valueProp - Property name for the value * @param {boolean} higherIsBetter - Whether higher values are better * @returns {Object|null} Worst scenario or null if not found */ function findWorstScenario(results, testType, metricName, valueProp, higherIsBetter) { const validResults = results.filter( (result) => result.testResults[testType] && !result.testResults[testType].error && result.testResults[testType].webVitals && result.testResults[testType].webVitals[metricName] && result.testResults[testType].webVitals[metricName][valueProp] !== undefined ); if (validResults.length < 2) return null; // Calculate average value across all scenarios const values = validResults.map((result) => result.testResults[testType].webVitals[metricName][valueProp]); const average = values.reduce((sum, value) => sum + value, 0) / values.length; // Find scenarios that are significantly worse than average const threshold = 1.5; // 50% worse than average const significantlyWorse = validResults.filter((result) => { const value = result.testResults[testType].webVitals[metricName][valueProp]; return higherIsBetter ? value < average / threshold : value > average * threshold; }); if (significantlyWorse.length === 0) return null; // Return the worst one return significantlyWorse.sort((a, b) => { const valueA = a.testResults[testType].webVitals[metricName][valueProp]; const valueB = b.testResults[testType].webVitals[metricName][valueProp]; return higherIsBetter ? valueA - valueB : valueB - valueA; })[0]; }