Files
2026-07-31 13:12:54 -04:00

140 lines
5.7 KiB
JavaScript

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