140 lines
5.7 KiB
JavaScript
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
|