chrome-devtools-frontend
Version:
Chrome DevTools UI
229 lines (214 loc) • 8.1 kB
text/typescript
// 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);
}
}
}