UNPKG

lighthouse

Version:

Automated auditing, performance metrics, and best practices for the web.

506 lines (447 loc) 14.7 kB
/** * @license * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as LH from '../../types/lh.js'; import {isUnderTest} from '../lib/lh-env.js'; import {Util} from '../../shared/util.js'; /** @type {Record<keyof LH.Audit.ProductMetricSavings, number>} */ const METRIC_SAVINGS_PRECISION = { FCP: 50, LCP: 50, INP: 50, TBT: 50, CLS: 0.001, }; /** * @typedef TableOptions * @property {number=} wastedMs * @property {number=} wastedBytes * @property {LH.Audit.Details.Table['sortedBy']=} sortedBy * @property {LH.Audit.Details.Table['skipSumming']=} skipSumming * @property {LH.Audit.Details.Table['isEntityGrouped']=} isEntityGrouped */ /** * @typedef OpportunityOptions * @property {number} overallSavingsMs * @property {number=} overallSavingsBytes * @property {LH.Audit.Details.Opportunity['sortedBy']=} sortedBy * @property {LH.Audit.Details.Opportunity['skipSumming']=} skipSumming * @property {LH.Audit.Details.Opportunity['isEntityGrouped']=} isEntityGrouped */ /** * Clamp figure to 2 decimal places * @param {number} val * @return {number} */ const clampTo2Decimals = val => Math.round(val * 100) / 100; class Audit { /** * @return {LH.Audit.ScoreDisplayModes} */ static get SCORING_MODES() { return { NUMERIC: 'numeric', METRIC_SAVINGS: 'metricSavings', BINARY: 'binary', MANUAL: 'manual', INFORMATIVE: 'informative', NOT_APPLICABLE: 'notApplicable', ERROR: 'error', }; } /** * @return {LH.Audit.Meta} */ static get meta() { throw new Error('Audit meta information must be overridden.'); } /** * @return {Object} */ static get defaultOptions() { return {}; } /* eslint-disable no-unused-vars */ /** * * @param {LH.Artifacts} artifacts * @param {LH.Audit.Context} context * @return {LH.Audit.Product|Promise<LH.Audit.Product>} */ static audit(artifacts, context) { throw new Error('audit() method must be overridden'); } /* eslint-enable no-unused-vars */ /** * Computes a score between 0 and 1 based on the measured `value`. Score is determined by * considering a log-normal distribution governed by two control points (the 10th * percentile value and the median value) and represents the percentage of sites that are * greater than `value`. * * Score characteristics: * - within [0, 1] * - rounded to two digits * - value must meet or beat a controlPoint value to meet or exceed its percentile score: * - value > median will give a score < 0.5; value ≤ median will give a score ≥ 0.5. * - value > p10 will give a score < 0.9; value ≤ p10 will give a score ≥ 0.9. * - values < p10 will get a slight boost so a score of 1 is achievable by a * `value` other than those close to 0. Scores of > ~0.99524 end up rounded to 1. * @param {{median: number, p10: number}} controlPoints * @param {number} value * @return {number} */ static computeLogNormalScore(controlPoints, value) { return Util.computeLogNormalScore(controlPoints, value); } /** * This catches typos in the `key` property of a heading definition of table/opportunity details. * Throws an error if any of keys referenced by headings don't exist in at least one of the items. * * @param {LH.Audit.Details.Table['headings']|LH.Audit.Details.Opportunity['headings']} headings * @param {LH.Audit.Details.Opportunity['items']|LH.Audit.Details.Table['items']} items */ static assertHeadingKeysExist(headings, items) { // If there are no items, there's nothing to check. if (!items.length) return; // Only do this in tests for now. if (!isUnderTest) return; for (const heading of headings) { // `null` heading key means it's a column for subrows only if (heading.key === null) continue; const key = heading.key; if (items.some(item => key in item)) continue; throw new Error(`"${heading.key}" is missing from items`); } } /** * @param {LH.Audit.Details.Checklist['items']} items * @return {LH.Audit.Details.Checklist} */ static makeChecklistDetails(items) { return { type: 'checklist', items, }; } /** * @param {LH.Audit.Details.Table['headings']} headings * @param {LH.Audit.Details.Table['items']} results * @param {TableOptions=} options * @return {LH.Audit.Details.Table} */ static makeTableDetails(headings, results, options = {}) { const {wastedBytes, wastedMs, sortedBy, skipSumming, isEntityGrouped} = options; const summary = (wastedBytes || wastedMs) ? {wastedBytes, wastedMs} : undefined; if (results.length === 0) { return { type: 'table', headings, items: [], summary, }; } Audit.assertHeadingKeysExist(headings, results); return { type: 'table', headings, items: results, summary, sortedBy, skipSumming, isEntityGrouped, }; } /** * @param {LH.Audit.Details.List['items']} items * @return {LH.Audit.Details.List} */ static makeListDetails(items) { return { type: 'list', items: items, }; } /** * @param {LH.IcuMessage | string=} title * @param {LH.IcuMessage | string=} description * @param {LH.Audit.Details.ListableDetail} value * @return {LH.Audit.Details.ListSectionItem} */ static makeListDetailSectionItem(value, title, description) { return { type: 'list-section', title, description, value, }; } /** @typedef {{ * content: string; * title: string; * lineMessages: LH.Audit.Details.SnippetValue['lineMessages']; * generalMessages: LH.Audit.Details.SnippetValue['generalMessages']; * node?: LH.Audit.Details.NodeValue; * maxLineLength?: number; * maxLinesAroundMessage?: number; * }} SnippetInfo */ /** * @param {SnippetInfo} snippetInfo * @return {LH.Audit.Details.SnippetValue} */ static makeSnippetDetails({ content, title, lineMessages, generalMessages, node, maxLineLength = 200, maxLinesAroundMessage = 20, }) { const allLines = Audit._makeSnippetLinesArray(content, maxLineLength); const lines = Util.filterRelevantLines(allLines, lineMessages, maxLinesAroundMessage); return { type: 'snippet', lines, title, lineMessages, generalMessages, lineCount: allLines.length, node, }; } /** * @param {string} content * @param {number} maxLineLength * @return {LH.Audit.Details.SnippetValue['lines']} */ static _makeSnippetLinesArray(content, maxLineLength) { return content.split('\n').map((line, lineIndex) => { const lineNumber = lineIndex + 1; /** @type LH.Audit.Details.SnippetValue['lines'][0] */ const lineDetail = { content: Util.truncate(line, maxLineLength), lineNumber, }; if (line.length > maxLineLength) { lineDetail.truncated = true; } return lineDetail; }); } /** * @param {LH.Audit.Details.Opportunity['headings']} headings * @param {LH.Audit.Details.Opportunity['items']} items * @param {OpportunityOptions} options * @return {LH.Audit.Details.Opportunity} */ static makeOpportunityDetails(headings, items, options) { Audit.assertHeadingKeysExist(headings, items); const {overallSavingsMs, overallSavingsBytes, sortedBy, skipSumming, isEntityGrouped} = options; return { type: 'opportunity', headings: items.length === 0 ? [] : headings, items, overallSavingsMs, overallSavingsBytes, sortedBy, skipSumming, isEntityGrouped, }; } /** * @param {LH.Artifacts.NodeDetails} node * @return {LH.Audit.Details.NodeValue} */ static makeNodeItem(node) { return { type: 'node', lhId: node.lhId, path: node.devtoolsNodePath, selector: node.selector, boundingRect: node.boundingRect, snippet: node.snippet, nodeLabel: node.nodeLabel, }; } /** * @param {LH.Artifacts.Bundle} bundle * @param {number} generatedLine * @param {number} generatedColumn * @return {LH.Audit.Details.SourceLocationValue['original']} */ static _findOriginalLocation(bundle, generatedLine, generatedColumn) { const entry = bundle?.map.findEntry(generatedLine, generatedColumn); if (!entry) return; return { file: entry.sourceURL || '', line: entry.sourceLineNumber || 0, column: entry.sourceColumnNumber || 0, }; } /** * @param {string} url * @param {number} line 0-indexed * @param {number} column 0-indexed * @param {LH.Artifacts.Bundle=} bundle * @return {LH.Audit.Details.SourceLocationValue} */ static makeSourceLocation(url, line, column, bundle) { return { type: 'source-location', url, urlProvider: 'network', line, column, original: bundle && this._findOriginalLocation(bundle, line, column), }; } /** * @param {LH.Artifacts.ConsoleMessage} entry * @param {LH.Artifacts.Bundle=} bundle * @return {LH.Audit.Details.SourceLocationValue | undefined} */ static makeSourceLocationFromConsoleMessage(entry, bundle) { if (!entry.url) return; const line = entry.lineNumber || 0; const column = entry.columnNumber || 0; return this.makeSourceLocation(entry.url, line, column, bundle); } /** * @param {number|null} score * @param {LH.Audit.ScoreDisplayMode} scoreDisplayMode * @param {string} auditId * @return {number|null} */ static _normalizeAuditScore(score, scoreDisplayMode, auditId) { if (scoreDisplayMode === Audit.SCORING_MODES.INFORMATIVE) { return 1; } if (scoreDisplayMode !== Audit.SCORING_MODES.BINARY && scoreDisplayMode !== Audit.SCORING_MODES.NUMERIC && scoreDisplayMode !== Audit.SCORING_MODES.METRIC_SAVINGS) { return null; } // Otherwise, score must be a number in [0, 1]. if (score === null || !Number.isFinite(score)) { throw new Error(`Invalid score for ${auditId}: ${score}`); } if (score > 1) throw new Error(`Audit score for ${auditId} is > 1`); if (score < 0) throw new Error(`Audit score for ${auditId} is < 0`); score = clampTo2Decimals(score); return score; } /** * @param {LH.Audit.ProductMetricSavings|undefined} metricSavings * @return {LH.Audit.ProductMetricSavings|undefined} */ static _quantizeMetricSavings(metricSavings) { if (!metricSavings) return; /** @type {LH.Audit.ProductMetricSavings} */ const normalizedMetricSavings = {...metricSavings}; // eslint-disable-next-line max-len for (const key of /** @type {Array<keyof LH.Audit.ProductMetricSavings>} */ (Object.keys(metricSavings))) { let value = metricSavings[key]; if (value === undefined) continue; value = Math.max(value, 0); const precision = METRIC_SAVINGS_PRECISION[key]; if (precision !== undefined) { value = Math.round(value / precision) * precision; } normalizedMetricSavings[key] = value; } return normalizedMetricSavings; } /** * @param {typeof Audit} audit * @param {string | LH.IcuMessage} errorMessage * @param {string=} errorStack * @return {LH.RawIcu<LH.Audit.Result>} */ static generateErrorAuditResult(audit, errorMessage, errorStack) { return Audit.generateAuditResult(audit, { score: null, errorMessage, errorStack, }); } /** * @param {typeof Audit} audit * @param {LH.Audit.Product} product * @return {LH.RawIcu<LH.Audit.Result>} */ static generateAuditResult(audit, product) { if (product.score === undefined) { throw new Error('generateAuditResult requires a score'); } // Default to binary scoring. let scoreDisplayMode = audit.meta.scoreDisplayMode || Audit.SCORING_MODES.BINARY; let score = product.score; // But override if product contents require it. if (product.errorMessage !== undefined) { // Error result. scoreDisplayMode = Audit.SCORING_MODES.ERROR; } else if (product.notApplicable) { // Audit was determined to not apply to the page. scoreDisplayMode = Audit.SCORING_MODES.NOT_APPLICABLE; } else if (product.scoreDisplayMode) { scoreDisplayMode = product.scoreDisplayMode; } const metricSavings = Audit._quantizeMetricSavings(product.metricSavings); const hasSomeSavings = Object.values(metricSavings || {}).some(v => v); if (scoreDisplayMode === Audit.SCORING_MODES.METRIC_SAVINGS) { if (score && score >= Util.PASS_THRESHOLD) { score = 1; } else if (hasSomeSavings) { score = 0; } else { score = 0.5; } } score = Audit._normalizeAuditScore(score, scoreDisplayMode, audit.meta.id); let auditTitle = audit.meta.title; if (audit.meta.failureTitle) { if (score !== null && score < Util.PASS_THRESHOLD) { auditTitle = audit.meta.failureTitle; } } // The Audit.Product type is bifurcated to enforce numericUnit accompanying numericValue; // the existence of `numericUnit` is our discriminant. // Make ts happy and enforce this contract programmatically by only pulling numericValue off of // a `NumericProduct` type. const numericProduct = 'numericUnit' in product ? product : undefined; return { id: audit.meta.id, title: auditTitle, description: audit.meta.description, score, scoreDisplayMode, numericValue: numericProduct?.numericValue, numericUnit: numericProduct?.numericUnit, displayValue: product.displayValue, explanation: product.explanation, errorMessage: product.errorMessage, errorStack: product.errorStack, warnings: product.warnings, scoringOptions: product.scoringOptions, metricSavings, details: product.details, guidanceLevel: audit.meta.guidanceLevel, }; } /** * @param {LH.Artifacts} artifacts * @param {LH.Audit.Context} context * @returns {LH.Artifacts.MetricComputationDataInput} */ static makeMetricComputationDataInput(artifacts, context) { const trace = artifacts.Trace; const devtoolsLog = artifacts.DevtoolsLog; const gatherContext = artifacts.GatherContext; const {URL, HostDPR, SourceMaps} = artifacts; // eslint-disable-next-line max-len return {trace, devtoolsLog, gatherContext, settings: context.settings, URL, SourceMaps, HostDPR, simulator: null}; } } export {Audit};