UNPKG

chrome-devtools-frontend

Version:
229 lines (214 loc) • 8.1 kB
// Copyright 2026 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import type * as LHModel from '../../lighthouse/lighthouse.js'; import {bytes, millis} from './UnitFormatters.js'; /** * A formatter that takes a raw Lighthouse report JSON and creates a markdown * summary for an AI Agent. */ export class LighthouseFormatter { /** * Returns an overall summary and high-level overview of the Lighthouse report. */ summary(report: LHModel.ReporterTypes.ReportJSON): string { const lines: string[] = []; lines.push('# Lighthouse Report Summary'); lines.push(`URL: ${report.finalDisplayedUrl}`); lines.push(`Fetch Time: ${report.fetchTime}`); lines.push(`Lighthouse Version: ${report.lighthouseVersion}`); lines.push(''); lines.push('## Category Scores'); for (const category of Object.values(report.categories)) { const score = category.score !== null ? Math.round(category.score * 100) : 'n/a'; lines.push(`- ${category.title}: ${score}`); } return lines.join('\n'); } /** * Returns a markdown list of all audits in a given category. * Highlight failing audits (score < 90). */ audits(report: LHModel.ReporterTypes.ReportJSON, categoryId: LHModel.RunTypes.CategoryId): string { const category = report.categories[categoryId]; if (!category) { return `Category "${categoryId}" not found.`; } const lines: string[] = []; lines.push(`# Audits for ${category.title}`); if (category.description) { lines.push(`${category.description.replace(/\n/g, ' ')}`); } lines.push(''); const failingAudits = category.auditRefs.filter(ref => { const audit = report.audits[ref.id]; return audit && audit.score !== null && audit.score < 0.9; }); if (failingAudits.length === 0) { lines.push('All audits in this category passed (score >= 90).'); return lines.join('\n'); } lines.push('The following audits in this category have a score below 90 and may need attention:'); for (const ref of failingAudits) { const audit = report.audits[ref.id]; if (!audit) { continue; } const score = audit.score !== null ? Math.round(audit.score * 100) : 'n/a'; let line = `- **${audit.title}**: ${score}`; if (audit.displayValue) { line += ` (${audit.displayValue})`; } lines.push(line); lines.push(` * ${audit.description.replace(/\n/g, ' ')}`); if (audit.details) { const formattedDetails = this.#formatDetails(audit.details); if (formattedDetails) { lines.push(''); lines.push(formattedDetails.split('\n').map(l => ` ${l}`).join('\n')); } } } return lines.join('\n'); } #formatDetails(details: LHModel.ReporterTypes.DetailsJSON): string { switch (details.type) { case 'table': { const lines: string[] = []; if (details.summary) { const summaryParts = []; // Purposefully rule out 0 because we want to skip if there is 0 wasted time. if (details.summary.wastedMs) { summaryParts.push(`Wasted time: ${details.summary.wastedMs}ms`); } // Purposefully rule out 0 because we want to skip if there is 0 wasted time. if (details.summary.wastedBytes) { summaryParts.push(`Wasted bytes: ${details.summary.wastedBytes}`); } if (summaryParts.length > 0) { lines.push(summaryParts.join('\n')); } } lines.push(this.#formatTable(details.headings, details.items)); return lines.join('\n'); } case 'opportunity': { const lines: string[] = []; const summaryParts = []; if (details.overallSavingsMs) { summaryParts.push(`Potential savings: ${details.overallSavingsMs}ms`); } if (details.overallSavingsBytes) { summaryParts.push(`Potential savings: ${details.overallSavingsBytes} bytes`); } if (summaryParts.length > 0) { lines.push(summaryParts.join(', ')); } lines.push(this.#formatTable(details.headings, details.items)); return lines.join('\n'); } default: return ''; } } #formatTable(headings: LHModel.ReporterTypes.TableHeadingJSON[], items: LHModel.ReporterTypes.TableItem[]): string { const lines: string[] = []; for (const item of items) { const itemLines: string[] = []; for (const heading of headings) { const value = item[heading.key] as LHModel.ReporterTypes.TableItemValue; const formattedValues = this.#formatTableValues(value, heading.valueType); for (const {labelSuffix, value: v} of formattedValues) { const baseLabel = heading.label || heading.key; const label = labelSuffix ? `${baseLabel} ${labelSuffix}` : baseLabel; itemLines.push(` * **${label}**: ${v}`); } const subItems = item.subItems; // subItems can technically be a string (TableItemValue), but we // only care about it here if it's a SubItemsJSON (type: // 'subitems'), which represents a nested table of values. if (subItems && typeof subItems === 'object' && 'type' in subItems && subItems.type === 'subitems' && heading.subItemsHeading) { for (const subItem of subItems.items) { const subValue = subItem[heading.subItemsHeading.key] as LHModel.ReporterTypes.TableItemValue; // Skip sub-item values that are identical to the main item's value // for the same heading to avoid redundant output (e.g. if both // show the same "Est Savings" value). if (subValue === value) { continue; } const formattedSubValues = this.#formatTableValues(subValue, heading.subItemsHeading.valueType); for (const {value: v} of formattedSubValues) { itemLines.push(` * ${v}`); } } } } if (itemLines.length > 0) { lines.push(`- Item:`); lines.push(...itemLines); } } return lines.join('\n'); } #formatTableValues(value: LHModel.ReporterTypes.TableItemValue|undefined, valueType?: string): Array<{ value: string, labelSuffix?: string, }> { if (value === undefined || value === null) { return []; } if (typeof value === 'string' || typeof value === 'number') { return [{value: this.#formatValue(value, valueType)}]; } if (typeof value === 'object' && 'type' in value) { switch (value.type) { case 'node': { const results = []; const label = value.nodeLabel || value.selector || value.snippet || '(node)'; results.push({value: label}); if (value.selector && value.selector !== label) { results.push({labelSuffix: 'selector', value: value.selector}); } if (value.path) { results.push({labelSuffix: 'path', value: value.path}); } if (value.explanation) { results.push({labelSuffix: 'explanation', value: value.explanation.replace(/\n/g, ' ')}); } return results; } case 'source-location': { const parts = []; if (value.url) { parts.push(value.url); } if (value.line) { parts.push(String(value.line)); } if (value.column) { parts.push(String(value.column)); } return [{value: parts.join(':')}]; } } } return []; } #formatValue(value: string|number, valueType?: string): string { if (typeof value === 'string') { return value; } switch (valueType) { case 'bytes': { return bytes(value); } case 'timespanMs': case 'ms': { return millis(value); } default: return String(value); } } }