import { z } from 'zod'; import { config } from '../../config.js'; import { runCommand } from '../../lib/runCommand.js'; import { errorResult, jsonResult, toErrorMessage } from '../shared.js'; function scorePercent(score) { if (score == null) return null; return Number((score * 100).toFixed(1)); } function extractScores(report) { const scores = {}; for (const [id, category] of Object.entries(report.categories ?? {})) { scores[id] = scorePercent(category.score); } return scores; } function extractCategoryDetails(report) { return Object.entries(report.categories ?? {}).map(([id, category]) => ({ id, title: category.title ?? id, description: category.description ?? null, score: scorePercent(category.score), auditCount: category.auditRefs?.length ?? 0, })); } function categoryForAudit(report, auditId) { for (const [categoryId, category] of Object.entries(report.categories ?? {})) { if (category.auditRefs?.some((ref) => ref.id === auditId)) return categoryId; } return null; } function weightForAudit(report, auditId) { for (const category of Object.values(report.categories ?? {})) { const ref = category.auditRefs?.find((r) => r.id === auditId); if (ref?.weight != null) return ref.weight; } return 0; } function topOpportunities(report, limit = 12) { const audits = report.audits ?? {}; const candidates = Object.entries(audits) .filter(([, audit]) => typeof audit.score === 'number' && (audit.score ?? 1) < 0.9) .filter(([, audit]) => audit.scoreDisplayMode !== 'informative' && audit.scoreDisplayMode !== 'manual') .map(([id, audit]) => { const details = audit.details; return { id, title: audit.title ?? id, category: categoryForAudit(report, id), score: audit.score ?? null, displayValue: audit.displayValue ?? null, weight: weightForAudit(report, id), savingsMs: details?.overallSavingsMs ?? null, savingsBytes: details?.overallSavingsBytes ?? null, }; }) .sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0) || (b.savingsMs ?? 0) - (a.savingsMs ?? 0) || (a.score ?? 1) - (b.score ?? 1)); return candidates.slice(0, limit); } function failingByCategory(report, perCategory = 5) { const byCategory = {}; for (const opportunity of topOpportunities(report, 50)) { const cat = opportunity.category ?? 'other'; if (!byCategory[cat]) byCategory[cat] = []; if (byCategory[cat].length < perCategory) byCategory[cat].push(opportunity); } return byCategory; } export function parseLighthouseOutput(raw) { // Lighthouse may print non-JSON noise before/after the JSON blob when run via npx. const start = raw.indexOf('{'); const end = raw.lastIndexOf('}'); if (start === -1 || end === -1 || end <= start) { throw new Error('Could not parse Lighthouse JSON output.'); } const report = JSON.parse(raw.slice(start, end + 1)); return { scores: extractScores(report), categories: extractCategoryDetails(report), opportunities: topOpportunities(report), failingByCategory: failingByCategory(report), }; } export function registerLighthouseAuditTool(server) { server.registerTool('lighthouse_audit', { title: 'Lighthouse Audit', description: 'Run a headless Lighthouse audit against a URL (prefer localhost / the running dev server). Returns per-category ' + 'scores and titles, top opportunities across categories (with savings when available), and failing audits grouped by category.', inputSchema: { url: z.string().url().describe('URL to audit, e.g. http://127.0.0.1:5173/'), categories: z .array(z.enum(['performance', 'accessibility', 'best-practices', 'seo', 'pwa'])) .optional() .describe('Optional subset of Lighthouse categories. Defaults to performance, accessibility, best-practices, seo (add pwa explicitly if needed).'), }, }, async ({ url, categories }) => { try { const selected = categories && categories.length > 0 ? categories : ['performance', 'accessibility', 'best-practices', 'seo']; // Prefer npx so lighthouse need not be globally installed; npx is already allow-listed. const result = await runCommand('npx', [ '--yes', 'lighthouse', url, '--output=json', '--quiet', '--chrome-flags=--headless --no-sandbox', ...selected.flatMap((category) => ['--only-categories', category]), ], { cwd: config.workspaceRoot, timeoutMs: config.scaffoldCommandTimeoutMs, }); if (result.failed && !result.output.includes('{')) { return errorResult(`Lighthouse failed (exit ${result.exitCode}). Ensure Chrome/Chromium is available.\n${result.output}`); } const parsed = parseLighthouseOutput(result.output); return jsonResult({ url, requestedCategories: selected, scores: parsed.scores, categories: parsed.categories, opportunities: parsed.opportunities, failingByCategory: parsed.failingByCategory, timedOut: result.timedOut, exitCode: result.exitCode, }); } catch (error) { return errorResult(toErrorMessage(error)); } }); } //# sourceMappingURL=lighthouse.js.map