init project
This commit is contained in:
Vendored
+140
@@ -0,0 +1,140 @@
|
||||
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
|
||||
Reference in New Issue
Block a user