/** * JS/CSS coverage collector via Playwright's built-in coverage API. * * For each crawled page, records what percentage of loaded JS/CSS was actually * executed/applied. High unused code % → performance opportunity (code splitting, lazy loading). * * Uses Playwright's page.coverage API (wraps CDP Coverage domain). */ import type { Page } from 'playwright'; export interface CoverageEntry { url: string; type: 'js' | 'css'; totalBytes: number; usedBytes: number; unusedPercent: number; } export interface CoverageReport { pageUrl: string; entries: CoverageEntry[]; totalJsBytes: number; usedJsBytes: number; totalCssBytes: number; usedCssBytes: number; jsUnusedPercent: number; cssUnusedPercent: number; collectedAt: string; } export async function startCoverage(page: Page): Promise { try { await Promise.all([ page.coverage.startJSCoverage({ resetOnNavigation: false }), page.coverage.startCSSCoverage({ resetOnNavigation: false }), ]); } catch { /* coverage not available in all environments */ } } export async function stopAndCollectCoverage(page: Page, pageUrl: string): Promise { try { const [jsCoverage, cssCoverage] = await Promise.all([ page.coverage.stopJSCoverage().catch(() => [] as any[]), page.coverage.stopCSSCoverage().catch(() => [] as any[]), ]); const entries: CoverageEntry[] = []; for (const entry of jsCoverage) { const totalBytes = entry.text?.length ?? 0; const usedBytes = (entry.ranges ?? []).reduce((sum: number, r: any) => sum + (r.end - r.start), 0); if (totalBytes > 0) { entries.push({ url: entry.url, type: 'js', totalBytes, usedBytes, unusedPercent: Math.round((1 - usedBytes / totalBytes) * 100) }); } } for (const entry of cssCoverage) { const totalBytes = entry.text?.length ?? 0; const usedBytes = (entry.ranges ?? []).reduce((sum: number, r: any) => sum + (r.end - r.start), 0); if (totalBytes > 0) { entries.push({ url: entry.url, type: 'css', totalBytes, usedBytes, unusedPercent: Math.round((1 - usedBytes / totalBytes) * 100) }); } } const jsEntries = entries.filter(e => e.type === 'js'); const cssEntries = entries.filter(e => e.type === 'css'); const totalJsBytes = jsEntries.reduce((s, e) => s + e.totalBytes, 0); const usedJsBytes = jsEntries.reduce((s, e) => s + e.usedBytes, 0); const totalCssBytes = cssEntries.reduce((s, e) => s + e.totalBytes, 0); const usedCssBytes = cssEntries.reduce((s, e) => s + e.usedBytes, 0); return { pageUrl, entries, totalJsBytes, usedJsBytes, totalCssBytes, usedCssBytes, jsUnusedPercent: totalJsBytes > 0 ? Math.round((1 - usedJsBytes / totalJsBytes) * 100) : 0, cssUnusedPercent: totalCssBytes > 0 ? Math.round((1 - usedCssBytes / totalCssBytes) * 100) : 0, collectedAt: new Date().toISOString(), }; } catch { return null; } }