#!/usr/bin/env node import { execFileSync } from 'node:child_process'; import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; function parseArgs(argv) { const args = { url: '', label: 'baseline', runs: 3, formFactor: 'mobile', outDir: 'flutter/lighthouse/results', }; for (let i = 2; i < argv.length; i += 1) { const current = argv[i]; const next = argv[i + 1]; if (current === '--url' && next) { args.url = next; i += 1; } else if (current === '--label' && next) { args.label = next; i += 1; } else if (current === '--runs' && next) { args.runs = Number.parseInt(next, 10); i += 1; } else if (current === '--form-factor' && next) { args.formFactor = next; i += 1; } else if (current === '--out-dir' && next) { args.outDir = next; i += 1; } } if (!args.url) { throw new Error('Missing --url, example: node flutter/lighthouse/collect.mjs --url http://localhost:5000'); } if (!Number.isFinite(args.runs) || args.runs < 1) { throw new Error('--runs must be >= 1'); } return args; } function median(values) { if (values.length === 0) return null; const ordered = [...values].sort((a, b) => a - b); const center = Math.floor(ordered.length / 2); if (ordered.length % 2 === 0) { return (ordered[center - 1] + ordered[center]) / 2; } return ordered[center]; } function toMs(value) { if (typeof value !== 'number') return null; return Math.round(value); } function readMetric(lhr, id) { const audit = lhr.audits[id]; return audit && typeof audit.numericValue === 'number' ? audit.numericValue : null; } function readResourceSummary(lhr) { const details = lhr.audits['resource-summary']?.details; const items = details?.items; if (!Array.isArray(items)) return { transferKb: null, requests: null }; const total = items.find((item) => item.resourceType === 'total'); if (!total) return { transferKb: null, requests: null }; return { transferKb: typeof total.transferSize === 'number' ? Math.round(total.transferSize / 1024) : null, requests: typeof total.requestCount === 'number' ? total.requestCount : null, }; } function formatNullable(value) { return value == null ? 'n/a' : String(value); } function main() { const args = parseArgs(process.argv); const outputRoot = resolve(args.outDir); mkdirSync(outputRoot, { recursive: true }); const runRows = []; for (let runIndex = 1; runIndex <= args.runs; runIndex += 1) { const outputPath = join(outputRoot, `${args.label}-run-${runIndex}.json`); const flags = [ 'lighthouse', args.url, '--only-categories=performance,accessibility,seo', '--throttling-method=simulate', `--form-factor=${args.formFactor}`, '--screenEmulation.mobile=true', '--output=json', `--output-path=${outputPath}`, '--chrome-flags=--headless=new --no-sandbox', '--quiet', ]; execFileSync('npx', flags, { stdio: 'inherit' }); const lhr = JSON.parse(readFileSync(outputPath, 'utf8')); const summary = readResourceSummary(lhr); runRows.push({ run: runIndex, performance: Math.round((lhr.categories.performance.score ?? 0) * 100), accessibility: Math.round((lhr.categories.accessibility.score ?? 0) * 100), seo: Math.round((lhr.categories.seo.score ?? 0) * 100), lcpMs: toMs(readMetric(lhr, 'largest-contentful-paint')), tbtMs: toMs(readMetric(lhr, 'total-blocking-time')), inpMs: toMs(readMetric(lhr, 'interaction-to-next-paint')), transferKb: summary.transferKb, requests: summary.requests, fetchTime: lhr.fetchTime, finalUrl: lhr.finalUrl, }); } const medians = { performance: median(runRows.map((r) => r.performance)), accessibility: median(runRows.map((r) => r.accessibility)), seo: median(runRows.map((r) => r.seo)), lcpMs: median(runRows.filter((r) => r.lcpMs != null).map((r) => r.lcpMs)), tbtMs: median(runRows.filter((r) => r.tbtMs != null).map((r) => r.tbtMs)), inpMs: median(runRows.filter((r) => r.inpMs != null).map((r) => r.inpMs)), transferKb: median(runRows.filter((r) => r.transferKb != null).map((r) => r.transferKb)), requests: median(runRows.filter((r) => r.requests != null).map((r) => r.requests)), }; const summaryPath = join(outputRoot, `${args.label}-summary.json`); writeFileSync( summaryPath, JSON.stringify( { url: args.url, label: args.label, runs: args.runs, formFactor: args.formFactor, medians, results: runRows, }, null, 2, ), ); const markdownPath = join(outputRoot, `${args.label}-summary.md`); const markdown = [ `# Lighthouse summary: ${args.label}`, '', `- URL: ${args.url}`, `- Runs: ${args.runs}`, `- Form factor: ${args.formFactor}`, '', '| Metric | Median |', '| --- | ---: |', `| Performance | ${formatNullable(medians.performance)} |`, `| Accessibility | ${formatNullable(medians.accessibility)} |`, `| SEO | ${formatNullable(medians.seo)} |`, `| LCP (ms) | ${formatNullable(medians.lcpMs)} |`, `| TBT (ms) | ${formatNullable(medians.tbtMs)} |`, `| INP (ms) | ${formatNullable(medians.inpMs)} |`, `| Transfer (KB) | ${formatNullable(medians.transferKb)} |`, `| Requests | ${formatNullable(medians.requests)} |`, '', '## Runs', '', '| Run | Perf | A11y | SEO | LCP | TBT | INP | KB | Req |', '| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', ...runRows.map( (row) => `| ${row.run} | ${row.performance} | ${row.accessibility} | ${row.seo} | ${formatNullable(row.lcpMs)} | ${formatNullable(row.tbtMs)} | ${formatNullable(row.inpMs)} | ${formatNullable(row.transferKb)} | ${formatNullable(row.requests)} |`, ), '', ].join('\n'); writeFileSync(markdownPath, markdown); process.stdout.write(`Wrote ${summaryPath}\n`); process.stdout.write(`Wrote ${markdownPath}\n`); } main();