lighthouse
Version:
Automated auditing, performance metrics, and best practices for the web.
560 lines (485 loc) • 17.7 kB
JavaScript
/**
* @license
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs';
import path from 'path';
import stream from 'stream';
import url from 'url';
import {createGzip, gunzipSync} from 'zlib';
import log from 'lighthouse-logger';
import * as Lantern from './lantern/lantern.js';
import lanternTraceSaver from './lantern-trace-saver.js';
import {MetricTraceEvents} from './traces/metric-trace-events.js';
import {NetworkAnalysis} from '../computed/network-analysis.js';
import {LoadSimulator} from '../computed/load-simulator.js';
import {LighthouseError} from '../lib/lh-error.js';
import {LH_ROOT} from '../../shared/root.js';
const optionsFilename = 'options.json';
const artifactsFilename = 'artifacts.json';
const traceSuffix = '.trace.json';
const devtoolsLogSuffix = '.devtoolslog.json';
const defaultPrefix = 'defaultPass';
const errorPrefix = 'pageLoadError-defaultPass';
const stepDirectoryRegex = /^step(\d+)$/;
/**
* @typedef {object} PreparedAssets
* @property {LH.Trace} [traceData]
* @property {LH.DevtoolsLog} [devtoolsLog]
*/
/**
* @param {import('stream').PipelineSource<any>} contents
* @param {string} path
* @param {boolean} gzip
*/
async function writeJson(contents, path, gzip) {
const writeStream = fs.createWriteStream(gzip ? path + '.gz' : path);
if (gzip) {
await stream.promises.pipeline(contents, createGzip(), writeStream);
} else {
await stream.promises.pipeline(contents, writeStream);
}
}
/**
* Prefers reading a gzipped file (.gz) if present.
* @param {string} filename
* @param {(this: any, key: string, value: any) => any=} reviver
*/
function readJson(filename, reviver) {
if (fs.existsSync(filename + '.gz')) {
filename = filename + '.gz';
}
if (!filename.endsWith('.json.gz')) {
return JSON.parse(fs.readFileSync(filename, 'utf8'), reviver);
}
const buffer = gunzipSync(fs.readFileSync(filename));
return JSON.parse(buffer.toString('utf8'), reviver);
}
/**
* @param {string} filename
* @param {string} suffix
* @returns
*/
function endsWithSuffix(filename, suffix) {
return filename.endsWith(suffix) || filename.endsWith(suffix + '.gz');
}
/**
* Load artifacts object from files located within basePath
* Also save the traces to their own files
* @param {string} basePath
* @return {LH.Artifacts}
*/
function loadArtifacts(basePath) {
log.log('Reading artifacts from disk:', basePath);
if (!fs.existsSync(basePath)) {
throw new Error('No saved artifacts found at ' + basePath);
}
// load artifacts.json using a reviver to deserialize any LighthouseErrors in artifacts.
/** @type {LH.Artifacts} */
const artifacts = readJson(path.join(basePath, artifactsFilename), LighthouseError.parseReviver);
const filenames = fs.readdirSync(basePath);
filenames.filter(f => endsWithSuffix(f, devtoolsLogSuffix)).forEach(filename => {
if (!artifacts.devtoolsLogs) artifacts.devtoolsLogs = {};
const prefix = filename.replace(devtoolsLogSuffix + '.gz', '').replace(devtoolsLogSuffix, '');
const devtoolsLog = readJson(path.join(basePath, filename));
artifacts.devtoolsLogs[prefix] = devtoolsLog;
if (prefix === defaultPrefix) {
artifacts.DevtoolsLog = devtoolsLog;
}
if (prefix === errorPrefix) {
artifacts.DevtoolsLogError = devtoolsLog;
}
});
filenames.filter(f => endsWithSuffix(f, traceSuffix)).forEach(filename => {
if (!artifacts.traces) artifacts.traces = {};
const trace = readJson(path.join(basePath, filename));
const prefix = filename.replace(traceSuffix + '.gz', '').replace(traceSuffix, '');
artifacts.traces[prefix] = Array.isArray(trace) ? {traceEvents: trace} : trace;
if (prefix === defaultPrefix) {
artifacts.Trace = artifacts.traces[prefix];
}
if (prefix === errorPrefix) {
artifacts.TraceError = artifacts.traces[prefix];
}
});
if (Array.isArray(artifacts.Timing)) {
// Any Timing entries in saved artifacts will have a different timeOrigin than the auditing phase
// The `gather` prop is read later in generate-timing-trace and they're added to a separate track of trace events
artifacts.Timing.forEach(entry => (entry.gather = true));
}
return artifacts;
}
/**
* @param {string} basePath
* @return {LH.UserFlow.FlowArtifacts}
*/
function loadFlowArtifacts(basePath) {
log.log('Reading flow artifacts from disk:', basePath);
if (!fs.existsSync(basePath)) {
throw new Error('No saved flow artifacts found at ' + basePath);
}
/** @type {LH.UserFlow.FlowArtifacts} */
const flowArtifacts = JSON.parse(
fs.readFileSync(path.join(basePath, optionsFilename), 'utf-8')
);
const filenames = fs.readdirSync(basePath);
flowArtifacts.gatherSteps = [];
for (const filename of filenames) {
const regexResult = stepDirectoryRegex.exec(filename);
if (!regexResult) continue;
const index = Number(regexResult[1]);
if (!Number.isFinite(index)) continue;
const stepPath = path.join(basePath, filename);
if (!fs.lstatSync(stepPath).isDirectory()) continue;
/** @type {LH.UserFlow.GatherStep} */
const gatherStep = JSON.parse(
fs.readFileSync(path.join(stepPath, optionsFilename), 'utf-8')
);
gatherStep.artifacts = loadArtifacts(stepPath);
flowArtifacts.gatherSteps[index] = gatherStep;
}
const missingStepIndex = flowArtifacts.gatherSteps.findIndex(gatherStep => !gatherStep);
if (missingStepIndex !== -1) {
throw new Error(`Could not find step with index ${missingStepIndex} at ${basePath}`);
}
return flowArtifacts;
}
/**
* A replacer function for JSON.stingify of the artifacts. Used to serialize objects that
* JSON won't normally handle.
* @param {string} key
* @param {any} value
*/
function stringifyReplacer(key, value) {
// Currently only handle LighthouseError and other Error types.
if (value instanceof Error) {
return LighthouseError.stringifyReplacer(value);
}
return value;
}
/**
* Saves flow artifacts with the following file structure:
* flow/ -- Directory specified by `basePath`.
* options.json -- Flow options (e.g. flow name, flags).
* step0/ -- Directory containing artifacts for the first step.
* options.json -- First step's options (e.g. step flags).
* artifacts.json -- First step's artifacts except the DevTools log and trace.
* defaultPass.devtoolslog.json -- First step's DevTools log.
* defaultPass.trace.json -- First step's trace.
* step1/ -- Directory containing artifacts for the second step.
*
* @param {LH.UserFlow.FlowArtifacts} flowArtifacts
* @param {string} basePath
* @return {Promise<void>}
*/
async function saveFlowArtifacts(flowArtifacts, basePath) {
const status = {msg: 'Saving flow artifacts', id: 'lh:assetSaver:saveArtifacts'};
log.time(status);
fs.mkdirSync(basePath, {recursive: true});
// Delete any previous artifacts in this directory.
const filenames = fs.readdirSync(basePath);
for (const filename of filenames) {
if (stepDirectoryRegex.test(filename) || filename === optionsFilename) {
fs.rmSync(`${basePath}/${filename}`, {recursive: true});
}
}
const {gatherSteps, ...flowOptions} = flowArtifacts;
for (let i = 0; i < gatherSteps.length; ++i) {
const {artifacts, ...stepOptions} = gatherSteps[i];
const stepPath = path.join(basePath, `step${i}`);
await saveArtifacts(artifacts, stepPath);
fs.writeFileSync(
path.join(stepPath, optionsFilename),
JSON.stringify(stepOptions, stringifyReplacer, 2) + '\n'
);
}
fs.writeFileSync(
path.join(basePath, optionsFilename),
JSON.stringify(flowOptions, stringifyReplacer, 2) + '\n'
);
log.log('Flow artifacts saved to disk in folder:', basePath);
log.timeEnd(status);
}
/**
* Save artifacts object mostly to single file located at basePath/artifacts.json.
* Also save the traces & devtoolsLogs to their own files, with optional compression.
* @param {LH.Artifacts} artifacts
* @param {string} basePath
* @param {{gzip?: boolean}} options
* @return {Promise<void>}
*/
async function saveArtifacts(artifacts, basePath, options = {}) {
const status = {msg: 'Saving artifacts', id: 'lh:assetSaver:saveArtifacts'};
log.time(status);
fs.mkdirSync(basePath, {recursive: true});
// Delete any previous artifacts in this directory.
const filenames = fs.readdirSync(basePath);
for (const filename of filenames) {
const isPreviousFile =
filename.endsWith(traceSuffix) || filename.endsWith(devtoolsLogSuffix) ||
filename.endsWith(traceSuffix + '.gz') || filename.endsWith(devtoolsLogSuffix + '.gz') ||
filename === artifactsFilename || filename === artifactsFilename + '.gz';
if (isPreviousFile) {
fs.unlinkSync(`${basePath}/${filename}`);
}
}
// `devtoolsLogs` and `traces` are duplicate compat artifacts.
// We don't need to save them twice, so extract them here. TODO(v13): remove
const {
// eslint-disable-next-line no-unused-vars
traces,
// eslint-disable-next-line no-unused-vars
devtoolsLogs,
DevtoolsLog,
Trace,
DevtoolsLogError,
TraceError,
...restArtifacts
} = artifacts;
if (Trace) {
await saveTrace(Trace, `${basePath}/${defaultPrefix}${traceSuffix}`, options);
}
if (TraceError) {
await saveTrace(TraceError, `${basePath}/${errorPrefix}${traceSuffix}`, options);
}
if (DevtoolsLog) {
await saveDevtoolsLog(
DevtoolsLog, `${basePath}/${defaultPrefix}${devtoolsLogSuffix}`, options);
}
if (DevtoolsLogError) {
await saveDevtoolsLog(
DevtoolsLogError, `${basePath}/${errorPrefix}${devtoolsLogSuffix}`, options);
}
// save everything else, using a replacer to serialize LighthouseErrors in the artifacts.
const restArtifactsString = JSON.stringify(restArtifacts, stringifyReplacer, 2);
await writeJson(function* () {
yield restArtifactsString;
yield '\n';
}, `${basePath}/${artifactsFilename}`, !!options.gzip);
log.log('Artifacts saved to disk in folder:', basePath);
log.timeEnd(status);
}
/**
* Save LHR to file located at basePath/lhr.report.json.
* @param {LH.Result} lhr
* @param {string} basePath
*/
function saveLhr(lhr, basePath) {
fs.writeFileSync(`${basePath}/lhr.report.json`, JSON.stringify(lhr, null, 2));
}
/**
* Filter trace and extract screenshots to prepare for saving.
* @param {LH.Trace} trace
* @param {LH.Result['audits']} [audits]
* @return {LH.Trace}
*/
function prepareTraceAsset(trace, audits) {
if (!trace) return trace;
const traceData = Object.assign({}, trace);
if (audits) {
const evts = new MetricTraceEvents(traceData.traceEvents, audits).generateFakeEvents();
traceData.traceEvents = traceData.traceEvents.concat(evts);
}
return traceData;
}
/**
* @param {LH.Artifacts} artifacts
* @param {LH.Result['audits']} [audits]
* @return {Promise<Array<PreparedAssets>>}
*/
async function prepareAssets(artifacts, audits) {
/** @type {Array<PreparedAssets>} */
const assets = [];
const devtoolsLog = artifacts.DevtoolsLog;
const traceData = prepareTraceAsset(artifacts.Trace, audits);
if (traceData || devtoolsLog) {
assets.push({
traceData,
devtoolsLog,
});
}
const devtoolsLogError = artifacts.DevtoolsLogError;
const traceErrorData = prepareTraceAsset(artifacts.TraceError, audits);
if (devtoolsLogError || traceErrorData) {
assets.push({
traceData: traceErrorData,
devtoolsLog: devtoolsLogError,
});
}
return assets;
}
/**
* Generates a JSON representation of an array of objects with the objects
* printed one per line for a more readable (but not too verbose) version.
* @param {Array<unknown>} arrayOfObjects
* @return {IterableIterator<string>}
*/
function* arrayOfObjectsJsonGenerator(arrayOfObjects) {
const ITEMS_PER_ITERATION = 500;
// Stringify and emit items separately to avoid a giant string in memory.
yield '[\n';
if (arrayOfObjects.length > 0) {
const itemsIterator = arrayOfObjects[Symbol.iterator]();
// Emit first item manually to avoid a trailing comma.
const firstItem = itemsIterator.next().value;
yield ` ${JSON.stringify(firstItem)}`;
let itemsRemaining = ITEMS_PER_ITERATION;
let itemsJSON = '';
for (const item of itemsIterator) {
itemsJSON += `,\n ${JSON.stringify(item)}`;
itemsRemaining--;
if (itemsRemaining === 0) {
yield itemsJSON;
itemsRemaining = ITEMS_PER_ITERATION;
itemsJSON = '';
}
}
yield itemsJSON;
}
yield '\n]';
}
/**
* Generates a JSON representation of traceData line-by-line for a nicer printed
* version with one trace event per line.
* @param {LH.Trace} traceData
* @return {IterableIterator<string>}
*/
function* traceJsonGenerator(traceData) {
const {traceEvents, ...rest} = traceData;
yield '{\n';
yield '"traceEvents": ';
yield* arrayOfObjectsJsonGenerator(traceEvents);
// Emit the rest of the object (usually just `metadata`, if anything).
for (const [key, value] of Object.entries(rest)) {
yield `,\n"${key}": ${JSON.stringify(value, null, 2)}`;
}
yield '}\n';
}
/**
* Save a trace as JSON by streaming to disk at traceFilename.
* @param {LH.Trace} traceData
* @param {string} traceFilename
* @param {{gzip?: boolean}=} options
* @return {Promise<void>}
*/
function saveTrace(traceData, traceFilename, options = {}) {
const traceIter = traceJsonGenerator(traceData);
return writeJson(traceIter, traceFilename, !!options.gzip);
}
/**
* Save a devtoolsLog as JSON by streaming to disk at devtoolLogFilename.
* @param {LH.DevtoolsLog} devtoolsLog
* @param {string} devtoolLogFilename
* @param {{gzip?: boolean}=} options
* @return {Promise<void>}
*/
function saveDevtoolsLog(devtoolsLog, devtoolLogFilename, options = {}) {
return writeJson(function* () {
yield* arrayOfObjectsJsonGenerator(devtoolsLog);
yield '\n';
}, devtoolLogFilename, !!options.gzip);
}
/**
* @param {string} pathWithBasename
* @return {Promise<void>}
*/
async function saveLanternDebugTraces(pathWithBasename) {
if (!process.env.LANTERN_DEBUG) return;
for (const [label, nodeTimings] of Lantern.Simulation.Simulator.allNodeTimings) {
if (lanternTraceSaver.simulationNamesToIgnore.includes(label)) continue;
const traceFilename = `${pathWithBasename}-${label}${traceSuffix}`;
await saveTrace(lanternTraceSaver.convertNodeTimingsToTrace(nodeTimings), traceFilename);
log.log('saveAssets', `${label} lantern trace file streamed to disk: ${traceFilename}`);
}
}
/**
* Writes trace(s) and associated asset(s) to disk.
* @param {LH.Artifacts} artifacts
* @param {LH.Result['audits']} audits
* @param {string} pathWithBasename
* @return {Promise<void>}
*/
async function saveAssets(artifacts, audits, pathWithBasename) {
const allAssets = await prepareAssets(artifacts, audits);
const saveAll = allAssets.map(async (assets, index) => {
if (assets.devtoolsLog) {
const devtoolsLogFilename = `${pathWithBasename}-${index}${devtoolsLogSuffix}`;
await saveDevtoolsLog(assets.devtoolsLog, devtoolsLogFilename);
log.log('saveAssets', 'devtools log saved to disk: ' + devtoolsLogFilename);
}
if (assets.traceData) {
const traceFilename = `${pathWithBasename}-${index}${traceSuffix}`;
await saveTrace(assets.traceData, traceFilename);
log.log('saveAssets', 'trace file streamed to disk: ' + traceFilename);
}
});
await Promise.all(saveAll);
await saveLanternDebugTraces(pathWithBasename);
}
/**
* @param {LH.DevtoolsLog} devtoolsLog
* @param {string} outputPath
* @return {Promise<void>}
*/
async function saveLanternNetworkData(devtoolsLog, outputPath) {
/** @type {LH.Audit.Context} */
// @ts-expect-error - the full audit context isn't needed for analysis.
const context = {computedCache: new Map()};
const networkAnalysis = await NetworkAnalysis.request(devtoolsLog, context);
const lanternData = LoadSimulator.convertAnalysisToSaveableLanternData(networkAnalysis);
fs.writeFileSync(outputPath, JSON.stringify(lanternData));
}
/**
* Normalize timing data so it doesn't change every update.
* @param {LH.Result.MeasureEntry[]} timings
*/
function normalizeTimingEntries(timings) {
let baseTime = 0;
for (const timing of timings) {
// @ts-expect-error: Value actually is writeable at this point.
timing.startTime = baseTime++;
// @ts-expect-error: Value actually is writeable at this point.
timing.duration = 1;
}
}
/**
* @param {string} errorStack
* @return {string}
*/
function elideErrorStack(errorStack) {
const baseCallFrameUrl = url.pathToFileURL(LH_ROOT);
return errorStack
// Make paths relative to the repo root.
.replaceAll(baseCallFrameUrl.pathname, '')
// Remove line/col info.
.replaceAll(/:\d+:\d+/g, '');
}
/**
* @param {LH.Result} lhr
*/
function elideLhrErrorStacks(lhr) {
for (const auditResult of Object.values(lhr.audits)) {
if (auditResult.errorStack) {
auditResult.errorStack = elideErrorStack(auditResult.errorStack);
}
}
if (lhr.runtimeError?.errorStack) {
lhr.runtimeError.errorStack = elideErrorStack(lhr.runtimeError.errorStack);
}
}
export {
saveArtifacts,
saveFlowArtifacts,
saveLhr,
loadArtifacts,
loadFlowArtifacts,
saveAssets,
prepareAssets,
saveTrace,
saveDevtoolsLog,
saveLanternNetworkData,
stringifyReplacer,
normalizeTimingEntries,
elideLhrErrorStacks,
};