Files
ForgeLM/.agents/skills/perf-optimization/scripts/frontend-lighthouse.sh
T

375 lines
13 KiB
Bash

#!/usr/bin/env bash
# frontend-lighthouse.sh — Robust frontend performance profiling for AI agents.
#
# Two modes:
# 1. `lighthouse` — Run headless Lighthouse with timeout and binary discovery
# 2. `bundle` — Analyze Vite build output (always works, no Chrome needed)
#
# Usage:
# frontend-lighthouse.sh lighthouse <url> [preset]
# frontend-lighthouse.sh bundle <frontend-dir>
#
# Arguments:
# lighthouse mode:
# url — The URL to audit (e.g., http://localhost:4173)
# preset — "desktop" or "mobile" (default: mobile)
#
# bundle mode:
# frontend-dir — Path to the frontend project root (must have vite.config.*)
#
# Examples:
# frontend-lighthouse.sh lighthouse http://localhost:4173 desktop
# frontend-lighthouse.sh bundle ./apps/frontend
# frontend-lighthouse.sh bundle /home/user/project/apps/frontend
#
# Prerequisites:
# lighthouse mode: Node.js + Chrome/Chromium (or globally installed lighthouse)
# bundle mode: Node.js + Vite project (no Chrome needed)
#
# Robustness features (learned from real-world agent sessions):
# - Binary discovery: checks PATH, npx, ~/.npm-global/bin, common install locations
# - Timeout: kills Lighthouse after LIGHTHOUSE_TIMEOUT seconds (default: 120)
# - Fallback: if Lighthouse fails/hangs, suggests running bundle mode instead
# - Error reporting disabled: prevents interactive consent prompts
# - Bundle mode: always works, extracts chunk sizes from vite build output
#
# Output: Structured markdown tables suitable for LLM consumption.
set -euo pipefail
MODE="${1:-}"
LIGHTHOUSE_TIMEOUT="${LIGHTHOUSE_TIMEOUT:-120}"
# ─── Binary Discovery ──────────────────────────────────────────────────────────
# Lighthouse can be installed globally via npm, in ~/.npm-global, or via npx.
# Try multiple locations to find a working binary.
find_lighthouse() {
# 1. Check PATH
if command -v lighthouse &>/dev/null; then
echo "lighthouse"
return
fi
# 2. Check common global install locations
local candidates=(
"$HOME/.npm-global/bin/lighthouse"
"/usr/local/bin/lighthouse"
"/usr/bin/lighthouse"
)
for candidate in "${candidates[@]}"; do
if [[ -x "$candidate" ]]; then
echo "$candidate"
return
fi
done
# 3. Fall back to npx (will auto-install if needed)
echo "npx --yes lighthouse"
}
# ─── Lighthouse Mode ───────────────────────────────────────────────────────────
run_lighthouse() {
local url="$1"
local preset="${2:-mobile}"
local lh_cmd
lh_cmd=$(find_lighthouse)
local preset_flag=""
if [[ "$preset" == "desktop" ]]; then
preset_flag="--preset=desktop"
fi
local tmpfile
tmpfile=$(mktemp /tmp/lighthouse-XXXXXX.json)
trap 'rm -f "$tmpfile"' EXIT
echo "# Lighthouse Report: ${url} (${preset})"
echo ""
echo "Binary: ${lh_cmd}"
echo "Timeout: ${LIGHTHOUSE_TIMEOUT}s"
echo ""
# Run Lighthouse with timeout to prevent infinite hangs in headless environments.
# --enable-error-reporting=false prevents interactive consent prompts.
# --no-sandbox is required in containerized/CI environments.
local lh_pid
local lh_exit=0
$lh_cmd "$url" \
--output=json \
--output-path="$tmpfile" \
--chrome-flags="--headless --no-sandbox --disable-gpu --disable-dev-shm-usage" \
--only-categories=performance \
$preset_flag \
--enable-error-reporting=false \
--quiet 2>/dev/null &
lh_pid=$!
# Wait with timeout — kill if Lighthouse hangs (common in headless/CI)
local elapsed=0
while kill -0 "$lh_pid" 2>/dev/null; do
if (( elapsed >= LIGHTHOUSE_TIMEOUT )); then
kill -9 "$lh_pid" 2>/dev/null || true
wait "$lh_pid" 2>/dev/null || true
echo "⚠️ Lighthouse timed out after ${LIGHTHOUSE_TIMEOUT}s."
echo ""
echo "This usually means Chrome/Chromium is not available or the headless"
echo "environment doesn't support it. Try bundle mode instead:"
echo ""
echo " $0 bundle <frontend-dir>"
echo ""
return 1
fi
sleep 2
elapsed=$((elapsed + 2))
done
wait "$lh_pid" || lh_exit=$?
if [[ $lh_exit -ne 0 ]]; then
echo "⚠️ Lighthouse exited with code ${lh_exit}."
echo "Try bundle mode instead: $0 bundle <frontend-dir>"
return 1
fi
if [[ ! -s "$tmpfile" ]]; then
echo "⚠️ Lighthouse produced no output."
echo "Try bundle mode instead: $0 bundle <frontend-dir>"
return 1
fi
# Extract metrics into structured markdown
node -e "
const fs = require('fs');
const r = JSON.parse(fs.readFileSync('$tmpfile', 'utf8'));
const a = r.audits;
const score = Math.round((r.categories.performance.score || 0) * 100);
console.log('## Performance Score: ' + score + '/100');
console.log('');
console.log('## Core Web Vitals');
console.log('');
console.log('| Metric | Value | Score |');
console.log('|---|---|---|');
const vitals = [
'first-contentful-paint',
'largest-contentful-paint',
'total-blocking-time',
'cumulative-layout-shift',
'speed-index',
'interactive',
];
vitals.forEach(k => {
if (a[k]) {
const s = a[k].score !== null ? Math.round(a[k].score * 100) : 'N/A';
console.log('| ' + a[k].title + ' | ' + (a[k].displayValue || 'N/A') + ' | ' + s + ' |');
}
});
console.log('');
console.log('## Opportunities');
console.log('');
const opportunities = Object.values(a)
.filter(audit => audit.details && audit.details.type === 'opportunity' && audit.details.overallSavingsMs > 0)
.sort((x, y) => (y.details.overallSavingsMs || 0) - (x.details.overallSavingsMs || 0));
if (opportunities.length > 0) {
console.log('| Opportunity | Potential Savings |');
console.log('|---|---|');
opportunities.forEach(o => {
console.log('| ' + o.title + ' | ' + Math.round(o.details.overallSavingsMs) + ' ms |');
});
} else {
console.log('No significant opportunities found.');
}
console.log('');
console.log('## Diagnostics');
console.log('');
const diagnostics = Object.values(a)
.filter(audit => audit.details && audit.details.type === 'table' && audit.score !== null && audit.score < 0.9)
.slice(0, 10);
if (diagnostics.length > 0) {
console.log('| Diagnostic | Score |');
console.log('|---|---|');
diagnostics.forEach(d => {
console.log('| ' + d.title + ' | ' + Math.round(d.score * 100) + ' |');
});
} else {
console.log('All diagnostics passed.');
}
"
}
# ─── Bundle Analysis Mode ──────────────────────────────────────────────────────
# Always works — no Chrome needed. Extracts chunk sizes from vite build output.
# This was the most useful data source during the FATH frontend session: it
# revealed the 297KB monolithic index chunk and guided all 6 fixes.
run_bundle() {
local frontend_dir="$1"
if [[ ! -d "$frontend_dir" ]]; then
echo "Error: directory '$frontend_dir' does not exist."
exit 1
fi
# Verify it's a Vite project
if ! ls "$frontend_dir"/vite.config.* &>/dev/null; then
echo "Error: no vite.config.* found in '$frontend_dir'. Is this a Vite project?"
exit 1
fi
echo "# Bundle Analysis: ${frontend_dir}"
echo ""
echo "Running \`vite build\`..."
echo ""
local build_output
build_output=$(cd "$frontend_dir" && npx vite build 2>&1)
local build_exit=$?
if [[ $build_exit -ne 0 ]]; then
echo "⚠️ Build failed (exit code $build_exit):"
echo ""
echo '```'
echo "$build_output"
echo '```'
return 1
fi
# Parse build output into structured tables
node -e "
const output = \`$build_output\`;
const lines = output.split('\n');
const jsChunks = [];
const cssChunks = [];
// Parse Vite build output lines like:
// dist/assets/index-BExof8y3.js 20.04 kB │ gzip: 6.90 kB
for (const line of lines) {
const match = line.match(/dist\/assets\/(.+?)\s+([\d.]+)\s*kB\s*│\s*gzip:\s*([\d.]+)\s*kB/);
if (match) {
const [, name, rawKB, gzipKB] = match;
const entry = { name, raw: parseFloat(rawKB), gzip: parseFloat(gzipKB) };
if (name.endsWith('.js')) jsChunks.push(entry);
else if (name.endsWith('.css')) cssChunks.push(entry);
}
}
// Sort by gzip size descending
jsChunks.sort((a, b) => b.gzip - a.gzip);
cssChunks.sort((a, b) => b.gzip - a.gzip);
const totalJsRaw = jsChunks.reduce((s, c) => s + c.raw, 0);
const totalJsGzip = jsChunks.reduce((s, c) => s + c.gzip, 0);
const totalCssRaw = cssChunks.reduce((s, c) => s + c.raw, 0);
const totalCssGzip = cssChunks.reduce((s, c) => s + c.gzip, 0);
console.log('## JS Chunks (sorted by gzip size)');
console.log('');
console.log('| Chunk | Raw (KB) | Gzip (KB) |');
console.log('|---|---|---|');
jsChunks.forEach(c => {
// Clean chunk name: remove hash suffix for readability
const clean = c.name.replace(/-[A-Za-z0-9_-]{8}\\.js$/, '.js');
console.log('| ' + clean + ' | ' + c.raw.toFixed(2) + ' | ' + c.gzip.toFixed(2) + ' |');
});
console.log('| **Total JS** | **' + totalJsRaw.toFixed(2) + '** | **' + totalJsGzip.toFixed(2) + '** |');
if (cssChunks.length > 0) {
console.log('');
console.log('## CSS Chunks');
console.log('');
console.log('| Chunk | Raw (KB) | Gzip (KB) |');
console.log('|---|---|---|');
cssChunks.forEach(c => {
const clean = c.name.replace(/-[A-Za-z0-9_-]{8}\\.css$/, '.css');
console.log('| ' + clean + ' | ' + c.raw.toFixed(2) + ' | ' + c.gzip.toFixed(2) + ' |');
});
console.log('| **Total CSS** | **' + totalCssRaw.toFixed(2) + '** | **' + totalCssGzip.toFixed(2) + '** |');
}
console.log('');
console.log('## Summary');
console.log('');
console.log('| Metric | Value |');
console.log('|---|---|');
console.log('| JS chunks | ' + jsChunks.length + ' |');
console.log('| CSS chunks | ' + cssChunks.length + ' |');
console.log('| Total JS (gzip) | ' + totalJsGzip.toFixed(2) + ' KB |');
console.log('| Total CSS (gzip) | ' + totalCssGzip.toFixed(2) + ' KB |');
console.log('| Largest JS chunk | ' + (jsChunks[0]?.name || 'N/A') + ' (' + (jsChunks[0]?.gzip.toFixed(2) || '0') + ' KB gzip) |');
// Flag potential issues
console.log('');
console.log('## Flags');
console.log('');
const bigChunks = jsChunks.filter(c => c.gzip > 50);
if (bigChunks.length > 0) {
console.log('⚠️ **Large chunks (>50 KB gzip):**');
bigChunks.forEach(c => {
console.log(' - ' + c.name + ': ' + c.gzip.toFixed(2) + ' KB gzip');
});
console.log('');
console.log('Consider splitting vendor libraries into separate chunks (see: Artifact Partitioning pattern in SKILL.md).');
} else {
console.log('✅ No oversized chunks detected.');
}
const vendorChunks = jsChunks.filter(c => c.name.startsWith('vendor-'));
if (vendorChunks.length > 0) {
console.log('');
console.log('✅ Vendor chunk splitting detected: ' + vendorChunks.map(c => c.name).join(', '));
} else if (jsChunks.some(c => c.gzip > 40)) {
console.log('');
console.log('⚠️ No vendor chunk splitting detected. The largest chunk may contain framework + app code bundled together.');
}
"
}
# ─── Main ──────────────────────────────────────────────────────────────────────
case "${MODE}" in
lighthouse|lh)
URL="${2:-}"
PRESET="${3:-mobile}"
if [[ -z "$URL" ]]; then
echo "Usage: $0 lighthouse <url> [desktop|mobile]"
echo " $0 bundle <frontend-dir>"
exit 1
fi
run_lighthouse "$URL" "$PRESET"
;;
bundle|build)
DIR="${2:-}"
if [[ -z "$DIR" ]]; then
echo "Usage: $0 bundle <frontend-dir>"
echo " $0 lighthouse <url> [desktop|mobile]"
exit 1
fi
run_bundle "$DIR"
;;
*)
echo "Frontend Performance Profiling Script"
echo ""
echo "Usage:"
echo " $0 lighthouse <url> [desktop|mobile] — Run Lighthouse (needs Chrome)"
echo " $0 bundle <frontend-dir> — Analyze Vite build chunks (no Chrome)"
echo ""
echo "Examples:"
echo " $0 lighthouse http://localhost:4173 desktop"
echo " $0 bundle ./apps/frontend"
echo ""
echo "Environment:"
echo " LIGHTHOUSE_TIMEOUT=120 — Seconds before killing a hung Lighthouse (default: 120)"
exit 1
;;
esac