lighthouse
Version:
Automated auditing, performance metrics, and best practices for the web.
545 lines (473 loc) • 21.3 kB
JavaScript
/**
* @license
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs';
import path from 'path';
import log from 'lighthouse-logger';
import {isEqual} from 'lodash-es';
import {ReportScoring} from './scoring.js';
import {Audit} from './audits/audit.js';
import * as format from '../shared/localization/format.js';
import * as stackPacks from './lib/stack-packs.js';
import * as assetSaver from './lib/asset-saver.js';
import {Sentry} from './lib/sentry.js';
import {ReportGenerator} from '../report/generator/report-generator.js';
import {LighthouseError} from './lib/lh-error.js';
import {lighthouseVersion} from '../shared/root.js';
import {getModuleDirectory} from '../shared/esm-utils.js';
import {EntityClassification} from './computed/entity-classification.js';
import UrlUtils from './lib/url-utils.js';
const moduleDir = getModuleDirectory(import.meta);
/** @typedef {import('./lib/arbitrary-equality-map.js').ArbitraryEqualityMap} ArbitraryEqualityMap */
class Runner {
/**
* @param {LH.Artifacts} artifacts
* @param {{resolvedConfig: LH.Config.ResolvedConfig, computedCache: Map<string, ArbitraryEqualityMap>}} options
* @return {Promise<LH.RunnerResult|undefined>}
*/
static async audit(artifacts, options) {
const {resolvedConfig, computedCache} = options;
const settings = resolvedConfig.settings;
try {
const runnerStatus = {msg: 'Audit phase', id: 'lh:runner:audit'};
log.time(runnerStatus, 'verbose');
/**
* List of top-level warnings for this Lighthouse run.
* @type {Array<string | LH.IcuMessage>}
*/
const lighthouseRunWarnings = [];
// Potentially quit early
if (settings.gatherMode && !settings.auditMode) return;
if (!resolvedConfig.audits) {
throw new Error('No audits to evaluate.');
}
const auditResultsById = await Runner._runAudits(settings, resolvedConfig.audits, artifacts,
lighthouseRunWarnings, computedCache);
// LHR construction phase
const resultsStatus = {msg: 'Generating results...', id: 'lh:runner:generate'};
log.time(resultsStatus);
if (artifacts.LighthouseRunWarnings) {
lighthouseRunWarnings.push(...artifacts.LighthouseRunWarnings);
}
// Entering: conclusion of the lighthouse result object
// Use version from gathering stage.
// If accessibility gatherer didn't run or errored, it won't be in credits.
const axeVersion = artifacts.Accessibility?.version;
const credits = {
'axe-core': axeVersion,
};
/** @type {Record<string, LH.RawIcu<LH.Result.Category>>} */
let categories = {};
if (resolvedConfig.categories) {
categories = ReportScoring.scoreAllCategories(resolvedConfig.categories, auditResultsById);
}
log.timeEnd(resultsStatus);
log.timeEnd(runnerStatus);
/** @type {LH.Artifacts['FullPageScreenshot']|undefined} */
let fullPageScreenshot = artifacts.FullPageScreenshot;
if (resolvedConfig.settings.disableFullPageScreenshot ||
fullPageScreenshot instanceof Error) {
fullPageScreenshot = undefined;
}
/** @type {LH.RawIcu<LH.Result>} */
const i18nLhr = {
lighthouseVersion,
requestedUrl: artifacts.URL.requestedUrl,
mainDocumentUrl: artifacts.URL.mainDocumentUrl,
finalDisplayedUrl: artifacts.URL.finalDisplayedUrl,
finalUrl: artifacts.URL.mainDocumentUrl,
fetchTime: artifacts.fetchTime,
gatherMode: artifacts.GatherContext.gatherMode,
runtimeError: Runner.getArtifactRuntimeError(artifacts),
runWarnings: lighthouseRunWarnings,
userAgent: artifacts.HostUserAgent,
environment: {
networkUserAgent: artifacts.NetworkUserAgent,
hostUserAgent: artifacts.HostUserAgent,
benchmarkIndex: artifacts.BenchmarkIndex,
credits,
},
audits: auditResultsById,
configSettings: settings,
categories,
categoryGroups: resolvedConfig.groups || undefined,
stackPacks: stackPacks.getStackPacks(artifacts.Stacks),
entities: await Runner.getEntityClassification(artifacts, {computedCache}),
fullPageScreenshot,
timing: this._getTiming(artifacts),
i18n: {
rendererFormattedStrings: format.getRendererFormattedStrings(settings.locale),
icuMessagePaths: {},
},
};
// Replace ICU message references with localized strings; save replaced paths in lhr.
i18nLhr.i18n.icuMessagePaths = format.replaceIcuMessages(i18nLhr, settings.locale);
// LHR has now been localized.
const lhr = /** @type {LH.Result} */ (i18nLhr);
if (settings.auditMode) {
const path = Runner._getDataSavePath(settings);
assetSaver.saveLhr(lhr, path);
}
// Create the HTML, JSON, and/or CSV string
const report = ReportGenerator.generateReport(lhr, settings.output);
return {lhr, artifacts, report};
} catch (err) {
throw Runner.createRunnerError(err, settings);
}
}
/**
* @param {LH.Artifacts} artifacts
* @param {LH.Artifacts.ComputedContext} context
*/
static async getEntityClassification(artifacts, context) {
const devtoolsLog = artifacts.DevtoolsLog;
if (!devtoolsLog) return;
const classifiedEntities = await EntityClassification.request(
{URL: artifacts.URL, devtoolsLog}, context);
/** @type {Array<LH.Result.LhrEntity>} */
const entities = [];
for (const [entity, entityUrls] of classifiedEntities.urlsByEntity) {
const uniqueOrigins = new Set();
for (const url of entityUrls) {
const origin = UrlUtils.getOrigin(url);
if (origin) uniqueOrigins.add(origin);
}
/** @type {LH.Result.LhrEntity} */
const shortEntity = {
name: entity.name,
homepage: entity.homepage,
origins: [...uniqueOrigins],
};
// Reduce payload size in LHR JSON by omitting whats falsy.
if (entity === classifiedEntities.firstParty) shortEntity.isFirstParty = true;
if (entity.isUnrecognized) shortEntity.isUnrecognized = true;
if (entity.category) shortEntity.category = entity.category;
entities.push(shortEntity);
}
return entities;
}
/**
* User can run -G solo, -A solo, or -GA together
* -G and -A will run partial lighthouse pipelines,
* and -GA will run everything plus save artifacts and lhr to disk.
*
* @param {(runnerData: {resolvedConfig: LH.Config.ResolvedConfig}) => Promise<LH.Artifacts>} gatherFn
* @param {{resolvedConfig: LH.Config.ResolvedConfig, computedCache: Map<string, ArbitraryEqualityMap>}} options
* @return {Promise<LH.Artifacts>}
*/
static async gather(gatherFn, options) {
const settings = options.resolvedConfig.settings;
// Either load saved artifacts from disk or from the browser.
try {
const sentryContext = Sentry.getContext();
Sentry.captureBreadcrumb({
message: 'Run started',
category: 'lifecycle',
data: sentryContext,
});
if (settings.auditMode && !settings.gatherMode) {
// No browser required, just load the artifacts from disk.
const path = this._getDataSavePath(settings);
return assetSaver.loadArtifacts(path);
}
const runnerStatus = {msg: 'Gather phase', id: 'lh:runner:gather'};
log.time(runnerStatus, 'verbose');
const artifacts = await gatherFn({resolvedConfig: options.resolvedConfig});
log.timeEnd(runnerStatus);
// If `gather` is run multiple times before `audit`, the timing entries for each `gather` can pollute one another.
// We need to clear the timing entries at the end of gathering.
// Set artifacts.Timing again to ensure lh:runner:gather is included.
artifacts.Timing = log.takeTimeEntries();
// -G means save these to disk (e.g. ./latest-run).
if (settings.gatherMode) {
const path = this._getDataSavePath(settings);
await assetSaver.saveArtifacts(artifacts, path);
}
return artifacts;
} catch (err) {
throw Runner.createRunnerError(err, settings);
}
}
/**
* @param {any} err
* @param {LH.Config.Settings} settings
*/
static createRunnerError(err, settings) {
// i18n LighthouseError strings.
if (err.friendlyMessage) {
err.friendlyMessage = format.getFormatted(err.friendlyMessage, settings.locale);
}
Sentry.captureException(err, {level: 'fatal'});
return err;
}
/**
* This handles both the auditMode case where gatherer entries need to be merged in and
* the gather/audit case where timingEntriesFromRunner contains all entries from this run,
* including those also in timingEntriesFromArtifacts.
* @param {LH.Artifacts} artifacts
* @return {LH.Result.Timing}
*/
static _getTiming(artifacts) {
const timingEntriesFromArtifacts = artifacts.Timing || [];
const timingEntriesFromRunner = log.takeTimeEntries();
const timingEntriesKeyValues = [
...timingEntriesFromArtifacts,
...timingEntriesFromRunner,
].map(entry => /** @type {[string, PerformanceEntry]} */ ([
// As entries can share a name and start time, dedupe based on the name, startTime and duration
`${entry.startTime}-${entry.name}-${entry.duration}`,
entry,
]));
const timingEntries = Array.from(new Map(timingEntriesKeyValues).values())
// Truncate timestamps to hundredths of a millisecond saves ~4KB. No need for microsecond
// resolution.
.map(entry => {
return {
// Don't spread entry because browser PerformanceEntries can't be spread.
// https://github.com/GoogleChrome/lighthouse/issues/8638
startTime: parseFloat(entry.startTime.toFixed(2)),
name: entry.name,
duration: parseFloat(entry.duration.toFixed(2)),
entryType: entry.entryType,
};
}).sort((a, b) => a.startTime - b.startTime);
const gatherEntry = timingEntries.find(e => e.name === 'lh:runner:gather');
const auditEntry = timingEntries.find(e => e.name === 'lh:runner:audit');
const gatherTiming = gatherEntry?.duration || 0;
const auditTiming = auditEntry?.duration || 0;
return {entries: timingEntries, total: gatherTiming + auditTiming};
}
/**
* Run all audits with specified settings and artifacts.
* @param {LH.Config.Settings} settings
* @param {Array<LH.Config.AuditDefn>} audits
* @param {LH.Artifacts} artifacts
* @param {Array<string | LH.IcuMessage>} runWarnings
* @param {Map<string, ArbitraryEqualityMap>} computedCache
* @return {Promise<Record<string, LH.RawIcu<LH.Audit.Result>>>}
*/
static async _runAudits(settings, audits, artifacts, runWarnings, computedCache) {
const status = {msg: 'Analyzing and running audits...', id: 'lh:runner:auditing'};
log.time(status);
if (artifacts.settings) {
const overrides = {
locale: undefined,
gatherMode: undefined,
auditMode: undefined,
output: undefined,
channel: undefined,
};
const normalizedGatherSettings = Object.assign({}, artifacts.settings, overrides);
const normalizedAuditSettings = Object.assign({}, settings, overrides);
// First, try each key individually so we can print which key differed.
const keys = new Set([
...Object.keys(normalizedGatherSettings),
...Object.keys(normalizedAuditSettings),
]);
for (const k of keys) {
if (!isEqual(normalizedGatherSettings[k], normalizedAuditSettings[k])) {
throw new Error(
`Cannot change settings between gathering and auditing…
Difference found at: \`${k}\`: ${JSON.stringify(normalizedGatherSettings[k], null, 2)}
vs: ${JSON.stringify(normalizedAuditSettings[k], null, 2)}`);
}
}
// Call `isEqual` on the entire thing, just in case something was missed.
if (!isEqual(normalizedGatherSettings, normalizedAuditSettings)) {
throw new Error('Cannot change settings between gathering and auditing');
}
}
// Members of LH.Audit.Context that are shared across all audits.
const sharedAuditContext = {
settings,
computedCache,
};
// Run each audit sequentially
/** @type {Record<string, LH.RawIcu<LH.Audit.Result>>} */
const auditResultsById = {};
for (const auditDefn of audits) {
const auditId = auditDefn.implementation.meta.id;
const auditResult = await Runner._runAudit(auditDefn, artifacts, sharedAuditContext,
runWarnings);
auditResultsById[auditId] = auditResult;
}
log.timeEnd(status);
return auditResultsById;
}
/**
* Checks that the audit's required artifacts exist and runs the audit if so.
* Otherwise returns error audit result.
* @param {LH.Config.AuditDefn} auditDefn
* @param {LH.Artifacts} artifacts
* @param {Pick<LH.Audit.Context, 'settings'|'computedCache'>} sharedAuditContext
* @param {Array<string | LH.IcuMessage>} runWarnings
* @return {Promise<LH.RawIcu<LH.Audit.Result>>}
* @private
*/
static async _runAudit(auditDefn, artifacts, sharedAuditContext, runWarnings) {
const audit = auditDefn.implementation;
const status = {
msg: `Auditing: ${format.getFormatted(audit.meta.title, 'en-US')}`,
id: `lh:audit:${audit.meta.id}`,
};
log.time(status);
let auditResult;
try {
if (artifacts.PageLoadError) throw artifacts.PageLoadError;
// Return an early error if an artifact required for the audit is missing or an error.
for (const artifactName of audit.meta.requiredArtifacts) {
const noArtifact = artifacts[artifactName] === undefined;
// If trace/devtoolsLog required, check that DEFAULT_PASS trace/devtoolsLog exists.
// NOTE: for now, not a pass-specific check of traces or devtoolsLogs.
const noRequiredTrace = artifactName === 'traces' &&
!artifacts.traces?.[Audit.DEFAULT_PASS];
const noRequiredDevtoolsLog = artifactName === 'devtoolsLogs' &&
!artifacts.devtoolsLogs?.[Audit.DEFAULT_PASS];
if (noArtifact || noRequiredTrace || noRequiredDevtoolsLog) {
log.warn('Runner',
`${artifactName} gatherer, required by audit ${audit.meta.id}, did not run.`);
throw new LighthouseError(
LighthouseError.errors.MISSING_REQUIRED_ARTIFACT, {artifactName});
}
// If artifact was an error, output error result on behalf of audit.
if (artifacts[artifactName] instanceof Error) {
/** @type {Error} */
const artifactError = artifacts[artifactName];
log.warn('Runner', `${artifactName} gatherer, required by audit ${audit.meta.id},` +
` encountered an error: ${artifactError.message}`);
// Create a friendlier display error and mark it as expected to avoid duplicates in Sentry.
// The artifact error was already sent to Sentry in `collectPhaseArtifacts`.
const error = new LighthouseError(LighthouseError.errors.ERRORED_REQUIRED_ARTIFACT,
{artifactName, errorMessage: artifactError.message}, {cause: artifactError});
// @ts-expect-error Non-standard property added to Error
error.expected = true;
throw error;
}
}
// all required artifacts are in good shape, so we proceed
const auditOptions = Object.assign({}, audit.defaultOptions, auditDefn.options);
const auditContext = {
options: auditOptions,
...sharedAuditContext,
};
// Only pass the declared required and optional artifacts to the audit
// The type is masquerading as `LH.Artifacts` but will only contain a subset of the keys
// to prevent consumers from unnecessary type assertions.
const requestedArtifacts = audit.meta.requiredArtifacts
.concat(audit.meta.__internalOptionalArtifacts || []);
const narrowedArtifacts = requestedArtifacts
.reduce((narrowedArtifacts, artifactName) => {
const requestedArtifact = artifacts[artifactName];
// @ts-expect-error tsc can't yet express that artifactName is only a single type in each iteration, not a union of types.
narrowedArtifacts[artifactName] = requestedArtifact;
return narrowedArtifacts;
}, /** @type {LH.Artifacts} */ ({}));
const product = await audit.audit(narrowedArtifacts, auditContext);
runWarnings.push(...(product.runWarnings || []));
auditResult = Audit.generateAuditResult(audit, product);
} catch (err) {
// Log error if it hasn't already been logged above.
if (err.code !== 'MISSING_REQUIRED_ARTIFACT' && err.code !== 'ERRORED_REQUIRED_ARTIFACT') {
log.warn(audit.meta.id, `Caught exception: ${err.message}`);
}
Sentry.captureException(err, {tags: {audit: audit.meta.id}, level: 'error'});
// Errors become error audit result.
const errorMessage = err.friendlyMessage ? err.friendlyMessage : err.message;
// Prefer the stack trace closest to the error.
const stack = err.cause?.stack ?? err.stack;
auditResult = Audit.generateErrorAuditResult(audit, errorMessage, stack);
}
log.timeEnd(status);
return auditResult;
}
/**
* Searches a pass's artifacts for any `lhrRuntimeError` error artifacts.
* Returns the first one found or `null` if none found.
* @param {LH.Artifacts} artifacts
* @return {LH.RawIcu<LH.Result['runtimeError']>|undefined}
*/
static getArtifactRuntimeError(artifacts) {
/** @type {Array<[string, LighthouseError|object]>} */
const possibleErrorArtifacts = [
['PageLoadError', artifacts.PageLoadError], // Preferentially use `PageLoadError`, if it exists.
...Object.entries(artifacts), // Otherwise check amongst all artifacts.
];
for (const [artifactKey, possibleErrorArtifact] of possibleErrorArtifacts) {
const isError = possibleErrorArtifact instanceof LighthouseError;
if (isError && possibleErrorArtifact.lhrRuntimeError) {
const errorMessage = possibleErrorArtifact.friendlyMessage || possibleErrorArtifact.message;
// Prefer the stack trace closest to the error.
const stack =
/** @type {any} */ (possibleErrorArtifact.cause)?.stack ?? possibleErrorArtifact.stack;
return {
code: possibleErrorArtifact.code,
message: errorMessage,
errorStack: stack,
artifactKey,
};
}
}
return undefined;
}
/**
* Returns list of audit names for external querying.
* @return {Array<string>}
*/
static getAuditList() {
const ignoredFiles = [
'audit.js',
'violation-audit.js',
'accessibility/axe-audit.js',
'multi-check-audit.js',
'byte-efficiency/byte-efficiency-audit.js',
'manual/manual-audit.js',
'insights/insight-audit.js',
];
const fileList = [
...fs.readdirSync(path.join(moduleDir, './audits')),
...fs.readdirSync(path.join(moduleDir, './audits/dobetterweb')).map(f => `dobetterweb/${f}`),
...fs.readdirSync(path.join(moduleDir, './audits/metrics')).map(f => `metrics/${f}`),
...fs.readdirSync(path.join(moduleDir, './audits/seo')).map(f => `seo/${f}`),
...fs.readdirSync(path.join(moduleDir, './audits/seo/manual')).map(f => `seo/manual/${f}`),
...fs.readdirSync(path.join(moduleDir, './audits/accessibility'))
.map(f => `accessibility/${f}`),
...fs.readdirSync(path.join(moduleDir, './audits/accessibility/manual'))
.map(f => `accessibility/manual/${f}`),
...fs.readdirSync(path.join(moduleDir, './audits/byte-efficiency'))
.map(f => `byte-efficiency/${f}`),
...fs.readdirSync(path.join(moduleDir, './audits/manual')).map(f => `manual/${f}`),
...fs.readdirSync(path.join(moduleDir, './audits/insights')).map(f => `insights/${f}`),
];
return fileList.filter(f => {
return /\.js$/.test(f) && !ignoredFiles.includes(f);
}).sort();
}
/**
* Returns list of gatherer names for external querying.
* @return {Array<string>}
*/
static getGathererList() {
const fileList = [
...fs.readdirSync(path.join(moduleDir, './gather/gatherers')),
...fs.readdirSync(path.join(moduleDir, './gather/gatherers/seo')).map(f => `seo/${f}`),
...fs.readdirSync(path.join(moduleDir, './gather/gatherers/dobetterweb'))
.map(f => `dobetterweb/${f}`),
];
return fileList.filter(f => /\.js$/.test(f) && f !== 'gatherer.js').sort();
}
/**
* Get path to use for -G and -A modes. Defaults to $CWD/latest-run
* @param {LH.Config.Settings} settings
* @return {string}
*/
static _getDataSavePath(settings) {
const {auditMode, gatherMode} = settings;
// This enables usage like: -GA=./custom-folder
if (typeof auditMode === 'string') return path.resolve(process.cwd(), auditMode);
if (typeof gatherMode === 'string') return path.resolve(process.cwd(), gatherMode);
return path.join(process.cwd(), 'latest-run');
}
}
export {Runner};