/**
* WebPageTest Performance Analyzer (via Playwright automation)
* Web UI: https://www.webpagetest.org/
* Free tier: 300 tests/month
*
* Uses Playwright to automate the web UI since the API requires paid access.
*/
import { Page } from 'playwright';
import { getBrowserManager } from '../shared/browser-utils.js';
export interface WebPageTestOptions {
/** Test location (default: Dulles, VA - Chrome) */
location?: string;
/** Connection type (default: Cable) */
connection?: string;
/** Number of test runs (default: 1) */
runs?: number;
/** Timeout in milliseconds (default: 300000 = 5 minutes) */
timeout?: number;
/** Wait for test to complete before returning (default: false) */
waitForResults?: boolean;
}
export interface WebPageTestMetrics {
loadTime?: number;
firstContentfulPaint?: number;
speedIndex?: number;
largestContentfulPaint?: number;
timeToInteractive?: number;
totalBlockingTime?: number;
cumulativeLayoutShift?: number;
}
export interface WebPageTestResult {
tool: 'webpagetest';
success: boolean;
url: string;
test_id?: string;
results_url?: string;
summary?: WebPageTestMetrics;
performance_grade?: string;
security_grade?: string;
status: 'pending' | 'running' | 'complete' | 'error';
error?: string;
}
/**
* Submit a test to WebPageTest via web UI automation
*/
async function submitWebPageTest(
url: string,
options: WebPageTestOptions = {}
): Promise<{ testId: string; resultsUrl: string }> {
const browserManager = await getBrowserManager();
const page = await browserManager.newPage();
try {
const timeout = options.timeout || 300000; // 5 minutes default
// Navigate to WebPageTest
await page.goto('https://www.webpagetest.org/', { timeout });
// Enter URL in the test input
await page.fill('input[name="url"]', url);
// Select location if provided (default is usually Dulles:Chrome)
if (options.location) {
await page.selectOption('select[name="location"]', options.location);
}
// Set number of runs if provided
if (options.runs) {
await page.fill('input[name="runs"]', options.runs.toString());
}
// Submit the test
await Promise.all([
page.waitForNavigation({ timeout }),
page.click('input[type="submit"], button[type="submit"]'),
]);
// Wait for redirect to results page
await page.waitForURL(/.*\/result\/.*/, { timeout });
const resultsUrl = page.url();
const testIdMatch = resultsUrl.match(/\/result\/([^\/]+)/);
if (!testIdMatch) {
throw new Error('Could not extract test ID from results URL');
}
const testId = testIdMatch[1];
return { testId, resultsUrl };
} finally {
await page.close();
}
}
/**
* Poll WebPageTest results page until test completes
*/
async function waitForWebPageTestResults(
testId: string,
timeout: number = 300000
): Promise<WebPageTestMetrics> {
const browserManager = await getBrowserManager();
const page = await browserManager.newPage();
try {
const resultsUrl = `https://www.webpagetest.org/result/${testId}/`;
await page.goto(resultsUrl, { timeout });
// Wait for test to complete (look for results table)
await page.waitForSelector('.results-container, #test-results, .result', {
timeout,
state: 'visible',
});
// Give the page a moment to fully render
await page.waitForTimeout(2000);
// Scrape metrics from the results page
const metrics: WebPageTestMetrics = {};
// Try to extract key metrics
// Note: WebPageTest's DOM structure may vary, so we try multiple selectors
// Load Time
const loadTimeText = await page.textContent('[data-metric="loadTime"], .loadTime, td:has-text("Load Time") + td').catch(() => null);
if (loadTimeText) {
metrics.loadTime = parseFloat(loadTimeText.replace(/[^0-9.]/g, ''));
}
// First Contentful Paint
const fcpText = await page.textContent('[data-metric="firstContentfulPaint"], .firstContentfulPaint').catch(() => null);
if (fcpText) {
metrics.firstContentfulPaint = parseFloat(fcpText.replace(/[^0-9.]/g, ''));
}
// Speed Index
const siText = await page.textContent('[data-metric="speedIndex"], .speedIndex').catch(() => null);
if (siText) {
metrics.speedIndex = parseFloat(siText.replace(/[^0-9.]/g, ''));
}
// Largest Contentful Paint
const lcpText = await page.textContent('[data-metric="largestContentfulPaint"], .largestContentfulPaint').catch(() => null);
if (lcpText) {
metrics.largestContentfulPaint = parseFloat(lcpText.replace(/[^0-9.]/g, ''));
}
// Time to Interactive
const ttiText = await page.textContent('[data-metric="timeToInteractive"], .timeToInteractive').catch(() => null);
if (ttiText) {
metrics.timeToInteractive = parseFloat(ttiText.replace(/[^0-9.]/g, ''));
}
// Total Blocking Time
const tbtText = await page.textContent('[data-metric="totalBlockingTime"], .totalBlockingTime').catch(() => null);
if (tbtText) {
metrics.totalBlockingTime = parseFloat(tbtText.replace(/[^0-9.]/g, ''));
}
// Cumulative Layout Shift
const clsText = await page.textContent('[data-metric="cumulativeLayoutShift"], .cumulativeLayoutShift').catch(() => null);
if (clsText) {
metrics.cumulativeLayoutShift = parseFloat(clsText.replace(/[^0-9.]/g, ''));
}
return metrics;
} finally {
await page.close();
}
}
/**
* Extract grades from WebPageTest results page
*/
async function getWebPageTestGrades(testId: string): Promise<{ performance?: string; security?: string }> {
const browserManager = await getBrowserManager();
const page = await browserManager.newPage();
try {
const resultsUrl = `https://www.webpagetest.org/result/${testId}/`;
await page.goto(resultsUrl, { timeout: 30000 });
const grades: { performance?: string; security?: string } = {};
// Try to find performance grade
const perfGrade = await page.textContent('.performance-grade, [data-grade="performance"]').catch(() => null);
if (perfGrade) {
grades.performance = perfGrade.trim();
}
// Try to find security grade
const secGrade = await page.textContent('.security-grade, [data-grade="security"]').catch(() => null);
if (secGrade) {
grades.security = secGrade.trim();
}
return grades;
} finally {
await page.close();
}
}
/**
* Analyze website using WebPageTest
*
* @param url - The URL to test
* @param options - Test configuration options
* @returns WebPageTest results (immediate if waitForResults=false, complete if waitForResults=true)
*/
export async function analyzeWebPageTest(
url: string,
options: WebPageTestOptions = {}
): Promise<WebPageTestResult> {
try {
// Submit test
const { testId, resultsUrl } = await submitWebPageTest(url, options);
// If not waiting for results, return immediately with test ID
if (!options.waitForResults) {
return {
tool: 'webpagetest',
success: true,
url,
test_id: testId,
results_url: resultsUrl,
status: 'pending',
};
}
// Wait for results
const metrics = await waitForWebPageTestResults(testId, options.timeout);
const grades = await getWebPageTestGrades(testId);
return {
tool: 'webpagetest',
success: true,
url,
test_id: testId,
results_url: resultsUrl,
summary: metrics,
performance_grade: grades.performance,
security_grade: grades.security,
status: 'complete',
};
} catch (error) {
return {
tool: 'webpagetest',
success: false,
url,
status: 'error',
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Submit a WebPageTest and return test ID immediately
* Use this for async testing, then poll with getWebPageTestResults()
*/
export async function submitWebPageTestAsync(
url: string,
options: WebPageTestOptions = {}
): Promise<WebPageTestResult> {
return analyzeWebPageTest(url, { ...options, waitForResults: false });
}
/**
* Get results for a previously submitted WebPageTest
*/
export async function getWebPageTestResults(testId: string): Promise<WebPageTestResult> {
try {
const metrics = await waitForWebPageTestResults(testId);
const grades = await getWebPageTestGrades(testId);
return {
tool: 'webpagetest',
success: true,
url: `https://www.webpagetest.org/result/${testId}/`,
test_id: testId,
results_url: `https://www.webpagetest.org/result/${testId}/`,
summary: metrics,
performance_grade: grades.performance,
security_grade: grades.security,
status: 'complete',
};
} catch (error) {
return {
tool: 'webpagetest',
success: false,
url: '',
test_id: testId,
status: 'error',
error: error instanceof Error ? error.message : String(error),
};
}
}