UNPKG

lighthouse

Version:

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

245 lines (211 loc) • 8.57 kB
/** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /* eslint-disable no-console */ import path from 'path'; import os from 'os'; import * as ChromeLauncher from 'chrome-launcher'; import yargsParser from 'yargs-parser'; import log from 'lighthouse-logger'; import open from 'open'; import * as Printer from './printer.js'; import lighthouse from '../core/index.js'; import {getLhrFilenamePrefix} from '../report/generator/file-namer.js'; import * as assetSaver from '../core/lib/asset-saver.js'; import UrlUtils from '../core/lib/url-utils.js'; /** @typedef {Error & {code: string, friendlyMessage?: string}} ExitError */ const _RUNTIME_ERROR_CODE = 1; const _PROTOCOL_TIMEOUT_EXIT_CODE = 67; /** * exported for testing * @param {string|Array<string>} flags * @return {Array<string>} */ function parseChromeFlags(flags = '') { // flags will be a string if there is only one chrome-flag parameter: // i.e. `lighthouse --chrome-flags="--user-agent='My Agent' --headless"` // flags will be an array if there are multiple chrome-flags parameters // i.e. `lighthouse --chrome-flags="--user-agent='My Agent'" --chrome-flags="--headless"` const trimmedFlags = (Array.isArray(flags) ? flags : [flags]) // `child_process.execFile` and other programmatic invocations will pass Lighthouse arguments atomically. // Many developers aren't aware of this and attempt to pass arguments to LH as they would to a shell `--chromeFlags="--headless --no-sandbox"`. // In this case, yargs will see `"--headless --no-sandbox"` and treat it as a single argument instead of the intended `--headless --no-sandbox`. // We remove quotes that surround the entire expression to make this work. // i.e. `child_process.execFile("lighthouse", ["http://google.com", "--chrome-flags='--headless --no-sandbox'")` // the following regular expression removes those wrapping quotes: .map((flagsGroup) => flagsGroup.replace(/^\s*('|")(.+)\1\s*$/, '$2').trim()) .join(' ').trim(); const parsed = yargsParser(trimmedFlags, { configuration: {'camel-case-expansion': false, 'boolean-negation': false}, }); return Object .keys(parsed) // Remove unnecessary _ item provided by yargs, .filter(key => key !== '_') // Avoid '=true', then reintroduce quotes .map(key => { if (parsed[key] === true) return `--${key}`; // ChromeLauncher passes flags to Chrome as atomic arguments, so do not double quote // i.e. `lighthouse --chrome-flags="--user-agent='My Agent'"` becomes `chrome "--user-agent=My Agent"` // see https://github.com/GoogleChrome/lighthouse/issues/3744 return `--${key}=${parsed[key]}`; }); } /** * Attempts to connect to an instance of Chrome with an open remote-debugging * port. If none is found, launches a debuggable instance. * @param {LH.CliFlags} flags * @return {Promise<ChromeLauncher.LaunchedChrome>} */ function getDebuggableChrome(flags) { if (process.platform === 'darwin' && process.arch === 'x64') { const cpus = os.cpus(); if (cpus[0].model.includes('Apple')) { throw new Error( 'Launching Chrome on Mac Silicon (arm64) from an x64 Node installation results in ' + 'Rosetta translating the Chrome binary, even if Chrome is already arm64. This would ' + 'result in huge performance issues. To resolve this, you must run Lighthouse CLI with ' + 'a version of Node built for arm64. You should also confirm that your Chrome install ' + 'says arm64 in chrome://version'); } } return ChromeLauncher.launch({ port: flags.port, ignoreDefaultFlags: flags.chromeIgnoreDefaultFlags, chromeFlags: parseChromeFlags(flags.chromeFlags), logLevel: flags.logLevel, }); } /** @return {never} */ function printConnectionErrorAndExit() { console.error('Unable to connect to Chrome'); return process.exit(_RUNTIME_ERROR_CODE); } /** @return {never} */ function printProtocolTimeoutErrorAndExit() { console.error('Debugger protocol timed out while connecting to Chrome.'); return process.exit(_PROTOCOL_TIMEOUT_EXIT_CODE); } /** * @param {ExitError} err * @return {never} */ function printRuntimeErrorAndExit(err) { console.error('Runtime error encountered:', err.friendlyMessage || err.message); if (err.stack) { console.error(err.stack); } return process.exit(_RUNTIME_ERROR_CODE); } /** * @param {ExitError} err * @return {never} */ function printErrorAndExit(err) { if (err.code === 'ECONNREFUSED') { return printConnectionErrorAndExit(); } else if (err.code === 'CRI_TIMEOUT') { return printProtocolTimeoutErrorAndExit(); } else { return printRuntimeErrorAndExit(err); } } /** * @param {LH.RunnerResult} runnerResult * @param {LH.CliFlags} flags * @return {Promise<void>} */ async function saveResults(runnerResult, flags) { const cwd = process.cwd(); if (flags.lanternDataOutputPath) { const devtoolsLog = runnerResult.artifacts.DevtoolsLog; await assetSaver.saveLanternNetworkData(devtoolsLog, flags.lanternDataOutputPath); } const shouldSaveResults = flags.auditMode || (flags.gatherMode === flags.auditMode); if (!shouldSaveResults) return; const {lhr, artifacts, report} = runnerResult; // Use the output path as the prefix for all generated files. // If no output path is set, generate a file prefix using the URL and date. const configuredPath = !flags.outputPath || flags.outputPath === 'stdout' ? getLhrFilenamePrefix(lhr) : flags.outputPath.replace(/\.\w{2,4}$/, ''); const resolvedPath = path.resolve(cwd, configuredPath); if (flags.saveAssets) { await assetSaver.saveAssets(artifacts, lhr.audits, resolvedPath); } for (const outputType of flags.output) { const extension = outputType; const output = report[flags.output.indexOf(outputType)]; let outputPath = `${resolvedPath}.report.${extension}`; // If there was only a single output and the user specified an outputPath, force usage of it. if (flags.outputPath && flags.output.length === 1) outputPath = flags.outputPath; await Printer.write(output, outputType, outputPath); if (outputType === Printer.OutputMode[Printer.OutputMode.html]) { if (flags.view) { open(outputPath, {wait: false}); } else { // eslint-disable-next-line max-len log.log('CLI', 'Protip: Run lighthouse with `--view` to immediately open the HTML report in your browser'); } } } } /** * @param {string} url * @param {LH.CliFlags} flags * @param {LH.Config|undefined} config * @return {Promise<LH.RunnerResult|undefined>} */ async function runLighthouse(url, flags, config) { /** @param {any} reason */ function handleTheUnhandled(reason) { process.stderr.write(`Unhandled Rejection. Reason: ${reason}\n`); launchedChrome?.kill(); process.exit(1); } process.on('unhandledRejection', handleTheUnhandled); /** @type {ChromeLauncher.LaunchedChrome|undefined} */ let launchedChrome; try { if (url && flags.auditMode && !flags.gatherMode) { log.warn('CLI', 'URL parameter is ignored if -A flag is used without -G flag'); } const shouldGather = flags.gatherMode || flags.gatherMode === flags.auditMode; const shouldUseLocalChrome = UrlUtils.isLikeLocalhost(flags.hostname); if (shouldGather && shouldUseLocalChrome) { launchedChrome = await getDebuggableChrome(flags); flags.port = launchedChrome.port; } flags.channel = 'cli'; const runnerResult = await lighthouse(url, flags, config); // If in gatherMode only, there will be no runnerResult. if (runnerResult) { await saveResults(runnerResult, flags); } launchedChrome?.kill(); process.removeListener('unhandledRejection', handleTheUnhandled); // Runtime errors indicate something was *very* wrong with the page result. // We don't want the user to have to parse the report to figure it out, so we'll still exit // with an error code after we saved the results. if (runnerResult?.lhr.runtimeError) { const {runtimeError} = runnerResult.lhr; return printErrorAndExit({ name: 'LighthouseError', friendlyMessage: runtimeError.message, code: runtimeError.code, message: runtimeError.message, }); } return runnerResult; } catch (err) { launchedChrome?.kill(); return printErrorAndExit(err); } } export { parseChromeFlags, saveResults, runLighthouse, };