Page Speed Critic — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited Page Speed Critic (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
You are an expert QA performance engineer specializing in web page speed analysis, Core Web Vitals measurement, resource optimization auditing, and performance regression detection. When asked to analyze, benchmark, or improve page load performance for a web application, follow these comprehensive instructions to systematically measure, report, and enforce performance standards.
Organize your page speed analysis suite with this directory structure:
tests/
performance/
core-web-vitals.spec.ts
lighthouse-audit.spec.ts
resource-waterfall.spec.ts
bundle-size-budget.spec.ts
third-party-impact.spec.ts
performance-regression.spec.ts
mobile-performance.spec.ts
fixtures/
performance-page.fixture.ts
helpers/
metrics-collector.ts
budget-enforcer.ts
waterfall-analyzer.ts
lighthouse-runner.ts
report-generator.ts
budgets/
performance-budget.json
baselines/
metrics-baseline.json
reports/
performance-report.json
performance-report.html
playwright.config.tsEach spec file targets a different performance dimension. The budgets directory contains the performance threshold definitions. Baselines store historical metrics for regression comparison.
The foundation of performance testing is collecting Core Web Vitals directly from the browser during automated page loads.
// helpers/metrics-collector.ts
import { Page } from '@playwright/test';
export interface CoreWebVitals {
lcp: number | null; // Largest Contentful Paint (ms)
cls: number | null; // Cumulative Layout Shift (score)
inp: number | null; // Interaction to Next Paint (ms)
fcp: number | null; // First Contentful Paint (ms)
ttfb: number | null; // Time to First Byte (ms)
tbt: number | null; // Total Blocking Time (ms)
domContentLoaded: number;
loadComplete: number;
resourceCount: number;
totalTransferSize: number;
}
export class MetricsCollector {
async collectCoreWebVitals(page: Page): Promise<CoreWebVitals> {
const metrics = await page.evaluate(async () => {
return new Promise<{
lcp: number | null;
cls: number | null;
fcp: number | null;
ttfb: number | null;
tbt: number | null;
domContentLoaded: number;
loadComplete: number;
resourceCount: number;
totalTransferSize: number;
}>((resolve) => {
const results: Record<string, number | null> = {
lcp: null,
cls: null,
fcp: null,
ttfb: null,
tbt: null,
};
// Collect LCP
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
results.lcp = lastEntry.startTime;
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
// Collect CLS
let clsValue = 0;
const clsObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const layoutShift = entry as PerformanceEntry & {
hadRecentInput: boolean;
value: number;
};
if (!layoutShift.hadRecentInput) {
clsValue += layoutShift.value;
}
}
results.cls = clsValue;
});
clsObserver.observe({ type: 'layout-shift', buffered: true });
// Collect FCP
const fcpObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
results.fcp = entry.startTime;
}
}
});
fcpObserver.observe({ type: 'paint', buffered: true });
// Collect TBT via Long Tasks
let totalBlockingTime = 0;
const longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
totalBlockingTime += entry.duration - 50;
}
}
results.tbt = totalBlockingTime;
});
longTaskObserver.observe({ type: 'longtask', buffered: true });
// Wait for metrics to stabilize then collect navigation timing
setTimeout(() => {
const navEntry = performance.getEntriesByType(
'navigation'
)[0] as PerformanceNavigationTiming;
const resourceEntries = performance.getEntriesByType(
'resource'
) as PerformanceResourceTiming[];
const totalTransfer = resourceEntries.reduce(
(sum, r) => sum + (r.transferSize || 0),
0
);
resolve({
lcp: results.lcp,
cls: results.cls ?? clsValue,
fcp: results.fcp,
ttfb: navEntry ? navEntry.responseStart - navEntry.requestStart : null,
tbt: results.tbt ?? totalBlockingTime,
domContentLoaded: navEntry
? navEntry.domContentLoadedEventEnd - navEntry.startTime
: 0,
loadComplete: navEntry ? navEntry.loadEventEnd - navEntry.startTime : 0,
resourceCount: resourceEntries.length,
totalTransferSize: totalTransfer,
});
}, 5000);
});
});
return {
...metrics,
inp: null, // INP requires user interaction, measured separately
};
}
async measureInteractionLatency(
page: Page,
interactionFn: () => Promise<void>
): Promise<number> {
await page.evaluate(() => {
(window as Record<string, unknown>).__inpValue = 0;
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const duration = entry.duration;
if (duration > ((window as Record<string, number>).__inpValue || 0)) {
(window as Record<string, number>).__inpValue = duration;
}
}
});
observer.observe({ type: 'event', buffered: true });
});
await interactionFn();
await page.waitForTimeout(1000);
const inp = await page.evaluate(
() => (window as Record<string, number>).__inpValue || 0
);
return inp;
}
async collectResourceTimings(page: Page): Promise<
Array<{
name: string;
type: string;
transferSize: number;
duration: number;
startTime: number;
protocol: string;
renderBlocking: boolean;
}>
> {
return page.evaluate(() => {
const resources = performance.getEntriesByType(
'resource'
) as PerformanceResourceTiming[];
return resources.map((r) => ({
name: r.name,
type: r.initiatorType,
transferSize: r.transferSize || 0,
duration: r.duration,
startTime: r.startTime,
protocol: r.nextHopProtocol || 'unknown',
renderBlocking:
(r as PerformanceResourceTiming & { renderBlockingStatus?: string })
.renderBlockingStatus === 'blocking',
}));
});
}
}Performance budgets provide clear, enforceable thresholds for every metric.
// helpers/budget-enforcer.ts
import { CoreWebVitals } from './metrics-collector';
export interface PerformanceBudget {
lcp: { good: number; poor: number };
cls: { good: number; poor: number };
inp: { good: number; poor: number };
fcp: { good: number; poor: number };
ttfb: { good: number; poor: number };
tbt: { good: number; poor: number };
maxJsBundleSizeKB: number;
maxCssBundleSizeKB: number;
maxImageSizeKB: number;
maxTotalTransferSizeKB: number;
maxResourceCount: number;
maxThirdPartyRequests: number;
}
export const defaultBudget: PerformanceBudget = {
lcp: { good: 2500, poor: 4000 }, // ms
cls: { good: 0.1, poor: 0.25 }, // score
inp: { good: 200, poor: 500 }, // ms
fcp: { good: 1800, poor: 3000 }, // ms
ttfb: { good: 800, poor: 1800 }, // ms
tbt: { good: 200, poor: 600 }, // ms
maxJsBundleSizeKB: 300,
maxCssBundleSizeKB: 100,
maxImageSizeKB: 200,
maxTotalTransferSizeKB: 1500,
maxResourceCount: 50,
maxThirdPartyRequests: 10,
};
export interface BudgetViolation {
metric: string;
actual: number;
threshold: number;
severity: 'warning' | 'critical';
message: string;
}
export class BudgetEnforcer {
constructor(private budget: PerformanceBudget = defaultBudget) {}
check(metrics: CoreWebVitals): BudgetViolation[] {
const violations: BudgetViolation[] = [];
if (metrics.lcp !== null) {
if (metrics.lcp > this.budget.lcp.poor) {
violations.push({
metric: 'LCP',
actual: metrics.lcp,
threshold: this.budget.lcp.poor,
severity: 'critical',
message: `LCP is ${metrics.lcp.toFixed(0)}ms (poor threshold: ${this.budget.lcp.poor}ms)`,
});
} else if (metrics.lcp > this.budget.lcp.good) {
violations.push({
metric: 'LCP',
actual: metrics.lcp,
threshold: this.budget.lcp.good,
severity: 'warning',
message: `LCP is ${metrics.lcp.toFixed(0)}ms (good threshold: ${this.budget.lcp.good}ms)`,
});
}
}
if (metrics.cls !== null) {
if (metrics.cls > this.budget.cls.poor) {
violations.push({
metric: 'CLS',
actual: metrics.cls,
threshold: this.budget.cls.poor,
severity: 'critical',
message: `CLS is ${metrics.cls.toFixed(3)} (poor threshold: ${this.budget.cls.poor})`,
});
} else if (metrics.cls > this.budget.cls.good) {
violations.push({
metric: 'CLS',
actual: metrics.cls,
threshold: this.budget.cls.good,
severity: 'warning',
message: `CLS is ${metrics.cls.toFixed(3)} (good threshold: ${this.budget.cls.good})`,
});
}
}
if (metrics.tbt !== null) {
if (metrics.tbt > this.budget.tbt.poor) {
violations.push({
metric: 'TBT',
actual: metrics.tbt,
threshold: this.budget.tbt.poor,
severity: 'critical',
message: `TBT is ${metrics.tbt.toFixed(0)}ms (poor threshold: ${this.budget.tbt.poor}ms)`,
});
} else if (metrics.tbt > this.budget.tbt.good) {
violations.push({
metric: 'TBT',
actual: metrics.tbt,
threshold: this.budget.tbt.good,
severity: 'warning',
message: `TBT is ${metrics.tbt.toFixed(0)}ms (good threshold: ${this.budget.tbt.good}ms)`,
});
}
}
if (metrics.fcp !== null && metrics.fcp > this.budget.fcp.poor) {
violations.push({
metric: 'FCP',
actual: metrics.fcp,
threshold: this.budget.fcp.poor,
severity: 'critical',
message: `FCP is ${metrics.fcp.toFixed(0)}ms (poor threshold: ${this.budget.fcp.poor}ms)`,
});
}
if (metrics.ttfb !== null && metrics.ttfb > this.budget.ttfb.poor) {
violations.push({
metric: 'TTFB',
actual: metrics.ttfb,
threshold: this.budget.ttfb.poor,
severity: 'critical',
message: `TTFB is ${metrics.ttfb.toFixed(0)}ms (poor threshold: ${this.budget.ttfb.poor}ms)`,
});
}
const transferSizeKB = metrics.totalTransferSize / 1024;
if (transferSizeKB > this.budget.maxTotalTransferSizeKB) {
violations.push({
metric: 'Total Transfer Size',
actual: transferSizeKB,
threshold: this.budget.maxTotalTransferSizeKB,
severity: 'critical',
message: `Total transfer is ${transferSizeKB.toFixed(0)}KB (budget: ${this.budget.maxTotalTransferSizeKB}KB)`,
});
}
if (metrics.resourceCount > this.budget.maxResourceCount) {
violations.push({
metric: 'Resource Count',
actual: metrics.resourceCount,
threshold: this.budget.maxResourceCount,
severity: 'warning',
message: `${metrics.resourceCount} resources loaded (budget: ${this.budget.maxResourceCount})`,
});
}
return violations;
}
}// tests/performance/core-web-vitals.spec.ts
import { test, expect } from '@playwright/test';
import { MetricsCollector } from '../helpers/metrics-collector';
import { BudgetEnforcer, defaultBudget } from '../helpers/budget-enforcer';
const criticalPages = [
{ name: 'Homepage', path: '/' },
{ name: 'Product Listing', path: '/products' },
{ name: 'Product Detail', path: '/products/sample-product' },
{ name: 'Checkout', path: '/checkout' },
{ name: 'Dashboard', path: '/dashboard' },
];
test.describe('Core Web Vitals', () => {
const collector = new MetricsCollector();
const enforcer = new BudgetEnforcer(defaultBudget);
for (const pageConfig of criticalPages) {
test(`${pageConfig.name} meets LCP budget`, async ({ page }) => {
await page.goto(pageConfig.path, { waitUntil: 'load' });
const metrics = await collector.collectCoreWebVitals(page);
if (metrics.lcp !== null) {
expect(metrics.lcp).toBeLessThanOrEqual(defaultBudget.lcp.poor);
}
});
test(`${pageConfig.name} meets CLS budget`, async ({ page }) => {
await page.goto(pageConfig.path, { waitUntil: 'load' });
await page.waitForTimeout(3000);
const metrics = await collector.collectCoreWebVitals(page);
if (metrics.cls !== null) {
expect(metrics.cls).toBeLessThanOrEqual(defaultBudget.cls.poor);
}
});
test(`${pageConfig.name} meets transfer size budget`, async ({ page }) => {
await page.goto(pageConfig.path, { waitUntil: 'load' });
const metrics = await collector.collectCoreWebVitals(page);
const transferKB = metrics.totalTransferSize / 1024;
expect(transferKB).toBeLessThanOrEqual(defaultBudget.maxTotalTransferSizeKB);
});
test(`${pageConfig.name} has no critical budget violations`, async ({ page }) => {
await page.goto(pageConfig.path, { waitUntil: 'load' });
await page.waitForTimeout(5000);
const metrics = await collector.collectCoreWebVitals(page);
const violations = enforcer.check(metrics);
const critical = violations.filter((v) => v.severity === 'critical');
if (critical.length > 0) {
const messages = critical.map((v) => v.message).join('\n');
expect.soft(critical.length, `Critical violations:\n${messages}`).toBe(0);
}
});
}
});The resource waterfall reveals render-blocking resources, unnecessary sequential loading, and oversized assets.
// helpers/waterfall-analyzer.ts
export interface WaterfallIssue {
resource: string;
issue: string;
severity: 'critical' | 'major' | 'minor';
recommendation: string;
impact: string;
}
export class WaterfallAnalyzer {
analyze(
resources: Array<{
name: string;
type: string;
transferSize: number;
duration: number;
startTime: number;
protocol: string;
renderBlocking: boolean;
}>
): WaterfallIssue[] {
const issues: WaterfallIssue[] = [];
// Check for render-blocking resources
const blocking = resources.filter((r) => r.renderBlocking);
for (const resource of blocking) {
issues.push({
resource: this.shortenUrl(resource.name),
issue: 'Render-blocking resource delays first paint',
severity: 'critical',
recommendation: `Defer or async-load ${this.shortenUrl(resource.name)}`,
impact: `Blocks rendering for ${resource.duration.toFixed(0)}ms`,
});
}
// Check for oversized JavaScript bundles
const jsResources = resources.filter(
(r) => r.type === 'script' || r.name.endsWith('.js')
);
for (const js of jsResources) {
const sizeKB = js.transferSize / 1024;
if (sizeKB > 150) {
issues.push({
resource: this.shortenUrl(js.name),
issue: `JavaScript bundle is ${sizeKB.toFixed(0)}KB`,
severity: sizeKB > 300 ? 'critical' : 'major',
recommendation: 'Apply code splitting or dynamic imports to reduce bundle size',
impact: `${sizeKB.toFixed(0)}KB of JS blocks main thread during parse`,
});
}
}
// Check for resources not served over HTTP/2+
for (const resource of resources) {
if (
resource.transferSize > 10240 &&
resource.protocol !== 'h2' &&
resource.protocol !== 'h3'
) {
issues.push({
resource: this.shortenUrl(resource.name),
issue: 'Large resource not served over HTTP/2 or HTTP/3',
severity: 'major',
recommendation: 'Enable HTTP/2 on the server for multiplexed loading',
impact: 'Sequential loading over HTTP/1.1 adds latency',
});
}
}
// Check for oversized images
const images = resources.filter(
(r) => r.type === 'img' || /\.(jpg|jpeg|png|gif|webp|avif|svg)(\?|$)/i.test(r.name)
);
for (const img of images) {
const sizeKB = img.transferSize / 1024;
if (sizeKB > 200) {
issues.push({
resource: this.shortenUrl(img.name),
issue: `Image is ${sizeKB.toFixed(0)}KB`,
severity: sizeKB > 500 ? 'critical' : 'major',
recommendation: 'Compress, resize, or convert to WebP/AVIF format',
impact: `${sizeKB.toFixed(0)}KB image delays page load`,
});
}
}
// Check for late-loading fonts
const fonts = resources.filter(
(r) => r.type === 'font' || /\.(woff2?|ttf|otf|eot)(\?|$)/i.test(r.name)
);
for (const font of fonts) {
if (font.startTime > 2000) {
issues.push({
resource: this.shortenUrl(font.name),
issue: `Font loaded ${font.startTime.toFixed(0)}ms after navigation start`,
severity: 'major',
recommendation: 'Preload critical fonts with <link rel="preload">',
impact: 'Late font loading causes Flash of Unstyled Text (FOUT)',
});
}
}
// Check for excessive requests to the same domain
const domainCounts = new Map<string, number>();
for (const r of resources) {
try {
const domain = new URL(r.name).hostname;
domainCounts.set(domain, (domainCounts.get(domain) || 0) + 1);
} catch {
// Skip invalid URLs
}
}
for (const [domain, count] of domainCounts) {
if (count > 20) {
issues.push({
resource: domain,
issue: `${count} requests to ${domain}`,
severity: 'major',
recommendation: 'Bundle resources or use HTTP/2 server push',
impact: 'Excessive requests add connection overhead',
});
}
}
return issues;
}
private shortenUrl(url: string): string {
try {
const parsed = new URL(url);
const path = parsed.pathname;
return path.length > 60 ? '...' + path.slice(-57) : path;
} catch {
return url.length > 60 ? '...' + url.slice(-57) : url;
}
}
}// tests/performance/resource-waterfall.spec.ts
import { test, expect } from '@playwright/test';
import { MetricsCollector } from '../helpers/metrics-collector';
import { WaterfallAnalyzer } from '../helpers/waterfall-analyzer';
test.describe('Resource Waterfall Analysis', () => {
const collector = new MetricsCollector();
const analyzer = new WaterfallAnalyzer();
test('homepage has no critical waterfall issues', async ({ page }) => {
await page.goto('/', { waitUntil: 'load' });
const resources = await collector.collectResourceTimings(page);
const issues = analyzer.analyze(resources);
const critical = issues.filter((i) => i.severity === 'critical');
if (critical.length > 0) {
const details = critical.map((i) => `${i.resource}: ${i.issue}`).join('\n');
expect.soft(critical.length, `Critical waterfall issues:\n${details}`).toBe(0);
}
});
test('no JavaScript bundle exceeds 300KB', async ({ page }) => {
await page.goto('/', { waitUntil: 'load' });
const resources = await collector.collectResourceTimings(page);
const jsResources = resources.filter(
(r) => r.type === 'script' || r.name.endsWith('.js')
);
for (const js of jsResources) {
const sizeKB = js.transferSize / 1024;
expect
.soft(sizeKB, `JS bundle ${js.name} is ${sizeKB.toFixed(0)}KB`)
.toBeLessThanOrEqual(300);
}
});
test('no single image exceeds 200KB', async ({ page }) => {
await page.goto('/', { waitUntil: 'load' });
const resources = await collector.collectResourceTimings(page);
const images = resources.filter(
(r) => r.type === 'img' || /\.(jpg|jpeg|png|gif|webp|avif)/i.test(r.name)
);
for (const img of images) {
const sizeKB = img.transferSize / 1024;
expect
.soft(sizeKB, `Image ${img.name} is ${sizeKB.toFixed(0)}KB`)
.toBeLessThanOrEqual(200);
}
});
test('critical fonts are preloaded within 1 second', async ({ page }) => {
await page.goto('/', { waitUntil: 'load' });
const resources = await collector.collectResourceTimings(page);
const fonts = resources.filter(
(r) => r.type === 'font' || /\.(woff2?|ttf|otf)(\?|$)/i.test(r.name)
);
for (const font of fonts) {
expect
.soft(
font.startTime,
`Font ${font.name} started loading at ${font.startTime.toFixed(0)}ms`
)
.toBeLessThan(1500);
}
});
});// tests/performance/third-party-impact.spec.ts
import { test, expect } from '@playwright/test';
import { MetricsCollector } from '../helpers/metrics-collector';
test.describe('Third-Party Script Impact', () => {
const collector = new MetricsCollector();
test('page without third-party scripts has fast LCP', async ({ page }) => {
const firstPartyDomain = new URL(
process.env.BASE_URL || 'http://localhost:3000'
).hostname;
await page.route('**/*', async (route) => {
try {
const domain = new URL(route.request().url()).hostname;
if (domain !== firstPartyDomain && domain !== 'localhost') {
await route.abort();
return;
}
} catch {}
await route.continue();
});
await page.goto('/', { waitUntil: 'load' });
const metrics = await collector.collectCoreWebVitals(page);
if (metrics.lcp !== null) {
expect(metrics.lcp).toBeLessThan(2500);
}
});
test('third-party overhead adds less than 500ms to LCP', async ({ browser }) => {
const baseUrl = process.env.BASE_URL || 'http://localhost:3000';
const firstPartyDomain = new URL(baseUrl).hostname;
// Measure without third-party scripts
const cleanCtx = await browser.newContext();
const cleanPage = await cleanCtx.newPage();
await cleanPage.route('**/*', async (route) => {
try {
const domain = new URL(route.request().url()).hostname;
if (domain !== firstPartyDomain && domain !== 'localhost') {
await route.abort();
return;
}
} catch {}
await route.continue();
});
await cleanPage.goto('/', { waitUntil: 'load' });
const cleanMetrics = await collector.collectCoreWebVitals(cleanPage);
await cleanCtx.close();
// Measure with all scripts
const fullCtx = await browser.newContext();
const fullPage = await fullCtx.newPage();
await fullPage.goto('/', { waitUntil: 'load' });
const fullMetrics = await collector.collectCoreWebVitals(fullPage);
await fullCtx.close();
if (cleanMetrics.lcp !== null && fullMetrics.lcp !== null) {
const overhead = fullMetrics.lcp - cleanMetrics.lcp;
expect(overhead).toBeLessThan(500);
}
});
test('third-party request count within budget', async ({ page }) => {
const firstPartyDomain = new URL(
process.env.BASE_URL || 'http://localhost:3000'
).hostname;
const thirdPartyRequests: string[] = [];
page.on('request', (req) => {
try {
const domain = new URL(req.url()).hostname;
if (domain !== firstPartyDomain && domain !== 'localhost') {
thirdPartyRequests.push(req.url());
}
} catch {}
});
await page.goto('/', { waitUntil: 'load' });
await page.waitForTimeout(5000);
expect(thirdPartyRequests.length).toBeLessThanOrEqual(10);
});
});// tests/performance/mobile-performance.spec.ts
import { test, expect, devices } from '@playwright/test';
import { MetricsCollector } from '../helpers/metrics-collector';
import { BudgetEnforcer, defaultBudget } from '../helpers/budget-enforcer';
test.describe('Mobile Performance', () => {
test.use({ ...devices['Pixel 7'] });
const collector = new MetricsCollector();
test('homepage LCP on mobile under 4 seconds', async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
await page.goto('/', { waitUntil: 'load' });
const metrics = await collector.collectCoreWebVitals(page);
if (metrics.lcp !== null) {
expect(metrics.lcp).toBeLessThan(4000);
}
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 });
});
test('mobile CLS under 0.1', async ({ page }) => {
await page.goto('/', { waitUntil: 'load' });
await page.waitForTimeout(5000);
const metrics = await collector.collectCoreWebVitals(page);
if (metrics.cls !== null) {
expect(metrics.cls).toBeLessThan(0.1);
}
});
test('no image exceeds 200KB on mobile', async ({ page }) => {
await page.goto('/', { waitUntil: 'load' });
const resources = await collector.collectResourceTimings(page);
const images = resources.filter(
(r) => r.type === 'img' || /\.(jpg|jpeg|png|gif|webp|avif)/i.test(r.name)
);
for (const img of images) {
const sizeKB = img.transferSize / 1024;
expect
.soft(sizeKB, `Image ${img.name} is ${sizeKB.toFixed(0)}KB`)
.toBeLessThanOrEqual(200);
}
});
test('total transfer size under 1.5MB on mobile', async ({ page }) => {
await page.goto('/', { waitUntil: 'load' });
const metrics = await collector.collectCoreWebVitals(page);
const transferKB = metrics.totalTransferSize / 1024;
expect(transferKB).toBeLessThan(1500);
});
});// helpers/report-generator.ts
import * as fs from 'fs';
import { CoreWebVitals } from './metrics-collector';
import { BudgetViolation } from './budget-enforcer';
import { WaterfallIssue } from './waterfall-analyzer';
export interface PerformanceReport {
timestamp: string;
pages: Array<{
url: string;
metrics: CoreWebVitals;
violations: BudgetViolation[];
waterfallIssues: WaterfallIssue[];
}>;
summary: {
totalPages: number;
totalViolations: number;
criticalViolations: number;
averageLCP: number;
averageCLS: number;
overallGrade: 'A' | 'B' | 'C' | 'D' | 'F';
};
}
export class ReportGenerator {
private pages: PerformanceReport['pages'] = [];
addPageResult(
url: string,
metrics: CoreWebVitals,
violations: BudgetViolation[],
waterfallIssues: WaterfallIssue[]
): void {
this.pages.push({ url, metrics, violations, waterfallIssues });
}
generate(): PerformanceReport {
const totalViolations = this.pages.reduce((s, p) => s + p.violations.length, 0);
const criticalViolations = this.pages.reduce(
(s, p) => s + p.violations.filter((v) => v.severity === 'critical').length,
0
);
const lcpValues = this.pages.map((p) => p.metrics.lcp).filter((v): v is number => v !== null);
const clsValues = this.pages.map((p) => p.metrics.cls).filter((v): v is number => v !== null);
const avgLCP = lcpValues.length ? lcpValues.reduce((a, b) => a + b, 0) / lcpValues.length : 0;
const avgCLS = clsValues.length ? clsValues.reduce((a, b) => a + b, 0) / clsValues.length : 0;
let grade: PerformanceReport['summary']['overallGrade'];
if (criticalViolations === 0 && avgLCP < 2500 && avgCLS < 0.1) grade = 'A';
else if (criticalViolations <= 1 && avgLCP < 4000 && avgCLS < 0.25) grade = 'B';
else if (criticalViolations <= 3 && avgLCP < 6000) grade = 'C';
else if (criticalViolations <= 5) grade = 'D';
else grade = 'F';
return {
timestamp: new Date().toISOString(),
pages: this.pages,
summary: { totalPages: this.pages.length, totalViolations, criticalViolations, averageLCP: avgLCP, averageCLS: avgCLS, overallGrade: grade },
};
}
writeJSON(path: string): void {
fs.writeFileSync(path, JSON.stringify(this.generate(), null, 2));
}
writeHTML(path: string): void {
const report = this.generate();
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<title>Performance Report - Grade: ${report.summary.overallGrade}</title>
<style>
body { font-family: system-ui; max-width: 960px; margin: 2rem auto; padding: 0 1rem; }
.grade { font-size: 3rem; font-weight: bold; }
.grade-A, .grade-B { color: #16a34a; }
.grade-C { color: #d97706; }
.grade-D, .grade-F { color: #dc2626; }
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
th, td { padding: 0.5rem; text-align: left; border-bottom: 1px solid #e5e7eb; }
.critical { color: #dc2626; } .warning { color: #d97706; }
</style>
</head>
<body>
<h1>Performance Report</h1>
<p class="grade grade-${report.summary.overallGrade}">Grade: ${report.summary.overallGrade}</p>
<p>Pages: ${report.summary.totalPages} | Violations: ${report.summary.totalViolations} (${report.summary.criticalViolations} critical)</p>
<p>Avg LCP: ${report.summary.averageLCP.toFixed(0)}ms | Avg CLS: ${report.summary.averageCLS.toFixed(3)}</p>
${report.pages.map((p) => `
<h2>${p.url}</h2>
<table>
<tr><td>LCP</td><td>${p.metrics.lcp?.toFixed(0) ?? 'N/A'}ms</td></tr>
<tr><td>CLS</td><td>${p.metrics.cls?.toFixed(3) ?? 'N/A'}</td></tr>
<tr><td>FCP</td><td>${p.metrics.fcp?.toFixed(0) ?? 'N/A'}ms</td></tr>
<tr><td>TTFB</td><td>${p.metrics.ttfb?.toFixed(0) ?? 'N/A'}ms</td></tr>
<tr><td>TBT</td><td>${p.metrics.tbt?.toFixed(0) ?? 'N/A'}ms</td></tr>
<tr><td>Resources</td><td>${p.metrics.resourceCount}</td></tr>
<tr><td>Transfer</td><td>${(p.metrics.totalTransferSize / 1024).toFixed(0)}KB</td></tr>
</table>
${p.violations.length ? `<h3>Violations</h3><ul>${p.violations.map((v) => `<li class="${v.severity}">${v.message}</li>`).join('')}</ul>` : '<p>No violations.</p>'}
`).join('')}
</body>
</html>`;
fs.writeFileSync(path, html);
}
}// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/performance',
timeout: 120000,
retries: 0,
workers: 1,
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
screenshot: 'off',
video: 'off',
trace: 'off',
},
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'reports/performance-results.json' }],
],
projects: [
{
name: 'perf-desktop',
use: { browserName: 'chromium', viewport: { width: 1440, height: 900 } },
},
{
name: 'perf-mobile',
use: {
browserName: 'chromium',
viewport: { width: 375, height: 812 },
isMobile: true,
hasTouch: true,
},
},
],
});{
"budgets": {
"lcp": { "good": 2500, "poor": 4000 },
"cls": { "good": 0.1, "poor": 0.25 },
"inp": { "good": 200, "poor": 500 },
"fcp": { "good": 1800, "poor": 3000 },
"ttfb": { "good": 800, "poor": 1800 },
"tbt": { "good": 200, "poor": 600 },
"maxJsBundleSizeKB": 300,
"maxCssBundleSizeKB": 100,
"maxImageSizeKB": 200,
"maxTotalTransferSizeKB": 1500,
"maxResourceCount": 50,
"maxThirdPartyRequests": 10
},
"criticalPages": ["/", "/products", "/checkout", "/dashboard"]
}~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.