#!/usr/bin/env node // // Turns a directory of Lighthouse JSON reports into one reviewable markdown // summary. Written because the numbers behind MAN-109 previously survived only // as a hand-typed table in a Linear comment: `storage/app/lighthouse` is // gitignored, so there was nothing to diff a later run against. // // For each page and preset the run holding the median LCP is the one reported. // Metrics are never averaged across runs — every figure in the table comes // from the same single navigation, so the LCP phase breakdown adds up. // // Usage: // node scripts/perf/summarize-lighthouse.mjs [--target=local] // [--base-url=...] [--commit=...] [--seeder=...] import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; const [dir, ...rest] = process.argv.slice(2); if (!dir) { console.error('usage: summarize-lighthouse.mjs [--target=...]'); process.exit(1); } const flags = new Map( rest .filter((argument) => argument.startsWith('--')) .map((argument) => { const [key, ...value] = argument.replace(/^--/, '').split('='); return [key, value.join('=')]; }), ); const ms = (value) => (typeof value === 'number' ? `${(value / 1000).toFixed(2)} s` : '—'); const kib = (value) => (typeof value === 'number' ? `${Math.round(value / 1024)} KiB` : '—'); const score = (value) => (typeof value === 'number' ? Math.round(value * 100) : '—'); const numeric = (audit) => (typeof audit?.numericValue === 'number' ? audit.numericValue : null); /** Every nested details table in an audit, flattened. */ function nestedItems(audit) { const items = audit?.details?.items ?? []; return items.flatMap((item) => (Array.isArray(item?.items) ? item.items : [item])); } function lcpElement(lhr) { const item = nestedItems(lhr.audits?.['largest-contentful-paint-element']).find((entry) => entry?.node); const snippet = item?.node?.snippet ?? item?.node?.selector ?? null; return snippet ? snippet.replace(/\s+/g, ' ').slice(0, 160) : null; } function lcpPhases(lhr) { return nestedItems(lhr.audits?.['largest-contentful-paint-element']) .filter((entry) => typeof entry?.phase === 'string') .map((entry) => ({ phase: entry.phase, timing: entry.timing ?? null })); } /** Requests that finished before LCP, heaviest first — the contention evidence. */ function requestsBeforeLcp(lhr, lcp) { const requests = lhr.audits?.['network-requests']?.details?.items ?? []; return requests .filter((request) => typeof request.networkEndTime === 'number' && (lcp === null || request.networkEndTime <= lcp + 50)) .filter((request) => (request.transferSize ?? 0) > 1024) .sort((a, b) => (b.transferSize ?? 0) - (a.transferSize ?? 0)) .slice(0, 8) .map((request) => ({ url: String(request.url ?? '').replace(/^https?:\/\/[^/]+/, ''), type: request.resourceType ?? '—', transferSize: request.transferSize ?? null, endTime: request.networkEndTime ?? null, })); } /** Failing LH12 insights, with whatever savings estimate they carry. */ function insights(lhr) { return Object.entries(lhr.audits ?? {}) .filter(([id, audit]) => id.endsWith('-insight') && typeof audit.score === 'number' && audit.score < 1) .map(([id, audit]) => ({ id, title: audit.title ?? id, lcpSavings: audit.metricSavings?.LCP ?? null, byteSavings: audit.details?.overallSavingsBytes ?? null, })) .sort((a, b) => (b.lcpSavings ?? 0) - (a.lcpSavings ?? 0)); } const reports = new Map(); for (const file of readdirSync(dir).filter((name) => name.endsWith('.report.json')).sort()) { const match = /^(.+)-(mobile|desktop)-run(\d+)\.report\.json$/.exec(file); if (!match) { continue; } const [, page, preset] = match; const key = `${page}::${preset}`; const lhr = JSON.parse(readFileSync(join(dir, file), 'utf8')); if (!reports.has(key)) { reports.set(key, []); } reports.get(key).push({ file, lhr }); } if (reports.size === 0) { console.error(`no Lighthouse JSON reports found in ${dir}`); process.exit(1); } /** The run whose LCP is the median — reported whole, never blended. */ function medianRun(runs) { const sorted = [...runs].sort( (a, b) => (numeric(a.lhr.audits?.['largest-contentful-paint']) ?? Infinity) - (numeric(b.lhr.audits?.['largest-contentful-paint']) ?? Infinity), ); return sorted[Math.floor((sorted.length - 1) / 2)]; } const rows = []; const details = []; for (const [key, runs] of [...reports.entries()].sort()) { const [page, preset] = key.split('::'); const chosen = medianRun(runs); const { lhr } = chosen; const lcp = numeric(lhr.audits?.['largest-contentful-paint']); const spread = runs .map((run) => numeric(run.lhr.audits?.['largest-contentful-paint'])) .filter((value) => value !== null) .sort((a, b) => a - b); rows.push({ page, preset, performance: score(lhr.categories?.performance?.score), accessibility: score(lhr.categories?.accessibility?.score), bestPractices: score(lhr.categories?.['best-practices']?.score), seo: score(lhr.categories?.seo?.score), lcp, cls: numeric(lhr.audits?.['cumulative-layout-shift']), tbt: numeric(lhr.audits?.['total-blocking-time']), fcp: numeric(lhr.audits?.['first-contentful-paint']), ttfb: numeric(lhr.audits?.['server-response-time']), runs: runs.length, spread: spread.length > 1 ? [spread[0], spread[spread.length - 1]] : null, file: chosen.file, }); details.push({ page, preset, element: lcpElement(lhr), phases: lcpPhases(lhr), requests: requestsBeforeLcp(lhr, lcp), insights: insights(lhr), }); } const first = reports.values().next().value[0].lhr; const out = []; out.push(`# Lighthouse — ${flags.get('target') ?? 'local'}`); out.push(''); out.push(`- Origem: \`${flags.get('base-url') ?? '—'}\``); out.push(`- Lighthouse: ${first.lighthouseVersion ?? '—'}`); // Which Chrome ran the audit is part of the measurement: the stable system // Chrome and Playwright's Chromium do not produce interchangeable numbers. out.push(`- Navegador: \`${first.environment?.hostUserAgent ?? '—'}\``); out.push(`- Coletado em: ${first.fetchTime ?? '—'}`); out.push(`- Execuções por página/preset: ${rows[0]?.runs ?? '—'} (reportada a de LCP mediano)`); if (flags.get('commit')) { out.push(`- Commit: \`${flags.get('commit')}\``); } if (flags.get('seeder')) { out.push(`- Seeder: \`${flags.get('seeder')}\``); } out.push(''); out.push('Metas SPEC §6.6: LCP ≤ 2,5 s · CLS ≤ 0,1 · INP ≤ 200 ms · zero erro de console.'); out.push(''); out.push('| página | preset | perf | a11y | BP | SEO | LCP | FCP | CLS | TBT | TTFB servidor | LCP min–max |'); out.push('|---|---|---|---|---|---|---|---|---|---|---|---|'); for (const row of rows) { const spread = row.spread ? `${ms(row.spread[0])} – ${ms(row.spread[1])}` : '—'; out.push( `| ${row.page} | ${row.preset} | ${row.performance} | ${row.accessibility} | ${row.bestPractices} ` + `| ${row.seo} | ${ms(row.lcp)} | ${ms(row.fcp)} | ${typeof row.cls === 'number' ? row.cls.toFixed(3) : '—'} ` + `| ${typeof row.tbt === 'number' ? `${Math.round(row.tbt)} ms` : '—'} ` + `| ${typeof row.ttfb === 'number' ? `${Math.round(row.ttfb)} ms` : '—'} | ${spread} |`, ); } out.push(''); out.push('## Decomposição do LCP'); for (const detail of details) { out.push(''); out.push(`### ${detail.page} — ${detail.preset}`); out.push(''); out.push(`Elemento de LCP: ${detail.element ? `\`${detail.element}\`` : '—'}`); if (detail.phases.length > 0) { out.push(''); out.push('| fase | tempo |'); out.push('|---|---|'); for (const phase of detail.phases) { out.push(`| ${phase.phase} | ${ms(phase.timing)} |`); } } if (detail.requests.length > 0) { out.push(''); out.push('Requests concluídas até o LCP, mais pesadas primeiro:'); out.push(''); out.push('| recurso | tipo | transferido | fim |'); out.push('|---|---|---|---|'); for (const request of detail.requests) { out.push(`| \`${request.url}\` | ${request.type} | ${kib(request.transferSize)} | ${ms(request.endTime)} |`); } } if (detail.insights.length > 0) { out.push(''); out.push('| insight com falha | ganho estimado de LCP | bytes |'); out.push('|---|---|---|'); for (const insight of detail.insights) { out.push(`| ${insight.title} | ${ms(insight.lcpSavings)} | ${kib(insight.byteSavings)} |`); } } } out.push(''); console.log(out.join('\n'));