UNPKG

lighthouse

Version:

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

620 lines (549 loc) • 21.4 kB
/** * @license * Copyright 2019 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import path from 'path'; import {createRequire} from 'module'; import url from 'url'; import {isEqual} from 'lodash-es'; import * as constants from './constants.js'; import ConfigPlugin from './config-plugin.js'; import {Runner} from '../runner.js'; import * as i18n from '../lib/i18n/i18n.js'; import * as validation from './validation.js'; import {getModuleDirectory} from '../../shared/esm-utils.js'; const require = createRequire(import.meta.url); /** @typedef {typeof import('../gather/base-gatherer.js').default} GathererConstructor */ /** @typedef {typeof import('../audits/audit.js')['Audit']} Audit */ /** @typedef {InstanceType<GathererConstructor>} Gatherer */ function isBundledEnvironment() { // If we're in DevTools or LightRider, we are definitely bundled. // TODO: refactor and delete `global.isDevtools`. if (global.isDevtools || global.isLightrider) return true; try { // Not foolproof, but `lighthouse-logger` is a dependency of lighthouse that should always be resolvable. // `require.resolve` will only throw in atypical/bundled environments. require.resolve('lighthouse-logger'); return false; } catch (err) { return true; } } /** * If any items with identical `path` properties are found in the input array, * merge their `options` properties into the first instance and then discard any * other instances. * @template {{path?: string, options: Record<string, unknown>}} T * @param {T[]} items * @return T[] */ const mergeOptionsOfItems = function(items) { /** @type {T[]} */ const mergedItems = []; for (const item of items) { const existingItem = item.path && mergedItems.find(candidate => candidate.path === item.path); if (!existingItem) { mergedItems.push(item); continue; } existingItem.options = Object.assign({}, existingItem.options, item.options); } return mergedItems; }; /** * Recursively merges config fragment objects in a somewhat Lighthouse-specific way. * * - `null` is treated similarly to `undefined` for whether a value should be overridden. * - `overwriteArrays` controls array extension behavior: * - true: Arrays are overwritten without any merging or concatenation. * - false: Arrays are concatenated and de-duped by isEqual. * - Objects are recursively merged. * - If the `settings` key is encountered while traversing an object, its arrays are *always* * overridden, not concatenated. (`overwriteArrays` is flipped to `true`) * * More widely typed than exposed merge() function, below. * @param {Object<string, any>|Array<any>|undefined|null} base * @param {Object<string, any>|Array<any>} extension * @param {boolean=} overwriteArrays */ function _mergeConfigFragment(base, extension, overwriteArrays = false) { // If the default value doesn't exist or is explicitly null, defer to the extending value if (typeof base === 'undefined' || base === null) { return extension; } else if (typeof extension === 'undefined') { return base; } else if (Array.isArray(extension)) { if (overwriteArrays) return extension; if (!Array.isArray(base)) throw new TypeError(`Expected array but got ${typeof base}`); const merged = base.slice(); extension.forEach(item => { if (!merged.some(candidate => isEqual(candidate, item))) merged.push(item); }); return merged; } else if (typeof extension === 'object') { if (typeof base !== 'object') throw new TypeError(`Expected object but got ${typeof base}`); if (Array.isArray(base)) throw new TypeError('Expected object but got Array'); Object.keys(extension).forEach(key => { const localOverwriteArrays = overwriteArrays || (key === 'settings' && typeof base[key] === 'object'); base[key] = _mergeConfigFragment(base[key], extension[key], localOverwriteArrays); }); return base; } return extension; } /** * Until support of jsdoc templates with constraints, type in config.d.ts. * See https://github.com/Microsoft/TypeScript/issues/24283 * @type {LH.Config.Merge} */ const mergeConfigFragment = _mergeConfigFragment; /** * Merge an array of items by a caller-defined key. `mergeConfigFragment` is used to merge any items * with a matching key. * * @template {Record<string, any>} T * @param {Array<T>|null|undefined} baseArray * @param {Array<T>|null|undefined} extensionArray * @param {(item: T) => string} keyFn * @return {Array<T>} */ function mergeConfigFragmentArrayByKey(baseArray, extensionArray, keyFn) { /** @type {Map<string, {index: number, item: T}>} */ const itemsByKey = new Map(); const mergedArray = baseArray || []; for (let i = 0; i < mergedArray.length; i++) { const item = mergedArray[i]; itemsByKey.set(keyFn(item), {index: i, item}); } for (const item of extensionArray || []) { const baseItemEntry = itemsByKey.get(keyFn(item)); if (baseItemEntry) { const baseItem = baseItemEntry.item; const merged = typeof item === 'object' && typeof baseItem === 'object' ? mergeConfigFragment(baseItem, item, true) : item; mergedArray[baseItemEntry.index] = merged; } else { mergedArray.push(item); } } return mergedArray; } /** * Expands a gatherer from user-specified to an internal gatherer definition format. * * Input Examples: * - 'my-gatherer' * - class MyGatherer extends Gatherer { } * - {instance: myGathererInstance} * * @param {LH.Config.GathererJson} gatherer * @return {{instance?: Gatherer, implementation?: GathererConstructor, path?: string}} */ function expandGathererShorthand(gatherer) { if (typeof gatherer === 'string') { // just 'path/to/gatherer' return {path: gatherer}; } else if ('implementation' in gatherer || 'instance' in gatherer) { // {implementation: GathererConstructor, ...} or {instance: GathererInstance, ...} return gatherer; } else if ('path' in gatherer) { // {path: 'path/to/gatherer', ...} if (typeof gatherer.path !== 'string') { throw new Error('Invalid Gatherer type ' + JSON.stringify(gatherer)); } return gatherer; } else if (typeof gatherer === 'function') { // just GathererConstructor return {implementation: gatherer}; } else if (gatherer && typeof gatherer.getArtifact === 'function') { // just GathererInstance return {instance: gatherer}; } else { throw new Error('Invalid Gatherer type ' + JSON.stringify(gatherer)); } } /** * Expands the audits from user-specified JSON to an internal audit definition format. * @param {LH.Config.AuditJson} audit * @return {{id?: string, path: string, options?: {}} | {id?: string, implementation: Audit, path?: string, options?: {}}} */ function expandAuditShorthand(audit) { if (typeof audit === 'string') { // just 'path/to/audit' return {path: audit, options: {}}; } else if ('implementation' in audit && typeof audit.implementation.audit === 'function') { // {implementation: AuditClass, ...} return audit; } else if ('path' in audit && typeof audit.path === 'string') { // {path: 'path/to/audit', ...} return audit; } else if ('audit' in audit && typeof audit.audit === 'function') { // just AuditClass return {implementation: audit, options: {}}; } else { throw new Error('Invalid Audit type ' + JSON.stringify(audit)); } } /** @type {Map<string, Promise<any>>} */ const bundledModules = new Map(/* BUILD_REPLACE_BUNDLED_MODULES */); /** * Wraps `import`/`require` with an entrypoint for bundled dynamic modules. * See build-bundle.js * @param {string} requirePath */ async function requireWrapper(requirePath) { // For windows. if (path.isAbsolute(requirePath)) { requirePath = url.pathToFileURL(requirePath).href; } /** @type {any} */ let module; if (bundledModules.has(requirePath)) { module = await bundledModules.get(requirePath); } else if (requirePath.match(/\.(js|mjs|cjs)$/)) { module = await import(requirePath); } else { requirePath += '.js'; module = await import(requirePath); } if (module.default) return module.default; // Find a valid named export. const methods = new Set(['meta']); const possibleNamedExports = Object.keys(module).filter(key => { if (!(module[key] && module[key] instanceof Object)) return false; return Object.getOwnPropertyNames(module[key]).some(method => methods.has(method)); }); if (possibleNamedExports.length === 1) return possibleNamedExports[0]; if (possibleNamedExports.length > 1) { throw new Error(`module '${requirePath}' has too many possible exports`); } throw new Error(`module '${requirePath}' missing default export`); } /** * @param {string} gathererPath * @param {Array<string>} coreGathererList * @param {string=} configDir * @return {Promise<LH.Config.AnyGathererDefn>} */ async function requireGatherer(gathererPath, coreGathererList, configDir) { const coreGatherer = coreGathererList.find(a => a === `${gathererPath}.js`); let requirePath = `../gather/gatherers/${gathererPath}`; if (!coreGatherer) { // Otherwise, attempt to find it elsewhere. This throws if not found. requirePath = resolveModulePath(gathererPath, configDir, 'gatherer'); } const GathererClass = /** @type {GathererConstructor} */ (await requireWrapper(requirePath)); return { instance: new GathererClass(), implementation: GathererClass, path: gathererPath, }; } /** * @param {string} auditPath * @param {Array<string>} coreAuditList * @param {string=} configDir * @return {Promise<LH.Config.AuditDefn['implementation']>} */ function requireAudit(auditPath, coreAuditList, configDir) { // See if the audit is a Lighthouse core audit. const auditPathJs = `${auditPath}.js`; const coreAudit = coreAuditList.find(a => a === auditPathJs); let requirePath = `../audits/${auditPath}`; if (!coreAudit) { if (isBundledEnvironment()) { // This is for plugin bundling. requirePath = auditPath; } else { // Otherwise, attempt to find it elsewhere. This throws if not found. const absolutePath = resolveModulePath(auditPath, configDir, 'audit'); if (isBundledEnvironment()) { // Use a relative path so bundler can easily expose it. requirePath = path.relative(getModuleDirectory(import.meta), absolutePath); } else { requirePath = absolutePath; } } } return requireWrapper(requirePath); } /** * Creates a settings object from potential flags object by dropping all the properties * that don't exist on Config.Settings. * @param {Partial<LH.Flags>=} flags * @return {LH.Util.RecursivePartial<LH.Config.Settings>} */ function cleanFlagsForSettings(flags = {}) { /** @type {LH.Util.RecursivePartial<LH.Config.Settings>} */ const settings = {}; for (const key of Object.keys(flags)) { if (key in constants.defaultSettings) { // @ts-expect-error tsc can't yet express that key is only a single type in each iteration, not a union of types. settings[key] = flags[key]; } } return settings; } /** * @param {LH.SharedFlagsSettings} settingsJson * @param {LH.Flags|undefined} overrides * @return {LH.Config.Settings} */ function resolveSettings(settingsJson = {}, overrides = undefined) { // If a locale is requested in flags or settings, use it. A typical CLI run will not have one, // however `lookupLocale` will always determine which of our supported locales to use (falling // back if necessary). // TODO: could do more work to sniff out the user's locale const locale = i18n.lookupLocale(overrides?.locale || settingsJson.locale); // Fill in missing settings with defaults const {defaultSettings} = constants; const settingWithDefaults = mergeConfigFragment(deepClone(defaultSettings), settingsJson, true); // Override any applicable settings with CLI flags const settingsWithFlags = mergeConfigFragment( settingWithDefaults, cleanFlagsForSettings(overrides), true ); // Locale is special and comes only from flags/settings/lookupLocale. settingsWithFlags.locale = locale; // Default constants uses the mobile UA. Explicitly stating to true asks LH to use the associated UA. // It's a little awkward, but the alternatives are not allowing `true` or a dedicated `disableUAEmulation` setting. if (settingsWithFlags.emulatedUserAgent === true) { settingsWithFlags.emulatedUserAgent = constants.userAgents[settingsWithFlags.formFactor]; } validation.assertValidSettings(settingsWithFlags); return settingsWithFlags; } /** * @param {LH.Config} config * @param {string | undefined} configDir * @param {{plugins?: string[]} | undefined} flags * @return {Promise<LH.Config>} */ async function mergePlugins(config, configDir, flags) { const configPlugins = config.plugins || []; const flagPlugins = flags?.plugins || []; const pluginNames = new Set([...configPlugins, ...flagPlugins]); for (const pluginName of pluginNames) { validation.assertValidPluginName(config, pluginName); // In bundled contexts, `resolveModulePath` will fail, so use the raw pluginName directly. const pluginPath = isBundledEnvironment() ? pluginName : resolveModulePath(pluginName, configDir, 'plugin'); const rawPluginJson = await requireWrapper(pluginPath); const pluginJson = ConfigPlugin.parsePlugin(rawPluginJson, pluginName); config = mergeConfigFragment(config, pluginJson); } return config; } /** * Turns a GathererJson into a GathererDefn which involves a few main steps: * - Expanding the JSON shorthand the full definition format. * - `require`ing in the implementation. * - Creating a gatherer instance from the implementation. * @param {LH.Config.GathererJson} gathererJson * @param {Array<string>} coreGathererList * @param {string=} configDir * @return {Promise<LH.Config.AnyGathererDefn>} */ async function resolveGathererToDefn(gathererJson, coreGathererList, configDir) { const gathererDefn = expandGathererShorthand(gathererJson); if (gathererDefn.instance) { return { instance: gathererDefn.instance, implementation: gathererDefn.implementation, path: gathererDefn.path, }; } else if (gathererDefn.implementation) { const GathererClass = gathererDefn.implementation; return { instance: new GathererClass(), implementation: gathererDefn.implementation, path: gathererDefn.path, }; } else if (gathererDefn.path) { const path = gathererDefn.path; return requireGatherer(path, coreGathererList, configDir); } else { throw new Error('Invalid expanded Gatherer: ' + JSON.stringify(gathererDefn)); } } /** * Take an array of audits and audit paths and require any paths (possibly * relative to the optional `configDir`) using `resolveModule`, * leaving only an array of AuditDefns. * @param {LH.Config['audits']} audits * @param {string=} configDir * @return {Promise<Array<LH.Config.AuditDefn>|null>} */ async function resolveAuditsToDefns(audits, configDir) { if (!audits) { return null; } const coreList = Runner.getAuditList(); const auditDefnsPromises = audits.map(async (auditJson) => { const auditDefn = expandAuditShorthand(auditJson); let implementation; if ('implementation' in auditDefn) { implementation = auditDefn.implementation; } else { implementation = await requireAudit(auditDefn.path, coreList, configDir); } return { implementation, path: auditDefn.path, options: auditDefn.options || {}, }; }); const auditDefns = await Promise.all(auditDefnsPromises); const mergedAuditDefns = mergeOptionsOfItems(auditDefns); mergedAuditDefns.forEach(audit => validation.assertValidAudit(audit)); return mergedAuditDefns; } /** * Resolves the location of the specified module and returns an absolute * string path to the file. Used for loading custom audits and gatherers. * Throws an error if no module is found. * @param {string} moduleIdentifier * @param {string=} configDir The absolute path to the directory of the config file, if there is one. * @param {string=} category Optional plugin category (e.g. 'audit') for better error messages. * @return {string} * @throws {Error} */ function resolveModulePath(moduleIdentifier, configDir, category) { // module in a node_modules/ that is... // | | Lighthouse globally installed | Lighthouse locally installed | // |--------------------------------|-------------------------------|------------------------------| // | global | 1. | 1. | // | in current working directory | 2. | 1. | // | relative to config.js file | 5. | - | // module given by a path that is... // | | Lighthouse globally/locally installed | // |-------------------------------------------|---------------------------------------| // | absolute | 1. | // | relative to the current working directory | 3. | // | relative to the config.js file | 4. | // 1. // First try straight `require()`. Unlikely to be specified relative to this // file, but adds support for Lighthouse modules from npm since // `require()` walks up parent directories looking inside any node_modules/ // present. Also handles absolute paths. try { return require.resolve(moduleIdentifier); } catch (e) {} // 2. // Lighthouse globally installed, node_modules/ in current working directory. // ex: lighthouse https://test.com // // working directory/ // |-- node_modules/ // |-- package.json try { return require.resolve(moduleIdentifier, {paths: [process.cwd()]}); } catch (e) {} // 3. // See if the module resolves relative to the current working directory. // Most useful to handle the case of invoking Lighthouse as a module, since // then the config is an object and so has no path. const cwdPath = path.resolve(process.cwd(), moduleIdentifier); try { return require.resolve(cwdPath); } catch (e) {} const errorString = 'Unable to locate ' + (category ? `${category}: ` : '') + `\`${moduleIdentifier}\`. Tried to resolve the module from these locations: ${getModuleDirectory(import.meta)} ${cwdPath}`; if (!configDir) { throw new Error(errorString); } // 4. // Try looking up relative to the config file path. Just like the // relative path passed to `require()` is found relative to the file it's // in, this allows module paths to be specified relative to the config file. const relativePath = path.resolve(configDir, moduleIdentifier); try { return require.resolve(relativePath); } catch (requireError) {} // 5. // Lighthouse globally installed, node_modules/ in config directory. // ex: lighthouse https://test.com --config-path=./config/config.js // // working directory/ // |-- config/ // |-- node_modules/ // |-- config.js // |-- package.json try { return require.resolve(moduleIdentifier, {paths: [configDir]}); } catch (requireError) {} throw new Error(errorString + ` ${relativePath}`); } /** * Many objects in the config can be an object whose properties are not serializable. * We use a shallow clone for these objects instead. * Any value that isn't an object will not be cloned. * * @template T * @param {T} item * @return {T} */ function shallowClone(item) { if (typeof item === 'object') { // Return copy of instance and prototype chain (in case item is instantiated class). return Object.assign( Object.create( Object.getPrototypeOf(item) ), item ); } return item; } /** * // TODO(bckenny): could adopt "jsonified" type to ensure T will survive JSON * round trip: https://github.com/Microsoft/TypeScript/issues/21838 * @template T * @param {T} json * @return {T} */ function deepClone(json) { return JSON.parse(JSON.stringify(json)); } /** * Deep clone a config, copying over any "live" gatherer or audit that * wouldn't make the JSON round trip. * @param {LH.Config} json * @return {LH.Config} */ function deepCloneConfigJson(json) { const cloned = deepClone(json); // Copy arrays that could contain non-serializable properties to allow for programmatic // injection of audit and gatherer implementations. if (Array.isArray(json.audits)) { cloned.audits = json.audits.map(audit => shallowClone(audit)); } if (Array.isArray(json.artifacts)) { cloned.artifacts = json.artifacts.map(artifact => ({ ...artifact, gatherer: shallowClone(artifact.gatherer), })); } return cloned; } export { deepClone, deepCloneConfigJson, mergeConfigFragment, mergeConfigFragmentArrayByKey, mergeOptionsOfItems, mergePlugins, resolveAuditsToDefns, resolveGathererToDefn, resolveModulePath, resolveSettings, };