From 78012fb2690deaf905a2f1660bc8515067764912 Mon Sep 17 00:00:00 2001 From: Patrick Hulce Date: Wed, 17 Apr 2019 11:22:20 -0500 Subject: [PATCH 1/4] scratch --- lighthouse-core/audits/deprecations.js | 4 +- .../dobetterweb/geolocation-on-start.js | 2 +- .../audits/dobetterweb/no-document-write.js | 2 +- .../dobetterweb/notification-on-start.js | 2 +- .../uses-passive-event-listeners.js | 2 +- lighthouse-core/audits/errors-in-console.js | 4 +- lighthouse-core/audits/violation-audit.js | 2 +- lighthouse-core/config/config-helpers.js | 963 ++++++++++++++++++ lighthouse-core/config/config.js | 53 +- .../gatherers/chrome-console-messages.js | 6 +- .../test/audits/deprecations-test.js | 6 +- .../dobetterweb/geolocation-on-start-test.js | 4 +- .../dobetterweb/no-document-write-test.js | 4 +- .../dobetterweb/notification-on-start-test.js | 4 +- .../uses-passive-event-listeners-test.js | 4 +- .../test/audits/errors-in-console-test.js | 10 +- types/artifacts.d.ts | 42 +- 17 files changed, 1019 insertions(+), 95 deletions(-) create mode 100644 lighthouse-core/config/config-helpers.js diff --git a/lighthouse-core/audits/deprecations.js b/lighthouse-core/audits/deprecations.js index acea4fd00396..d7890c4b4e7f 100644 --- a/lighthouse-core/audits/deprecations.js +++ b/lighthouse-core/audits/deprecations.js @@ -25,7 +25,7 @@ class Deprecations extends Audit { failureTitle: 'Uses deprecated APIs', description: 'Deprecated APIs will eventually be removed from the browser. ' + '[Learn more](https://www.chromestatus.com/features#deprecated).', - requiredArtifacts: ['ChromeConsoleMessages'], + requiredArtifacts: ['ConsoleMessages'], }; } @@ -34,7 +34,7 @@ class Deprecations extends Audit { * @return {LH.Audit.Product} */ static audit(artifacts) { - const entries = artifacts.ChromeConsoleMessages; + const entries = artifacts.ConsoleMessages; const deprecations = entries.filter(log => log.entry.source === 'deprecation').map(log => { return { diff --git a/lighthouse-core/audits/dobetterweb/geolocation-on-start.js b/lighthouse-core/audits/dobetterweb/geolocation-on-start.js index a5cdfa328082..bd2cfc312fc3 100644 --- a/lighthouse-core/audits/dobetterweb/geolocation-on-start.js +++ b/lighthouse-core/audits/dobetterweb/geolocation-on-start.js @@ -25,7 +25,7 @@ class GeolocationOnStart extends ViolationAudit { description: 'Users are mistrustful of or confused by sites that request their ' + 'location without context. Consider tying the request to user gestures instead. ' + '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/geolocation-on-load).', - requiredArtifacts: ['ChromeConsoleMessages'], + requiredArtifacts: ['ConsoleMessages'], }; } diff --git a/lighthouse-core/audits/dobetterweb/no-document-write.js b/lighthouse-core/audits/dobetterweb/no-document-write.js index e5c42eeedf28..61edf1a72fbf 100644 --- a/lighthouse-core/audits/dobetterweb/no-document-write.js +++ b/lighthouse-core/audits/dobetterweb/no-document-write.js @@ -24,7 +24,7 @@ class NoDocWriteAudit extends ViolationAudit { description: 'For users on slow connections, external scripts dynamically injected via ' + '`document.write()` can delay page load by tens of seconds. ' + '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/document-write).', - requiredArtifacts: ['ChromeConsoleMessages'], + requiredArtifacts: ['ConsoleMessages'], }; } diff --git a/lighthouse-core/audits/dobetterweb/notification-on-start.js b/lighthouse-core/audits/dobetterweb/notification-on-start.js index 92e243470c35..d928c7007b2c 100644 --- a/lighthouse-core/audits/dobetterweb/notification-on-start.js +++ b/lighthouse-core/audits/dobetterweb/notification-on-start.js @@ -25,7 +25,7 @@ class NotificationOnStart extends ViolationAudit { description: 'Users are mistrustful of or confused by sites that request to send ' + 'notifications without context. Consider tying the request to user gestures ' + 'instead. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/notifications-on-load).', - requiredArtifacts: ['ChromeConsoleMessages'], + requiredArtifacts: ['ConsoleMessages'], }; } diff --git a/lighthouse-core/audits/dobetterweb/uses-passive-event-listeners.js b/lighthouse-core/audits/dobetterweb/uses-passive-event-listeners.js index 2bf4e5be0209..036dd798a01c 100644 --- a/lighthouse-core/audits/dobetterweb/uses-passive-event-listeners.js +++ b/lighthouse-core/audits/dobetterweb/uses-passive-event-listeners.js @@ -25,7 +25,7 @@ class PassiveEventsAudit extends ViolationAudit { description: 'Consider marking your touch and wheel event listeners as `passive` ' + 'to improve your page\'s scroll performance. ' + '[Learn more](https://developers.google.com/web/tools/lighthouse/audits/passive-event-listeners).', - requiredArtifacts: ['ChromeConsoleMessages'], + requiredArtifacts: ['ConsoleMessages'], }; } diff --git a/lighthouse-core/audits/errors-in-console.js b/lighthouse-core/audits/errors-in-console.js index 74195a8e6335..1df3399a4f31 100644 --- a/lighthouse-core/audits/errors-in-console.js +++ b/lighthouse-core/audits/errors-in-console.js @@ -23,7 +23,7 @@ class ErrorLogs extends Audit { description: 'Errors logged to the console indicate unresolved problems. ' + 'They can come from network request failures and other browser concerns.', failureTitle: 'Browser errors were logged to the console', - requiredArtifacts: ['ChromeConsoleMessages', 'RuntimeExceptions'], + requiredArtifacts: ['ConsoleMessages', 'RuntimeExceptions'], }; } @@ -32,7 +32,7 @@ class ErrorLogs extends Audit { * @return {LH.Audit.Product} */ static audit(artifacts) { - const consoleEntries = artifacts.ChromeConsoleMessages; + const consoleEntries = artifacts.ConsoleMessages; const runtimeExceptions = artifacts.RuntimeExceptions; /** @type {Array<{source: string, description: string|undefined, url: string|undefined}>} */ const consoleRows = diff --git a/lighthouse-core/audits/violation-audit.js b/lighthouse-core/audits/violation-audit.js index b259537eeddf..92f434930343 100644 --- a/lighthouse-core/audits/violation-audit.js +++ b/lighthouse-core/audits/violation-audit.js @@ -15,7 +15,7 @@ class ViolationAudit extends Audit { */ static getViolationResults(artifacts, pattern) { const seen = new Set(); - return artifacts.ChromeConsoleMessages + return artifacts.ConsoleMessages .map(message => message.entry) .filter(entry => entry.url && entry.source === 'violation' && pattern.test(entry.text)) .map(entry => ({label: `line: ${entry.lineNumber}`, url: entry.url})) diff --git a/lighthouse-core/config/config-helpers.js b/lighthouse-core/config/config-helpers.js new file mode 100644 index 000000000000..b7fb5f7b9101 --- /dev/null +++ b/lighthouse-core/config/config-helpers.js @@ -0,0 +1,963 @@ +/** + * @license Copyright 2016 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ +'use strict'; + +const defaultConfigPath = './default-config.js'; +const defaultConfig = require('./default-config.js'); +const fullConfig = require('./full-config.js'); +const constants = require('./constants.js'); +const i18n = require('./../lib/i18n/i18n.js'); + +const isDeepEqual = require('lodash.isequal'); +const log = require('lighthouse-logger'); +const path = require('path'); +const Audit = require('../audits/audit.js'); +const Runner = require('../runner.js'); +const ConfigPlugin = require('./config-plugin.js'); + +/** @typedef {typeof import('../gather/gatherers/gatherer.js')} GathererConstructor */ +/** @typedef {InstanceType} Gatherer */ + +/** + * @param {Config['passes']} passes + * @param {Config['audits']} audits + */ +function validatePasses(passes, audits) { + if (!Array.isArray(passes)) { + return; + } + + const requiredGatherers = Config.getGatherersNeededByAudits(audits); + + // Log if we are running gathers that are not needed by the audits listed in the config + passes.forEach(pass => { + pass.gatherers.forEach(gathererDefn => { + const gatherer = gathererDefn.instance; + const isGatherRequiredByAudits = requiredGatherers.has(gatherer.name); + if (!isGatherRequiredByAudits) { + const msg = `${gatherer.name} gatherer requested, however no audit requires it.`; + log.warn('config', msg); + } + }); + }); + + // Passes must have unique `passName`s. Throw otherwise. + const usedNames = new Set(); + passes.forEach(pass => { + const passName = pass.passName; + if (usedNames.has(passName)) { + throw new Error(`Passes must have unique names (repeated passName: ${passName}.`); + } + usedNames.add(passName); + }); +} + +/** + * @param {Config['categories']} categories + * @param {Config['audits']} audits + * @param {Config['groups']} groups + */ +function validateCategories(categories, audits, groups) { + if (!categories) { + return; + } + + const auditsKeyedById = new Map((audits || []).map(audit => + /** @type {[string, LH.Config.AuditDefn]} */ + ([audit.implementation.meta.id, audit]) + )); + + Object.keys(categories).forEach(categoryId => { + categories[categoryId].auditRefs.forEach((auditRef, index) => { + if (!auditRef.id) { + throw new Error(`missing an audit id at ${categoryId}[${index}]`); + } + + const audit = auditsKeyedById.get(auditRef.id); + if (!audit) { + throw new Error(`could not find ${auditRef.id} audit for category ${categoryId}`); + } + + const auditImpl = audit.implementation; + const isManual = auditImpl.meta.scoreDisplayMode === 'manual'; + if (categoryId === 'accessibility' && !auditRef.group && !isManual) { + throw new Error(`${auditRef.id} accessibility audit does not have a group`); + } + + if (auditRef.weight > 0 && isManual) { + throw new Error(`${auditRef.id} is manual but has a positive weight`); + } + + if (auditRef.group && (!groups || !groups[auditRef.group])) { + throw new Error(`${auditRef.id} references unknown group ${auditRef.group}`); + } + }); + }); +} + +/** + * @param {typeof Audit} auditDefinition + * @param {string=} auditPath + */ +function assertValidAudit(auditDefinition, auditPath) { + const auditName = auditPath || + (auditDefinition && auditDefinition.meta && auditDefinition.meta.id); + + if (typeof auditDefinition.audit !== 'function' || auditDefinition.audit === Audit.audit) { + throw new Error(`${auditName} has no audit() method.`); + } + + if (typeof auditDefinition.meta.id !== 'string') { + throw new Error(`${auditName} has no meta.id property, or the property is not a string.`); + } + + if (typeof auditDefinition.meta.title !== 'string') { + throw new Error( + `${auditName} has no meta.title property, or the property is not a string.` + ); + } + + // If it'll have a ✔ or ✖ displayed alongside the result, it should have failureTitle + if (typeof auditDefinition.meta.failureTitle !== 'string' && + auditDefinition.meta.scoreDisplayMode === Audit.SCORING_MODES.BINARY) { + throw new Error(`${auditName} has no failureTitle and should.`); + } + + if (typeof auditDefinition.meta.description !== 'string') { + throw new Error( + `${auditName} has no meta.description property, or the property is not a string.` + ); + } else if (auditDefinition.meta.description === '') { + throw new Error( + `${auditName} has an empty meta.description string. Please add a description for the UI.` + ); + } + + if (!Array.isArray(auditDefinition.meta.requiredArtifacts)) { + throw new Error( + `${auditName} has no meta.requiredArtifacts property, or the property is not an array.` + ); + } +} + +/** + * @param {Gatherer} gathererInstance + * @param {string=} gathererName + */ +function assertValidGatherer(gathererInstance, gathererName) { + gathererName = gathererName || gathererInstance.name || 'gatherer'; + + if (typeof gathererInstance.beforePass !== 'function') { + throw new Error(`${gathererName} has no beforePass() method.`); + } + + if (typeof gathererInstance.pass !== 'function') { + throw new Error(`${gathererName} has no pass() method.`); + } + + if (typeof gathererInstance.afterPass !== 'function') { + throw new Error(`${gathererName} has no afterPass() method.`); + } +} + +/** + * Throws if pluginName is invalid or (somehow) collides with a category in the + * configJSON being added to. + * @param {LH.Config.Json} configJSON + * @param {string} pluginName + */ +function assertValidPluginName(configJSON, pluginName) { + if (!pluginName.startsWith('lighthouse-plugin-')) { + throw new Error(`plugin name '${pluginName}' does not start with 'lighthouse-plugin-'`); + } + + if (configJSON.categories && configJSON.categories[pluginName]) { + throw new Error(`plugin name '${pluginName}' not allowed because it is the id of a category already found in config`); // eslint-disable-line max-len + } +} + +/** + * Creates a settings object from potential flags object by dropping all the properties + * that don't exist on Config.Settings. + * @param {Partial=} flags + * @return {RecursivePartial} +*/ +function cleanFlagsForSettings(flags = {}) { + /** @type {RecursivePartial} */ + const settings = {}; + + for (const key of Object.keys(flags)) { + // @ts-ignore - intentionally testing some keys not on defaultSettings to discard them. + if (typeof constants.defaultSettings[key] !== 'undefined') { + // Cast since key now must be able to index both Flags and Settings. + const safekey = /** @type {Extract} */ (key); + settings[safekey] = flags[safekey]; + } + } + + return settings; +} + +/** + * More widely typed than exposed merge() function, below. + * @param {Object|Array|undefined|null} base + * @param {Object|Array} extension + * @param {boolean=} overwriteArrays + */ +function _merge(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 => isDeepEqual(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] = _merge(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 merge = _merge; + +/** + * @template T + * @param {Array} array + * @return {Array} + */ +function cloneArrayWithPluginSafety(array) { + return array.map(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 ConfigJson, copying over any "live" gatherer or audit that + * wouldn't make the JSON round trip. + * @param {LH.Config.Json} json + * @return {LH.Config.Json} + */ +function deepCloneConfigJson(json) { + const cloned = deepClone(json); + + // Copy arrays that could contain plugins to allow for programmatic + // injection of plugins. + if (Array.isArray(cloned.passes) && Array.isArray(json.passes)) { + for (let i = 0; i < cloned.passes.length; i++) { + const pass = cloned.passes[i]; + pass.gatherers = cloneArrayWithPluginSafety(json.passes[i].gatherers || []); + } + } + + if (Array.isArray(json.audits)) { + cloned.audits = cloneArrayWithPluginSafety(json.audits); + } + + return cloned; +} + +/** + * 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. + * Until support of jsdoc templates with constraints, type in config.d.ts. + * See https://github.com/Microsoft/TypeScript/issues/24283 + * @type {LH.Config.MergeOptionsOfItems} + */ +const mergeOptionsOfItems = (function(items) { + /** @type {Array<{path?: string, options?: Object}>} */ + 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; +}); + +class Config { + /** + * @constructor + * @implements {LH.Config.Json} + * @param {LH.Config.Json=} configJSON + * @param {LH.Flags=} flags + */ + constructor(configJSON, flags) { + const status = {msg: 'Create config', id: 'lh:init:config'}; + log.time(status, 'verbose'); + let configPath = flags && flags.configPath; + + if (!configJSON) { + configJSON = defaultConfig; + configPath = path.resolve(__dirname, defaultConfigPath); + } + + if (configPath && !path.isAbsolute(configPath)) { + throw new Error('configPath must be an absolute path.'); + } + + // We don't want to mutate the original config object + configJSON = deepCloneConfigJson(configJSON); + + // Extend the default or full config if specified + if (configJSON.extends === 'lighthouse:full') { + const explodedFullConfig = Config.extendConfigJSON(deepCloneConfigJson(defaultConfig), + deepCloneConfigJson(fullConfig)); + configJSON = Config.extendConfigJSON(explodedFullConfig, configJSON); + } else if (configJSON.extends) { + configJSON = Config.extendConfigJSON(deepCloneConfigJson(defaultConfig), configJSON); + } + + // The directory of the config path, if one was provided. + const configDir = configPath ? path.dirname(configPath) : undefined; + + // Validate and merge in plugins (if any). + configJSON = Config.mergePlugins(configJSON, flags, configDir); + + const settings = Config.initSettings(configJSON.settings, flags); + + // Augment passes with necessary defaults and require gatherers. + const passesWithDefaults = Config.augmentPassesWithDefaults(configJSON.passes); + Config.adjustDefaultPassForThrottling(settings, passesWithDefaults); + const passes = Config.requireGatherers(passesWithDefaults, configDir); + + /** @type {LH.Config.Settings} */ + this.settings = settings; + /** @type {?Array} */ + this.passes = passes; + /** @type {?Array} */ + this.audits = Config.requireAudits(configJSON.audits, configDir); + /** @type {?Record} */ + this.categories = configJSON.categories || null; + /** @type {?Record} */ + this.groups = configJSON.groups || null; + + Config.filterConfigIfNeeded(this); + + validatePasses(this.passes, this.audits); + validateCategories(this.categories, this.audits, this.groups); + + // TODO(bckenny): until tsc adds @implements support, assert that Config is a ConfigJson. + /** @type {LH.Config.Json} */ + const configJson = this; // eslint-disable-line no-unused-vars + log.timeEnd(status); + } + + /** + * Provides a cleaned-up, stringified version of this config. Gatherer and + * Audit `implementation` and `instance` do not survive this process. + * @return {string} + */ + getPrintString() { + const jsonConfig = deepClone(this); + + if (jsonConfig.passes) { + for (const pass of jsonConfig.passes) { + for (const gathererDefn of pass.gatherers) { + gathererDefn.implementation = undefined; + // @ts-ignore Breaking the Config.GathererDefn type. + gathererDefn.instance = undefined; + if (Object.keys(gathererDefn.options).length === 0) { + // @ts-ignore Breaking the Config.GathererDefn type. + gathererDefn.options = undefined; + } + } + } + } + + if (jsonConfig.audits) { + for (const auditDefn of jsonConfig.audits) { + // @ts-ignore Breaking the Config.AuditDefn type. + auditDefn.implementation = undefined; + if (Object.keys(auditDefn.options).length === 0) { + // @ts-ignore Breaking the Config.AuditDefn type. + auditDefn.options = undefined; + } + } + } + + // Printed config is more useful with localized strings. + i18n.replaceIcuMessageInstanceIds(jsonConfig, jsonConfig.settings.locale); + + return JSON.stringify(jsonConfig, null, 2); + } + + /** + * @param {LH.Config.Json} baseJSON The JSON of the configuration to extend + * @param {LH.Config.Json} extendJSON The JSON of the extensions + * @return {LH.Config.Json} + */ + static extendConfigJSON(baseJSON, extendJSON) { + if (extendJSON.passes && baseJSON.passes) { + for (const pass of extendJSON.passes) { + // use the default pass name if one is not specified + const passName = pass.passName || constants.defaultPassConfig.passName; + const basePass = baseJSON.passes.find(candidate => candidate.passName === passName); + + if (!basePass) { + baseJSON.passes.push(pass); + } else { + merge(basePass, pass); + } + } + + delete extendJSON.passes; + } + + return merge(baseJSON, extendJSON); + } + + /** + * @param {LH.Config.Json} configJSON + * @param {LH.Flags=} flags + * @param {string=} configDir + * @return {LH.Config.Json} + */ + static mergePlugins(configJSON, flags, configDir) { + const configPlugins = configJSON.plugins || []; + const flagPlugins = (flags && flags.plugins) || []; + const pluginNames = new Set([...configPlugins, ...flagPlugins]); + + for (const pluginName of pluginNames) { + assertValidPluginName(configJSON, pluginName); + + const pluginPath = Config.resolveModule(pluginName, configDir, 'plugin'); + const rawPluginJson = require(pluginPath); + const pluginJson = ConfigPlugin.parsePlugin(rawPluginJson, pluginName); + + configJSON = Config.extendConfigJSON(configJSON, pluginJson); + } + + return configJSON; + } + + /** + * @param {LH.Config.Json['passes']} passes + * @return {?Array>} + */ + static augmentPassesWithDefaults(passes) { + if (!passes) { + return null; + } + + const {defaultPassConfig} = constants; + return passes.map(pass => merge(deepClone(defaultPassConfig), pass)); + } + + /** + * @param {LH.Config.SettingsJson=} settingsJson + * @param {LH.Flags=} flags + * @return {LH.Config.Settings} + */ + static initSettings(settingsJson = {}, flags) { + // 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). + const locale = i18n.lookupLocale((flags && flags.locale) || settingsJson.locale); + + // Fill in missing settings with defaults + const {defaultSettings} = constants; + const settingWithDefaults = merge(deepClone(defaultSettings), settingsJson, true); + + // Override any applicable settings with CLI flags + const settingsWithFlags = merge(settingWithDefaults || {}, cleanFlagsForSettings(flags), true); + + // Locale is special and comes only from flags/settings/lookupLocale. + settingsWithFlags.locale = locale; + + return settingsWithFlags; + } + + /** + * Expands the audits from user-specified JSON to an internal audit definition format. + * @param {LH.Config.Json['audits']} audits + * @return {?Array<{path: string, options?: {}} | {implementation: typeof Audit, path?: string, options?: {}}>} + */ + static expandAuditShorthand(audits) { + if (!audits) { + return null; + } + + const newAudits = audits.map(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)); + } + }); + + return newAudits; + } + + /** + * Expands the gatherers from user-specified to an internal gatherer definition format. + * + * Input Examples: + * - 'my-gatherer' + * - class MyGatherer extends Gatherer { } + * - {instance: myGathererInstance} + * + * @param {Array} gatherers + * @return {Array<{instance?: Gatherer, implementation?: GathererConstructor, path?: string, options?: {}}>} passes + */ + static expandGathererShorthand(gatherers) { + const expanded = gatherers.map(gatherer => { + if (typeof gatherer === 'string') { + // just 'path/to/gatherer' + return {path: gatherer, options: {}}; + } 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, options: {}}; + } else if (gatherer && typeof gatherer.beforePass === 'function') { + // just GathererInstance + return {instance: gatherer, options: {}}; + } else { + throw new Error('Invalid Gatherer type ' + JSON.stringify(gatherer)); + } + }); + + return expanded; + } + + /** + * Observed throttling methods (devtools/provided) require at least 5s of quiet for the metrics to + * be computed. This method adjusts the quiet thresholds to the required minimums if necessary. + * @param {LH.Config.Settings} settings + * @param {?Array>} passes + */ + static adjustDefaultPassForThrottling(settings, passes) { + if (!passes || + (settings.throttlingMethod !== 'devtools' && settings.throttlingMethod !== 'provided')) { + return; + } + + const defaultPass = passes.find(pass => pass.passName === 'defaultPass'); + if (!defaultPass) return; + const overrides = constants.nonSimulatedPassConfigOverrides; + defaultPass.pauseAfterLoadMs = + Math.max(overrides.pauseAfterLoadMs, defaultPass.pauseAfterLoadMs); + defaultPass.cpuQuietThresholdMs = + Math.max(overrides.cpuQuietThresholdMs, defaultPass.cpuQuietThresholdMs); + defaultPass.networkQuietThresholdMs = + Math.max(overrides.networkQuietThresholdMs, defaultPass.networkQuietThresholdMs); + } + + /** + * Filter out any unrequested items from the config, based on requested categories or audits. + * @param {Config} config + */ + static filterConfigIfNeeded(config) { + const settings = config.settings; + if (!settings.onlyCategories && !settings.onlyAudits && !settings.skipAudits) { + return; + } + + // 1. Filter to just the chosen categories/audits + const {categories, requestedAuditNames} = Config.filterCategoriesAndAudits(config.categories, + settings); + + // 2. Resolve which audits will need to run + const audits = config.audits && config.audits.filter(auditDefn => + requestedAuditNames.has(auditDefn.implementation.meta.id)); + + // 3. Resolve which gatherers will need to run + const requiredGathererIds = Config.getGatherersNeededByAudits(audits); + + // 4. Filter to only the neccessary passes + const passes = Config.generatePassesNeededByGatherers(config.passes, requiredGathererIds); + + config.categories = categories; + config.audits = audits; + config.passes = passes; + } + + /** + * Filter out any unrequested categories or audits from the categories object. + * @param {Config['categories']} oldCategories + * @param {LH.Config.Settings} settings + * @return {{categories: Config['categories'], requestedAuditNames: Set}} + */ + static filterCategoriesAndAudits(oldCategories, settings) { + if (!oldCategories) { + return {categories: null, requestedAuditNames: new Set()}; + } + + if (settings.onlyAudits && settings.skipAudits) { + throw new Error('Cannot set both skipAudits and onlyAudits'); + } + + /** @type {NonNullable} */ + const categories = {}; + const filterByIncludedCategory = !!settings.onlyCategories; + const filterByIncludedAudit = !!settings.onlyAudits; + const categoryIds = settings.onlyCategories || []; + const auditIds = settings.onlyAudits || []; + const skipAuditIds = settings.skipAudits || []; + + // warn if the category is not found + categoryIds.forEach(categoryId => { + if (!oldCategories[categoryId]) { + log.warn('config', `unrecognized category in 'onlyCategories': ${categoryId}`); + } + }); + + // warn if the audit is not found in a category or there are overlaps + const auditsToValidate = new Set(auditIds.concat(skipAuditIds)); + for (const auditId of auditsToValidate) { + const foundCategory = Object.keys(oldCategories).find(categoryId => { + const auditRefs = oldCategories[categoryId].auditRefs; + return !!auditRefs.find(candidate => candidate.id === auditId); + }); + + if (!foundCategory) { + const parentKeyName = skipAuditIds.includes(auditId) ? 'skipAudits' : 'onlyAudits'; + log.warn('config', `unrecognized audit in '${parentKeyName}': ${auditId}`); + } else if (auditIds.includes(auditId) && categoryIds.includes(foundCategory)) { + log.warn('config', `${auditId} in 'onlyAudits' is already included by ` + + `${foundCategory} in 'onlyCategories'`); + } + } + + const includedAudits = new Set(auditIds); + skipAuditIds.forEach(id => includedAudits.delete(id)); + + Object.keys(oldCategories).forEach(categoryId => { + const category = deepClone(oldCategories[categoryId]); + + if (filterByIncludedCategory && filterByIncludedAudit) { + // If we're filtering to the category and audit whitelist, include the union of the two + if (!categoryIds.includes(categoryId)) { + category.auditRefs = category.auditRefs.filter(audit => auditIds.includes(audit.id)); + } + } else if (filterByIncludedCategory) { + // If we're filtering to just the category whitelist and the category is not included, skip it + if (!categoryIds.includes(categoryId)) { + return; + } + } else if (filterByIncludedAudit) { + category.auditRefs = category.auditRefs.filter(audit => auditIds.includes(audit.id)); + } + + // always filter to the audit blacklist + category.auditRefs = category.auditRefs.filter(audit => !skipAuditIds.includes(audit.id)); + + if (category.auditRefs.length) { + categories[categoryId] = category; + category.auditRefs.forEach(audit => includedAudits.add(audit.id)); + } + }); + + return {categories, requestedAuditNames: includedAudits}; + } + + /** + * @param {LH.Config.Json} config + * @return {Array<{id: string, title: string}>} + */ + static getCategories(config) { + const categories = config.categories; + if (!categories) { + return []; + } + + return Object.keys(categories).map(id => { + const title = categories[id].title; + return {id, title}; + }); + } + + /** + * From some requested audits, return names of all required artifacts + * @param {Config['audits']} audits + * @return {Set} + */ + static getGatherersNeededByAudits(audits) { + // It's possible we weren't given any audits (but existing audit results), in which case + // there is no need to do any work here. + if (!audits) { + return new Set(); + } + + return audits.reduce((list, auditDefn) => { + auditDefn.implementation.meta.requiredArtifacts.forEach(artifact => list.add(artifact)); + return list; + }, new Set()); + } + + /** + * Filters to only required passes and gatherers, returning a new passes array. + * @param {Config['passes']} passes + * @param {Set} requiredGatherers + * @return {Config['passes']} + */ + static generatePassesNeededByGatherers(passes, requiredGatherers) { + if (!passes) { + return null; + } + + const auditsNeedTrace = requiredGatherers.has('traces'); + const filteredPasses = passes.map(pass => { + // remove any unncessary gatherers from within the passes + pass.gatherers = pass.gatherers.filter(gathererDefn => { + const gatherer = gathererDefn.instance; + return requiredGatherers.has(gatherer.name); + }); + + // disable the trace if no audit requires a trace + if (pass.recordTrace && !auditsNeedTrace) { + const passName = pass.passName || 'unknown pass'; + log.warn('config', `Trace not requested by an audit, dropping trace in ${passName}`); + pass.recordTrace = false; + } + + return pass; + }).filter(pass => { + // remove any passes lacking concrete gatherers, unless they are dependent on the trace + if (pass.recordTrace) return true; + // Always keep defaultPass + if (pass.passName === 'defaultPass') return true; + return pass.gatherers.length > 0; + }); + return filteredPasses; + } + + /** + * Take an array of audits and audit paths and require any paths (possibly + * relative to the optional `configDir`) using `Config.resolveModule`, + * leaving only an array of AuditDefns. + * @param {LH.Config.Json['audits']} audits + * @param {string=} configDir + * @return {Config['audits']} + */ + static requireAudits(audits, configDir) { + const status = {msg: 'Requiring audits', id: 'lh:config:requireAudits'}; + log.time(status, 'verbose'); + const expandedAudits = Config.expandAuditShorthand(audits); + if (!expandedAudits) { + return null; + } + + const coreList = Runner.getAuditList(); + const auditDefns = expandedAudits.map(audit => { + let implementation; + if ('implementation' in audit) { + implementation = audit.implementation; + } else { + // See if the audit is a Lighthouse core audit. + const auditPathJs = `${audit.path}.js`; + const coreAudit = coreList.find(a => a === auditPathJs); + let requirePath = `../audits/${audit.path}`; + if (!coreAudit) { + // Otherwise, attempt to find it elsewhere. This throws if not found. + requirePath = Config.resolveModule(audit.path, configDir, 'audit'); + } + implementation = /** @type {typeof Audit} */ (require(requirePath)); + } + + return { + implementation, + path: audit.path, + options: audit.options || {}, + }; + }); + + const mergedAuditDefns = mergeOptionsOfItems(auditDefns); + mergedAuditDefns.forEach(audit => assertValidAudit(audit.implementation, audit.path)); + log.timeEnd(status); + return mergedAuditDefns; + } + + /** + * @param {string} path + * @param {{}=} options + * @param {Array} coreAuditList + * @param {string=} configDir + * @return {LH.Config.GathererDefn} + */ + static requireGathererFromPath(path, options, coreAuditList, configDir) { + const coreGatherer = coreAuditList.find(a => a === `${path}.js`); + + let requirePath = `../gather/gatherers/${path}`; + if (!coreGatherer) { + // Otherwise, attempt to find it elsewhere. This throws if not found. + requirePath = Config.resolveModule(path, configDir, 'gatherer'); + } + + const GathererClass = /** @type {GathererConstructor} */ (require(requirePath)); + + return { + instance: new GathererClass(), + implementation: GathererClass, + path, + options: options || {}, + }; + } + + /** + * Takes an array of passes with every property now initialized except the + * gatherers and requires them, (relative to the optional `configDir` if + * provided) using `Config.resolveModule`, returning an array of full Passes. + * @param {?Array>} passes + * @param {string=} configDir + * @return {Config['passes']} + */ + static requireGatherers(passes, configDir) { + if (!passes) { + return null; + } + const status = {msg: 'Requiring gatherers', id: 'lh:config:requireGatherers'}; + log.time(status, 'verbose'); + + const coreList = Runner.getGathererList(); + const fullPasses = passes.map(pass => { + const gathererDefns = Config.expandGathererShorthand(pass.gatherers).map(gathererDefn => { + if (gathererDefn.instance) { + return { + instance: gathererDefn.instance, + implementation: gathererDefn.implementation, + path: gathererDefn.path, + options: gathererDefn.options || {}, + }; + } else if (gathererDefn.implementation) { + const GathererClass = gathererDefn.implementation; + return { + instance: new GathererClass(), + implementation: gathererDefn.implementation, + path: gathererDefn.path, + options: gathererDefn.options || {}, + }; + } else if (gathererDefn.path) { + const path = gathererDefn.path; + const options = gathererDefn.options; + return Config.requireGathererFromPath(path, options, coreList, configDir); + } else { + throw new Error('Invalid expanded Gatherer: ' + JSON.stringify(gathererDefn)); + } + }); + + const mergedDefns = mergeOptionsOfItems(gathererDefns); + mergedDefns.forEach(gatherer => assertValidGatherer(gatherer.instance, gatherer.path)); + + return Object.assign(pass, {gatherers: mergedDefns}); + }); + log.timeEnd(status); + return fullPasses; + } + + /** + * 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} + */ + static resolveModule(moduleIdentifier, configDir, category) { + // 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) {} + + // 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 require() from '${__dirname}' and load from '${cwdPath}'`; + + if (!configDir) { + throw new Error(errorString + ')'); + } + + // Finally, 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) {} + + throw new Error(errorString + ` and '${relativePath}')`); + } +} + +module.exports = Config; diff --git a/lighthouse-core/config/config.js b/lighthouse-core/config/config.js index b7fb5f7b9101..6c15c0d21121 100644 --- a/lighthouse-core/config/config.js +++ b/lighthouse-core/config/config.js @@ -25,7 +25,7 @@ const ConfigPlugin = require('./config-plugin.js'); * @param {Config['passes']} passes * @param {Config['audits']} audits */ -function validatePasses(passes, audits) { +function assertValidPasses(passes, audits) { if (!Array.isArray(passes)) { return; } @@ -60,7 +60,7 @@ function validatePasses(passes, audits) { * @param {Config['audits']} audits * @param {Config['groups']} groups */ -function validateCategories(categories, audits, groups) { +function assertValidCategories(categories, audits, groups) { if (!categories) { return; } @@ -98,51 +98,6 @@ function validateCategories(categories, audits, groups) { }); } -/** - * @param {typeof Audit} auditDefinition - * @param {string=} auditPath - */ -function assertValidAudit(auditDefinition, auditPath) { - const auditName = auditPath || - (auditDefinition && auditDefinition.meta && auditDefinition.meta.id); - - if (typeof auditDefinition.audit !== 'function' || auditDefinition.audit === Audit.audit) { - throw new Error(`${auditName} has no audit() method.`); - } - - if (typeof auditDefinition.meta.id !== 'string') { - throw new Error(`${auditName} has no meta.id property, or the property is not a string.`); - } - - if (typeof auditDefinition.meta.title !== 'string') { - throw new Error( - `${auditName} has no meta.title property, or the property is not a string.` - ); - } - - // If it'll have a ✔ or ✖ displayed alongside the result, it should have failureTitle - if (typeof auditDefinition.meta.failureTitle !== 'string' && - auditDefinition.meta.scoreDisplayMode === Audit.SCORING_MODES.BINARY) { - throw new Error(`${auditName} has no failureTitle and should.`); - } - - if (typeof auditDefinition.meta.description !== 'string') { - throw new Error( - `${auditName} has no meta.description property, or the property is not a string.` - ); - } else if (auditDefinition.meta.description === '') { - throw new Error( - `${auditName} has an empty meta.description string. Please add a description for the UI.` - ); - } - - if (!Array.isArray(auditDefinition.meta.requiredArtifacts)) { - throw new Error( - `${auditName} has no meta.requiredArtifacts property, or the property is not an array.` - ); - } -} - /** * @param {Gatherer} gathererInstance * @param {string=} gathererName @@ -384,8 +339,8 @@ class Config { Config.filterConfigIfNeeded(this); - validatePasses(this.passes, this.audits); - validateCategories(this.categories, this.audits, this.groups); + assertValidPasses(this.passes, this.audits); + assertValidCategories(this.categories, this.audits, this.groups); // TODO(bckenny): until tsc adds @implements support, assert that Config is a ConfigJson. /** @type {LH.Config.Json} */ diff --git a/lighthouse-core/gather/gatherers/chrome-console-messages.js b/lighthouse-core/gather/gatherers/chrome-console-messages.js index 2444e6c7f163..3093c9746770 100644 --- a/lighthouse-core/gather/gatherers/chrome-console-messages.js +++ b/lighthouse-core/gather/gatherers/chrome-console-messages.js @@ -12,7 +12,7 @@ const Gatherer = require('./gatherer'); -class ChromeConsoleMessages extends Gatherer { +class ConsoleMessages extends Gatherer { constructor() { super(); /** @type {Array} */ @@ -41,7 +41,7 @@ class ChromeConsoleMessages extends Gatherer { /** * @param {LH.Gatherer.PassContext} passContext - * @return {Promise} + * @return {Promise} */ async afterPass(passContext) { await passContext.driver.sendCommand('Log.stopViolationsReport'); @@ -51,4 +51,4 @@ class ChromeConsoleMessages extends Gatherer { } } -module.exports = ChromeConsoleMessages; +module.exports = ConsoleMessages; diff --git a/lighthouse-core/test/audits/deprecations-test.js b/lighthouse-core/test/audits/deprecations-test.js index 0aff362a95f0..2fbb0b8b523f 100644 --- a/lighthouse-core/test/audits/deprecations-test.js +++ b/lighthouse-core/test/audits/deprecations-test.js @@ -13,7 +13,7 @@ const assert = require('assert'); describe('Console deprecations audit', () => { it('passes when no console messages were found', () => { const auditResult = DeprecationsAudit.audit({ - ChromeConsoleMessages: [], + ConsoleMessages: [], }); assert.equal(auditResult.score, 1); assert.equal(auditResult.details.items.length, 0); @@ -21,7 +21,7 @@ describe('Console deprecations audit', () => { it('handles deprecations that do not have url or line numbers', () => { const auditResult = DeprecationsAudit.audit({ - ChromeConsoleMessages: [ + ConsoleMessages: [ { entry: { source: 'deprecation', @@ -41,7 +41,7 @@ describe('Console deprecations audit', () => { const URL = 'http://example.com'; const auditResult = DeprecationsAudit.audit({ - ChromeConsoleMessages: [ + ConsoleMessages: [ { entry: { source: 'deprecation', diff --git a/lighthouse-core/test/audits/dobetterweb/geolocation-on-start-test.js b/lighthouse-core/test/audits/dobetterweb/geolocation-on-start-test.js index e6681ab9cec9..b0e5c3fc36d6 100644 --- a/lighthouse-core/test/audits/dobetterweb/geolocation-on-start-test.js +++ b/lighthouse-core/test/audits/dobetterweb/geolocation-on-start-test.js @@ -15,7 +15,7 @@ describe('UX: geolocation audit', () => { const text = 'Do not request geolocation permission without a user action.'; const auditResult = GeolocationOnStartAudit.audit({ - ChromeConsoleMessages: [ + ConsoleMessages: [ {entry: {source: 'violation', url: 'https://example.com/', text}}, {entry: {source: 'violation', url: 'https://example2.com/two', text}}, {entry: {source: 'violation', url: 'http://abc.com/', text: 'No document.write'}}, @@ -28,7 +28,7 @@ describe('UX: geolocation audit', () => { it('passes when geolocation has not been automatically requested', () => { const auditResult = GeolocationOnStartAudit.audit({ - ChromeConsoleMessages: [], + ConsoleMessages: [], }); assert.equal(auditResult.score, 1); assert.equal(auditResult.details.items.length, 0); diff --git a/lighthouse-core/test/audits/dobetterweb/no-document-write-test.js b/lighthouse-core/test/audits/dobetterweb/no-document-write-test.js index 6a3172b72b77..7c49f428c456 100644 --- a/lighthouse-core/test/audits/dobetterweb/no-document-write-test.js +++ b/lighthouse-core/test/audits/dobetterweb/no-document-write-test.js @@ -15,7 +15,7 @@ const URL = 'https://example.com'; describe('Page does not use document.write()', () => { it('passes when document.write() is not used', () => { const auditResult = DocWriteUseAudit.audit({ - ChromeConsoleMessages: [], + ConsoleMessages: [], URL: {finalUrl: URL}, }); assert.equal(auditResult.score, 1); @@ -26,7 +26,7 @@ describe('Page does not use document.write()', () => { const text = 'Do not use document.write'; const auditResult = DocWriteUseAudit.audit({ URL: {finalUrl: URL}, - ChromeConsoleMessages: [ + ConsoleMessages: [ {entry: {source: 'violation', url: 'https://example.com/', text}}, {entry: {source: 'violation', url: 'https://example2.com/two', text}}, {entry: {source: 'violation', url: 'http://abc.com/', text: 'Long event handler!'}}, diff --git a/lighthouse-core/test/audits/dobetterweb/notification-on-start-test.js b/lighthouse-core/test/audits/dobetterweb/notification-on-start-test.js index 8a50e5f33b23..94dfe89155d8 100644 --- a/lighthouse-core/test/audits/dobetterweb/notification-on-start-test.js +++ b/lighthouse-core/test/audits/dobetterweb/notification-on-start-test.js @@ -14,7 +14,7 @@ describe('UX: notification audit', () => { it('fails when notification has been automatically requested', () => { const text = 'Do not request notification permission without a user action.'; const auditResult = NotificationOnStart.audit({ - ChromeConsoleMessages: [ + ConsoleMessages: [ {entry: {source: 'violation', url: 'https://example.com/', text}}, {entry: {source: 'violation', url: 'https://example2.com/two', text}}, {entry: {source: 'violation', url: 'http://abc.com/', text: 'No document.write'}}, @@ -27,7 +27,7 @@ describe('UX: notification audit', () => { it('passes when notification has not been automatically requested', () => { const auditResult = NotificationOnStart.audit({ - ChromeConsoleMessages: [], + ConsoleMessages: [], }); assert.equal(auditResult.score, 1); assert.equal(auditResult.details.items.length, 0); diff --git a/lighthouse-core/test/audits/dobetterweb/uses-passive-event-listeners-test.js b/lighthouse-core/test/audits/dobetterweb/uses-passive-event-listeners-test.js index 505fb60213f4..8077cac547f5 100644 --- a/lighthouse-core/test/audits/dobetterweb/uses-passive-event-listeners-test.js +++ b/lighthouse-core/test/audits/dobetterweb/uses-passive-event-listeners-test.js @@ -15,7 +15,7 @@ describe('Page uses passive events listeners where applicable', () => { const text = 'Use passive event listeners when you do not use preventDefault'; const auditResult = PassiveEventsAudit.audit({ - ChromeConsoleMessages: [ + ConsoleMessages: [ {entry: {source: 'violation', url: 'https://example.com/', text}}, {entry: {source: 'violation', url: 'https://example2.com/two', text}}, {entry: {source: 'violation', url: 'https://example2.com/two', text}}, // duplicate @@ -30,7 +30,7 @@ describe('Page uses passive events listeners where applicable', () => { it('passes scroll blocking listeners should be passive', () => { const auditResult = PassiveEventsAudit.audit({ - ChromeConsoleMessages: [], + ConsoleMessages: [], }); assert.equal(auditResult.score, 1); assert.equal(auditResult.extendedInfo.value.length, 0); diff --git a/lighthouse-core/test/audits/errors-in-console-test.js b/lighthouse-core/test/audits/errors-in-console-test.js index 3238927e6848..6382a9d465f9 100644 --- a/lighthouse-core/test/audits/errors-in-console-test.js +++ b/lighthouse-core/test/audits/errors-in-console-test.js @@ -13,7 +13,7 @@ const assert = require('assert'); describe('Console error logs audit', () => { it('passes when no console messages were found', () => { const auditResult = ErrorLogsAudit.audit({ - ChromeConsoleMessages: [], + ConsoleMessages: [], RuntimeExceptions: [], }); assert.equal(auditResult.rawValue, 0); @@ -24,7 +24,7 @@ describe('Console error logs audit', () => { it('filter out the non error logs', () => { const auditResult = ErrorLogsAudit.audit({ - ChromeConsoleMessages: [ + ConsoleMessages: [ { entry: { level: 'info', @@ -42,7 +42,7 @@ describe('Console error logs audit', () => { it('fails when error logs are found ', () => { const auditResult = ErrorLogsAudit.audit({ - ChromeConsoleMessages: [ + ConsoleMessages: [ { entry: { level: 'error', @@ -98,7 +98,7 @@ describe('Console error logs audit', () => { it('handle the case when some logs fields are undefined', () => { const auditResult = ErrorLogsAudit.audit({ - ChromeConsoleMessages: [ + ConsoleMessages: [ { entry: { level: 'error', @@ -119,7 +119,7 @@ describe('Console error logs audit', () => { // Checks bug #4188 it('handle the case when exception info is not present', () => { const auditResult = ErrorLogsAudit.audit({ - ChromeConsoleMessages: [], + ConsoleMessages: [], RuntimeExceptions: [{ 'timestamp': 1506535813608.003, 'exceptionDetails': { diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts index db5e250f4a2f..a563f5648ed9 100644 --- a/types/artifacts.d.ts +++ b/types/artifacts.d.ts @@ -47,11 +47,33 @@ declare global { Timing: Artifacts.MeasureEntry[]; } + /** + * Artifacts provided by the default gatherers that are exposed to plugins with a hardended API. + * NOTE: any breaking changes here are considered breaking Lighthouse changes that must be done + * on a major version bump. + */ + export interface PublicGathererArtifacts { + /** Console deprecation and intervention warnings logged by Chrome during page load. */ + ConsoleMessages: Crdp.Log.EntryAddedEvent[]; + /** Information on size and loading for all the images in the page. Natural size information for `picture` and CSS images is only available if the image was one of the largest 50 images. */ + ImageElements: Artifacts.ImageElement[]; + /** All the link elements on the page or equivalently declared in `Link` headers. @see https://html.spec.whatwg.org/multipage/links.html */ + LinkElements: Artifacts.LinkElement[]; + /** The values of the elements in the head. */ + MetaElements: Array<{name: string, content?: string}>; + /** Set of exceptions thrown during page load. */ + RuntimeExceptions: Crdp.Runtime.ExceptionThrownEvent[]; + /** Information on all script elements in the page. Also contains the content of all requested scripts and the networkRecord requestId that contained their content. Note, HTML documents will have one entry per script tag, all with the same requestId. */ + ScriptElements: Array; + /** The dimensions and devicePixelRatio of the loaded viewport. */ + ViewportDimensions: Artifacts.ViewportDimensions; + } + /** * Artifacts provided by the default gatherers. Augment this interface when adding additional - * gatherers. + * gatherers. Changes to these artifacts are not considered a breaking Lighthouse change. */ - export interface GathererArtifacts { + export interface GathererArtifacts extends PublicGathererArtifacts { /** The results of running the aXe accessibility tests on the page. */ Accessibility: Artifacts.Accessibility; /** Array of all anchors on the page. */ @@ -60,8 +82,6 @@ declare global { AppCacheManifest: string | null; /** Array of all URLs cached in CacheStorage. */ CacheContents: string[]; - /** Console deprecation and intervention warnings logged by Chrome during page load. */ - ChromeConsoleMessages: Crdp.Log.EntryAddedEvent[]; /** CSS coverage information for styles used by page's final state. */ CSSUsage: {rules: Crdp.CSS.RuleUsage[], stylesheets: Artifacts.CSSStyleSheetInfo[]}; /** Information on the document's doctype(or null if not present), specifically the name, publicId, and systemId. @@ -71,26 +91,18 @@ declare global { DOMStats: Artifacts.DOMStats; /** Relevant attributes and child properties of all s, s and s in the page. */ EmbeddedContent: Artifacts.EmbeddedContentInfo[]; - /** All the link elements on the page or equivalently declared in `Link` headers. @see https://html.spec.whatwg.org/multipage/links.html */ - LinkElements: Artifacts.LinkElement[]; /** Information for font faces used in the page. */ Fonts: Artifacts.Font[]; /** Information on poorly sized font usage and the text affected by it. */ FontSize: Artifacts.FontSize; - /** The hreflang and href values of all link[rel=alternate] nodes found in HEAD. */ - Hreflang: {href: string, hreflang: string}[]; /** The page's document body innerText if loaded with JavaScript disabled. */ HTMLWithoutJavaScript: {bodyText: string, hasNoScript: boolean}; /** Whether the page ended up on an HTTPS page after attempting to load the HTTP version. */ HTTPRedirect: {value: boolean}; - /** Information on size and loading for all the images in the page. Natural size information for `picture` and CSS images is only available if the image was one of the largest 50 images. */ - ImageElements: Artifacts.ImageElement[]; /** JS coverage information for code used during page load. */ JsUsage: Crdp.Profiler.ScriptCoverage[]; /** Parsed version of the page's Web App Manifest, or null if none found. */ Manifest: Artifacts.Manifest | null; - /** The values of the elements in the head. */ - MetaElements: Array<{name: string, content?: string}>; /** The URL loaded with interception */ MixedContent: {url: string}; /** The status code of the attempted load of the page while network access is disabled. */ @@ -103,10 +115,6 @@ declare global { ResponseCompression: {requestId: string, url: string, mimeType: string, transferSize: number, resourceSize: number, gzipSize?: number}[]; /** Information on fetching and the content of the /robots.txt file. */ RobotsTxt: {status: number|null, content: string|null}; - /** Set of exceptions thrown during page load. */ - RuntimeExceptions: Crdp.Runtime.ExceptionThrownEvent[]; - /** Information on all script elements in the page. Also contains the content of all requested scripts and the networkRecord requestId that contained their content. Note, HTML documents will have one entry per script tag, all with the same requestId. */ - ScriptElements: Array; /** Version information for all ServiceWorkers active after the first page load. */ ServiceWorker: {versions: Crdp.ServiceWorker.ServiceWorkerVersion[], registrations: Crdp.ServiceWorker.ServiceWorkerRegistration[]}; /** The status of an offline fetch of the page's start_url. -1 and a explanation if missing or there was an error. */ @@ -115,8 +123,6 @@ declare global { TagsBlockingFirstPaint: Artifacts.TagBlockingFirstPaint[]; /** Information about tap targets including their position and size. */ TapTargets: Artifacts.TapTarget[]; - /** The dimensions and devicePixelRatio of the loaded viewport. */ - ViewportDimensions: Artifacts.ViewportDimensions; } module Artifacts { From 6a3c2372591490fd9ac8a215151c33ce6cc45460 Mon Sep 17 00:00:00 2001 From: Patrick Hulce Date: Wed, 17 Apr 2019 17:19:54 -0500 Subject: [PATCH 2/4] reverts --- lighthouse-core/config/config-helpers.js | 963 ----------------------- lighthouse-core/config/config.js | 53 +- 2 files changed, 49 insertions(+), 967 deletions(-) delete mode 100644 lighthouse-core/config/config-helpers.js diff --git a/lighthouse-core/config/config-helpers.js b/lighthouse-core/config/config-helpers.js deleted file mode 100644 index b7fb5f7b9101..000000000000 --- a/lighthouse-core/config/config-helpers.js +++ /dev/null @@ -1,963 +0,0 @@ -/** - * @license Copyright 2016 Google Inc. All Rights Reserved. - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - */ -'use strict'; - -const defaultConfigPath = './default-config.js'; -const defaultConfig = require('./default-config.js'); -const fullConfig = require('./full-config.js'); -const constants = require('./constants.js'); -const i18n = require('./../lib/i18n/i18n.js'); - -const isDeepEqual = require('lodash.isequal'); -const log = require('lighthouse-logger'); -const path = require('path'); -const Audit = require('../audits/audit.js'); -const Runner = require('../runner.js'); -const ConfigPlugin = require('./config-plugin.js'); - -/** @typedef {typeof import('../gather/gatherers/gatherer.js')} GathererConstructor */ -/** @typedef {InstanceType} Gatherer */ - -/** - * @param {Config['passes']} passes - * @param {Config['audits']} audits - */ -function validatePasses(passes, audits) { - if (!Array.isArray(passes)) { - return; - } - - const requiredGatherers = Config.getGatherersNeededByAudits(audits); - - // Log if we are running gathers that are not needed by the audits listed in the config - passes.forEach(pass => { - pass.gatherers.forEach(gathererDefn => { - const gatherer = gathererDefn.instance; - const isGatherRequiredByAudits = requiredGatherers.has(gatherer.name); - if (!isGatherRequiredByAudits) { - const msg = `${gatherer.name} gatherer requested, however no audit requires it.`; - log.warn('config', msg); - } - }); - }); - - // Passes must have unique `passName`s. Throw otherwise. - const usedNames = new Set(); - passes.forEach(pass => { - const passName = pass.passName; - if (usedNames.has(passName)) { - throw new Error(`Passes must have unique names (repeated passName: ${passName}.`); - } - usedNames.add(passName); - }); -} - -/** - * @param {Config['categories']} categories - * @param {Config['audits']} audits - * @param {Config['groups']} groups - */ -function validateCategories(categories, audits, groups) { - if (!categories) { - return; - } - - const auditsKeyedById = new Map((audits || []).map(audit => - /** @type {[string, LH.Config.AuditDefn]} */ - ([audit.implementation.meta.id, audit]) - )); - - Object.keys(categories).forEach(categoryId => { - categories[categoryId].auditRefs.forEach((auditRef, index) => { - if (!auditRef.id) { - throw new Error(`missing an audit id at ${categoryId}[${index}]`); - } - - const audit = auditsKeyedById.get(auditRef.id); - if (!audit) { - throw new Error(`could not find ${auditRef.id} audit for category ${categoryId}`); - } - - const auditImpl = audit.implementation; - const isManual = auditImpl.meta.scoreDisplayMode === 'manual'; - if (categoryId === 'accessibility' && !auditRef.group && !isManual) { - throw new Error(`${auditRef.id} accessibility audit does not have a group`); - } - - if (auditRef.weight > 0 && isManual) { - throw new Error(`${auditRef.id} is manual but has a positive weight`); - } - - if (auditRef.group && (!groups || !groups[auditRef.group])) { - throw new Error(`${auditRef.id} references unknown group ${auditRef.group}`); - } - }); - }); -} - -/** - * @param {typeof Audit} auditDefinition - * @param {string=} auditPath - */ -function assertValidAudit(auditDefinition, auditPath) { - const auditName = auditPath || - (auditDefinition && auditDefinition.meta && auditDefinition.meta.id); - - if (typeof auditDefinition.audit !== 'function' || auditDefinition.audit === Audit.audit) { - throw new Error(`${auditName} has no audit() method.`); - } - - if (typeof auditDefinition.meta.id !== 'string') { - throw new Error(`${auditName} has no meta.id property, or the property is not a string.`); - } - - if (typeof auditDefinition.meta.title !== 'string') { - throw new Error( - `${auditName} has no meta.title property, or the property is not a string.` - ); - } - - // If it'll have a ✔ or ✖ displayed alongside the result, it should have failureTitle - if (typeof auditDefinition.meta.failureTitle !== 'string' && - auditDefinition.meta.scoreDisplayMode === Audit.SCORING_MODES.BINARY) { - throw new Error(`${auditName} has no failureTitle and should.`); - } - - if (typeof auditDefinition.meta.description !== 'string') { - throw new Error( - `${auditName} has no meta.description property, or the property is not a string.` - ); - } else if (auditDefinition.meta.description === '') { - throw new Error( - `${auditName} has an empty meta.description string. Please add a description for the UI.` - ); - } - - if (!Array.isArray(auditDefinition.meta.requiredArtifacts)) { - throw new Error( - `${auditName} has no meta.requiredArtifacts property, or the property is not an array.` - ); - } -} - -/** - * @param {Gatherer} gathererInstance - * @param {string=} gathererName - */ -function assertValidGatherer(gathererInstance, gathererName) { - gathererName = gathererName || gathererInstance.name || 'gatherer'; - - if (typeof gathererInstance.beforePass !== 'function') { - throw new Error(`${gathererName} has no beforePass() method.`); - } - - if (typeof gathererInstance.pass !== 'function') { - throw new Error(`${gathererName} has no pass() method.`); - } - - if (typeof gathererInstance.afterPass !== 'function') { - throw new Error(`${gathererName} has no afterPass() method.`); - } -} - -/** - * Throws if pluginName is invalid or (somehow) collides with a category in the - * configJSON being added to. - * @param {LH.Config.Json} configJSON - * @param {string} pluginName - */ -function assertValidPluginName(configJSON, pluginName) { - if (!pluginName.startsWith('lighthouse-plugin-')) { - throw new Error(`plugin name '${pluginName}' does not start with 'lighthouse-plugin-'`); - } - - if (configJSON.categories && configJSON.categories[pluginName]) { - throw new Error(`plugin name '${pluginName}' not allowed because it is the id of a category already found in config`); // eslint-disable-line max-len - } -} - -/** - * Creates a settings object from potential flags object by dropping all the properties - * that don't exist on Config.Settings. - * @param {Partial=} flags - * @return {RecursivePartial} -*/ -function cleanFlagsForSettings(flags = {}) { - /** @type {RecursivePartial} */ - const settings = {}; - - for (const key of Object.keys(flags)) { - // @ts-ignore - intentionally testing some keys not on defaultSettings to discard them. - if (typeof constants.defaultSettings[key] !== 'undefined') { - // Cast since key now must be able to index both Flags and Settings. - const safekey = /** @type {Extract} */ (key); - settings[safekey] = flags[safekey]; - } - } - - return settings; -} - -/** - * More widely typed than exposed merge() function, below. - * @param {Object|Array|undefined|null} base - * @param {Object|Array} extension - * @param {boolean=} overwriteArrays - */ -function _merge(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 => isDeepEqual(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] = _merge(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 merge = _merge; - -/** - * @template T - * @param {Array} array - * @return {Array} - */ -function cloneArrayWithPluginSafety(array) { - return array.map(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 ConfigJson, copying over any "live" gatherer or audit that - * wouldn't make the JSON round trip. - * @param {LH.Config.Json} json - * @return {LH.Config.Json} - */ -function deepCloneConfigJson(json) { - const cloned = deepClone(json); - - // Copy arrays that could contain plugins to allow for programmatic - // injection of plugins. - if (Array.isArray(cloned.passes) && Array.isArray(json.passes)) { - for (let i = 0; i < cloned.passes.length; i++) { - const pass = cloned.passes[i]; - pass.gatherers = cloneArrayWithPluginSafety(json.passes[i].gatherers || []); - } - } - - if (Array.isArray(json.audits)) { - cloned.audits = cloneArrayWithPluginSafety(json.audits); - } - - return cloned; -} - -/** - * 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. - * Until support of jsdoc templates with constraints, type in config.d.ts. - * See https://github.com/Microsoft/TypeScript/issues/24283 - * @type {LH.Config.MergeOptionsOfItems} - */ -const mergeOptionsOfItems = (function(items) { - /** @type {Array<{path?: string, options?: Object}>} */ - 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; -}); - -class Config { - /** - * @constructor - * @implements {LH.Config.Json} - * @param {LH.Config.Json=} configJSON - * @param {LH.Flags=} flags - */ - constructor(configJSON, flags) { - const status = {msg: 'Create config', id: 'lh:init:config'}; - log.time(status, 'verbose'); - let configPath = flags && flags.configPath; - - if (!configJSON) { - configJSON = defaultConfig; - configPath = path.resolve(__dirname, defaultConfigPath); - } - - if (configPath && !path.isAbsolute(configPath)) { - throw new Error('configPath must be an absolute path.'); - } - - // We don't want to mutate the original config object - configJSON = deepCloneConfigJson(configJSON); - - // Extend the default or full config if specified - if (configJSON.extends === 'lighthouse:full') { - const explodedFullConfig = Config.extendConfigJSON(deepCloneConfigJson(defaultConfig), - deepCloneConfigJson(fullConfig)); - configJSON = Config.extendConfigJSON(explodedFullConfig, configJSON); - } else if (configJSON.extends) { - configJSON = Config.extendConfigJSON(deepCloneConfigJson(defaultConfig), configJSON); - } - - // The directory of the config path, if one was provided. - const configDir = configPath ? path.dirname(configPath) : undefined; - - // Validate and merge in plugins (if any). - configJSON = Config.mergePlugins(configJSON, flags, configDir); - - const settings = Config.initSettings(configJSON.settings, flags); - - // Augment passes with necessary defaults and require gatherers. - const passesWithDefaults = Config.augmentPassesWithDefaults(configJSON.passes); - Config.adjustDefaultPassForThrottling(settings, passesWithDefaults); - const passes = Config.requireGatherers(passesWithDefaults, configDir); - - /** @type {LH.Config.Settings} */ - this.settings = settings; - /** @type {?Array} */ - this.passes = passes; - /** @type {?Array} */ - this.audits = Config.requireAudits(configJSON.audits, configDir); - /** @type {?Record} */ - this.categories = configJSON.categories || null; - /** @type {?Record} */ - this.groups = configJSON.groups || null; - - Config.filterConfigIfNeeded(this); - - validatePasses(this.passes, this.audits); - validateCategories(this.categories, this.audits, this.groups); - - // TODO(bckenny): until tsc adds @implements support, assert that Config is a ConfigJson. - /** @type {LH.Config.Json} */ - const configJson = this; // eslint-disable-line no-unused-vars - log.timeEnd(status); - } - - /** - * Provides a cleaned-up, stringified version of this config. Gatherer and - * Audit `implementation` and `instance` do not survive this process. - * @return {string} - */ - getPrintString() { - const jsonConfig = deepClone(this); - - if (jsonConfig.passes) { - for (const pass of jsonConfig.passes) { - for (const gathererDefn of pass.gatherers) { - gathererDefn.implementation = undefined; - // @ts-ignore Breaking the Config.GathererDefn type. - gathererDefn.instance = undefined; - if (Object.keys(gathererDefn.options).length === 0) { - // @ts-ignore Breaking the Config.GathererDefn type. - gathererDefn.options = undefined; - } - } - } - } - - if (jsonConfig.audits) { - for (const auditDefn of jsonConfig.audits) { - // @ts-ignore Breaking the Config.AuditDefn type. - auditDefn.implementation = undefined; - if (Object.keys(auditDefn.options).length === 0) { - // @ts-ignore Breaking the Config.AuditDefn type. - auditDefn.options = undefined; - } - } - } - - // Printed config is more useful with localized strings. - i18n.replaceIcuMessageInstanceIds(jsonConfig, jsonConfig.settings.locale); - - return JSON.stringify(jsonConfig, null, 2); - } - - /** - * @param {LH.Config.Json} baseJSON The JSON of the configuration to extend - * @param {LH.Config.Json} extendJSON The JSON of the extensions - * @return {LH.Config.Json} - */ - static extendConfigJSON(baseJSON, extendJSON) { - if (extendJSON.passes && baseJSON.passes) { - for (const pass of extendJSON.passes) { - // use the default pass name if one is not specified - const passName = pass.passName || constants.defaultPassConfig.passName; - const basePass = baseJSON.passes.find(candidate => candidate.passName === passName); - - if (!basePass) { - baseJSON.passes.push(pass); - } else { - merge(basePass, pass); - } - } - - delete extendJSON.passes; - } - - return merge(baseJSON, extendJSON); - } - - /** - * @param {LH.Config.Json} configJSON - * @param {LH.Flags=} flags - * @param {string=} configDir - * @return {LH.Config.Json} - */ - static mergePlugins(configJSON, flags, configDir) { - const configPlugins = configJSON.plugins || []; - const flagPlugins = (flags && flags.plugins) || []; - const pluginNames = new Set([...configPlugins, ...flagPlugins]); - - for (const pluginName of pluginNames) { - assertValidPluginName(configJSON, pluginName); - - const pluginPath = Config.resolveModule(pluginName, configDir, 'plugin'); - const rawPluginJson = require(pluginPath); - const pluginJson = ConfigPlugin.parsePlugin(rawPluginJson, pluginName); - - configJSON = Config.extendConfigJSON(configJSON, pluginJson); - } - - return configJSON; - } - - /** - * @param {LH.Config.Json['passes']} passes - * @return {?Array>} - */ - static augmentPassesWithDefaults(passes) { - if (!passes) { - return null; - } - - const {defaultPassConfig} = constants; - return passes.map(pass => merge(deepClone(defaultPassConfig), pass)); - } - - /** - * @param {LH.Config.SettingsJson=} settingsJson - * @param {LH.Flags=} flags - * @return {LH.Config.Settings} - */ - static initSettings(settingsJson = {}, flags) { - // 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). - const locale = i18n.lookupLocale((flags && flags.locale) || settingsJson.locale); - - // Fill in missing settings with defaults - const {defaultSettings} = constants; - const settingWithDefaults = merge(deepClone(defaultSettings), settingsJson, true); - - // Override any applicable settings with CLI flags - const settingsWithFlags = merge(settingWithDefaults || {}, cleanFlagsForSettings(flags), true); - - // Locale is special and comes only from flags/settings/lookupLocale. - settingsWithFlags.locale = locale; - - return settingsWithFlags; - } - - /** - * Expands the audits from user-specified JSON to an internal audit definition format. - * @param {LH.Config.Json['audits']} audits - * @return {?Array<{path: string, options?: {}} | {implementation: typeof Audit, path?: string, options?: {}}>} - */ - static expandAuditShorthand(audits) { - if (!audits) { - return null; - } - - const newAudits = audits.map(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)); - } - }); - - return newAudits; - } - - /** - * Expands the gatherers from user-specified to an internal gatherer definition format. - * - * Input Examples: - * - 'my-gatherer' - * - class MyGatherer extends Gatherer { } - * - {instance: myGathererInstance} - * - * @param {Array} gatherers - * @return {Array<{instance?: Gatherer, implementation?: GathererConstructor, path?: string, options?: {}}>} passes - */ - static expandGathererShorthand(gatherers) { - const expanded = gatherers.map(gatherer => { - if (typeof gatherer === 'string') { - // just 'path/to/gatherer' - return {path: gatherer, options: {}}; - } 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, options: {}}; - } else if (gatherer && typeof gatherer.beforePass === 'function') { - // just GathererInstance - return {instance: gatherer, options: {}}; - } else { - throw new Error('Invalid Gatherer type ' + JSON.stringify(gatherer)); - } - }); - - return expanded; - } - - /** - * Observed throttling methods (devtools/provided) require at least 5s of quiet for the metrics to - * be computed. This method adjusts the quiet thresholds to the required minimums if necessary. - * @param {LH.Config.Settings} settings - * @param {?Array>} passes - */ - static adjustDefaultPassForThrottling(settings, passes) { - if (!passes || - (settings.throttlingMethod !== 'devtools' && settings.throttlingMethod !== 'provided')) { - return; - } - - const defaultPass = passes.find(pass => pass.passName === 'defaultPass'); - if (!defaultPass) return; - const overrides = constants.nonSimulatedPassConfigOverrides; - defaultPass.pauseAfterLoadMs = - Math.max(overrides.pauseAfterLoadMs, defaultPass.pauseAfterLoadMs); - defaultPass.cpuQuietThresholdMs = - Math.max(overrides.cpuQuietThresholdMs, defaultPass.cpuQuietThresholdMs); - defaultPass.networkQuietThresholdMs = - Math.max(overrides.networkQuietThresholdMs, defaultPass.networkQuietThresholdMs); - } - - /** - * Filter out any unrequested items from the config, based on requested categories or audits. - * @param {Config} config - */ - static filterConfigIfNeeded(config) { - const settings = config.settings; - if (!settings.onlyCategories && !settings.onlyAudits && !settings.skipAudits) { - return; - } - - // 1. Filter to just the chosen categories/audits - const {categories, requestedAuditNames} = Config.filterCategoriesAndAudits(config.categories, - settings); - - // 2. Resolve which audits will need to run - const audits = config.audits && config.audits.filter(auditDefn => - requestedAuditNames.has(auditDefn.implementation.meta.id)); - - // 3. Resolve which gatherers will need to run - const requiredGathererIds = Config.getGatherersNeededByAudits(audits); - - // 4. Filter to only the neccessary passes - const passes = Config.generatePassesNeededByGatherers(config.passes, requiredGathererIds); - - config.categories = categories; - config.audits = audits; - config.passes = passes; - } - - /** - * Filter out any unrequested categories or audits from the categories object. - * @param {Config['categories']} oldCategories - * @param {LH.Config.Settings} settings - * @return {{categories: Config['categories'], requestedAuditNames: Set}} - */ - static filterCategoriesAndAudits(oldCategories, settings) { - if (!oldCategories) { - return {categories: null, requestedAuditNames: new Set()}; - } - - if (settings.onlyAudits && settings.skipAudits) { - throw new Error('Cannot set both skipAudits and onlyAudits'); - } - - /** @type {NonNullable} */ - const categories = {}; - const filterByIncludedCategory = !!settings.onlyCategories; - const filterByIncludedAudit = !!settings.onlyAudits; - const categoryIds = settings.onlyCategories || []; - const auditIds = settings.onlyAudits || []; - const skipAuditIds = settings.skipAudits || []; - - // warn if the category is not found - categoryIds.forEach(categoryId => { - if (!oldCategories[categoryId]) { - log.warn('config', `unrecognized category in 'onlyCategories': ${categoryId}`); - } - }); - - // warn if the audit is not found in a category or there are overlaps - const auditsToValidate = new Set(auditIds.concat(skipAuditIds)); - for (const auditId of auditsToValidate) { - const foundCategory = Object.keys(oldCategories).find(categoryId => { - const auditRefs = oldCategories[categoryId].auditRefs; - return !!auditRefs.find(candidate => candidate.id === auditId); - }); - - if (!foundCategory) { - const parentKeyName = skipAuditIds.includes(auditId) ? 'skipAudits' : 'onlyAudits'; - log.warn('config', `unrecognized audit in '${parentKeyName}': ${auditId}`); - } else if (auditIds.includes(auditId) && categoryIds.includes(foundCategory)) { - log.warn('config', `${auditId} in 'onlyAudits' is already included by ` + - `${foundCategory} in 'onlyCategories'`); - } - } - - const includedAudits = new Set(auditIds); - skipAuditIds.forEach(id => includedAudits.delete(id)); - - Object.keys(oldCategories).forEach(categoryId => { - const category = deepClone(oldCategories[categoryId]); - - if (filterByIncludedCategory && filterByIncludedAudit) { - // If we're filtering to the category and audit whitelist, include the union of the two - if (!categoryIds.includes(categoryId)) { - category.auditRefs = category.auditRefs.filter(audit => auditIds.includes(audit.id)); - } - } else if (filterByIncludedCategory) { - // If we're filtering to just the category whitelist and the category is not included, skip it - if (!categoryIds.includes(categoryId)) { - return; - } - } else if (filterByIncludedAudit) { - category.auditRefs = category.auditRefs.filter(audit => auditIds.includes(audit.id)); - } - - // always filter to the audit blacklist - category.auditRefs = category.auditRefs.filter(audit => !skipAuditIds.includes(audit.id)); - - if (category.auditRefs.length) { - categories[categoryId] = category; - category.auditRefs.forEach(audit => includedAudits.add(audit.id)); - } - }); - - return {categories, requestedAuditNames: includedAudits}; - } - - /** - * @param {LH.Config.Json} config - * @return {Array<{id: string, title: string}>} - */ - static getCategories(config) { - const categories = config.categories; - if (!categories) { - return []; - } - - return Object.keys(categories).map(id => { - const title = categories[id].title; - return {id, title}; - }); - } - - /** - * From some requested audits, return names of all required artifacts - * @param {Config['audits']} audits - * @return {Set} - */ - static getGatherersNeededByAudits(audits) { - // It's possible we weren't given any audits (but existing audit results), in which case - // there is no need to do any work here. - if (!audits) { - return new Set(); - } - - return audits.reduce((list, auditDefn) => { - auditDefn.implementation.meta.requiredArtifacts.forEach(artifact => list.add(artifact)); - return list; - }, new Set()); - } - - /** - * Filters to only required passes and gatherers, returning a new passes array. - * @param {Config['passes']} passes - * @param {Set} requiredGatherers - * @return {Config['passes']} - */ - static generatePassesNeededByGatherers(passes, requiredGatherers) { - if (!passes) { - return null; - } - - const auditsNeedTrace = requiredGatherers.has('traces'); - const filteredPasses = passes.map(pass => { - // remove any unncessary gatherers from within the passes - pass.gatherers = pass.gatherers.filter(gathererDefn => { - const gatherer = gathererDefn.instance; - return requiredGatherers.has(gatherer.name); - }); - - // disable the trace if no audit requires a trace - if (pass.recordTrace && !auditsNeedTrace) { - const passName = pass.passName || 'unknown pass'; - log.warn('config', `Trace not requested by an audit, dropping trace in ${passName}`); - pass.recordTrace = false; - } - - return pass; - }).filter(pass => { - // remove any passes lacking concrete gatherers, unless they are dependent on the trace - if (pass.recordTrace) return true; - // Always keep defaultPass - if (pass.passName === 'defaultPass') return true; - return pass.gatherers.length > 0; - }); - return filteredPasses; - } - - /** - * Take an array of audits and audit paths and require any paths (possibly - * relative to the optional `configDir`) using `Config.resolveModule`, - * leaving only an array of AuditDefns. - * @param {LH.Config.Json['audits']} audits - * @param {string=} configDir - * @return {Config['audits']} - */ - static requireAudits(audits, configDir) { - const status = {msg: 'Requiring audits', id: 'lh:config:requireAudits'}; - log.time(status, 'verbose'); - const expandedAudits = Config.expandAuditShorthand(audits); - if (!expandedAudits) { - return null; - } - - const coreList = Runner.getAuditList(); - const auditDefns = expandedAudits.map(audit => { - let implementation; - if ('implementation' in audit) { - implementation = audit.implementation; - } else { - // See if the audit is a Lighthouse core audit. - const auditPathJs = `${audit.path}.js`; - const coreAudit = coreList.find(a => a === auditPathJs); - let requirePath = `../audits/${audit.path}`; - if (!coreAudit) { - // Otherwise, attempt to find it elsewhere. This throws if not found. - requirePath = Config.resolveModule(audit.path, configDir, 'audit'); - } - implementation = /** @type {typeof Audit} */ (require(requirePath)); - } - - return { - implementation, - path: audit.path, - options: audit.options || {}, - }; - }); - - const mergedAuditDefns = mergeOptionsOfItems(auditDefns); - mergedAuditDefns.forEach(audit => assertValidAudit(audit.implementation, audit.path)); - log.timeEnd(status); - return mergedAuditDefns; - } - - /** - * @param {string} path - * @param {{}=} options - * @param {Array} coreAuditList - * @param {string=} configDir - * @return {LH.Config.GathererDefn} - */ - static requireGathererFromPath(path, options, coreAuditList, configDir) { - const coreGatherer = coreAuditList.find(a => a === `${path}.js`); - - let requirePath = `../gather/gatherers/${path}`; - if (!coreGatherer) { - // Otherwise, attempt to find it elsewhere. This throws if not found. - requirePath = Config.resolveModule(path, configDir, 'gatherer'); - } - - const GathererClass = /** @type {GathererConstructor} */ (require(requirePath)); - - return { - instance: new GathererClass(), - implementation: GathererClass, - path, - options: options || {}, - }; - } - - /** - * Takes an array of passes with every property now initialized except the - * gatherers and requires them, (relative to the optional `configDir` if - * provided) using `Config.resolveModule`, returning an array of full Passes. - * @param {?Array>} passes - * @param {string=} configDir - * @return {Config['passes']} - */ - static requireGatherers(passes, configDir) { - if (!passes) { - return null; - } - const status = {msg: 'Requiring gatherers', id: 'lh:config:requireGatherers'}; - log.time(status, 'verbose'); - - const coreList = Runner.getGathererList(); - const fullPasses = passes.map(pass => { - const gathererDefns = Config.expandGathererShorthand(pass.gatherers).map(gathererDefn => { - if (gathererDefn.instance) { - return { - instance: gathererDefn.instance, - implementation: gathererDefn.implementation, - path: gathererDefn.path, - options: gathererDefn.options || {}, - }; - } else if (gathererDefn.implementation) { - const GathererClass = gathererDefn.implementation; - return { - instance: new GathererClass(), - implementation: gathererDefn.implementation, - path: gathererDefn.path, - options: gathererDefn.options || {}, - }; - } else if (gathererDefn.path) { - const path = gathererDefn.path; - const options = gathererDefn.options; - return Config.requireGathererFromPath(path, options, coreList, configDir); - } else { - throw new Error('Invalid expanded Gatherer: ' + JSON.stringify(gathererDefn)); - } - }); - - const mergedDefns = mergeOptionsOfItems(gathererDefns); - mergedDefns.forEach(gatherer => assertValidGatherer(gatherer.instance, gatherer.path)); - - return Object.assign(pass, {gatherers: mergedDefns}); - }); - log.timeEnd(status); - return fullPasses; - } - - /** - * 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} - */ - static resolveModule(moduleIdentifier, configDir, category) { - // 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) {} - - // 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 require() from '${__dirname}' and load from '${cwdPath}'`; - - if (!configDir) { - throw new Error(errorString + ')'); - } - - // Finally, 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) {} - - throw new Error(errorString + ` and '${relativePath}')`); - } -} - -module.exports = Config; diff --git a/lighthouse-core/config/config.js b/lighthouse-core/config/config.js index 6c15c0d21121..b7fb5f7b9101 100644 --- a/lighthouse-core/config/config.js +++ b/lighthouse-core/config/config.js @@ -25,7 +25,7 @@ const ConfigPlugin = require('./config-plugin.js'); * @param {Config['passes']} passes * @param {Config['audits']} audits */ -function assertValidPasses(passes, audits) { +function validatePasses(passes, audits) { if (!Array.isArray(passes)) { return; } @@ -60,7 +60,7 @@ function assertValidPasses(passes, audits) { * @param {Config['audits']} audits * @param {Config['groups']} groups */ -function assertValidCategories(categories, audits, groups) { +function validateCategories(categories, audits, groups) { if (!categories) { return; } @@ -98,6 +98,51 @@ function assertValidCategories(categories, audits, groups) { }); } +/** + * @param {typeof Audit} auditDefinition + * @param {string=} auditPath + */ +function assertValidAudit(auditDefinition, auditPath) { + const auditName = auditPath || + (auditDefinition && auditDefinition.meta && auditDefinition.meta.id); + + if (typeof auditDefinition.audit !== 'function' || auditDefinition.audit === Audit.audit) { + throw new Error(`${auditName} has no audit() method.`); + } + + if (typeof auditDefinition.meta.id !== 'string') { + throw new Error(`${auditName} has no meta.id property, or the property is not a string.`); + } + + if (typeof auditDefinition.meta.title !== 'string') { + throw new Error( + `${auditName} has no meta.title property, or the property is not a string.` + ); + } + + // If it'll have a ✔ or ✖ displayed alongside the result, it should have failureTitle + if (typeof auditDefinition.meta.failureTitle !== 'string' && + auditDefinition.meta.scoreDisplayMode === Audit.SCORING_MODES.BINARY) { + throw new Error(`${auditName} has no failureTitle and should.`); + } + + if (typeof auditDefinition.meta.description !== 'string') { + throw new Error( + `${auditName} has no meta.description property, or the property is not a string.` + ); + } else if (auditDefinition.meta.description === '') { + throw new Error( + `${auditName} has an empty meta.description string. Please add a description for the UI.` + ); + } + + if (!Array.isArray(auditDefinition.meta.requiredArtifacts)) { + throw new Error( + `${auditName} has no meta.requiredArtifacts property, or the property is not an array.` + ); + } +} + /** * @param {Gatherer} gathererInstance * @param {string=} gathererName @@ -339,8 +384,8 @@ class Config { Config.filterConfigIfNeeded(this); - assertValidPasses(this.passes, this.audits); - assertValidCategories(this.categories, this.audits, this.groups); + validatePasses(this.passes, this.audits); + validateCategories(this.categories, this.audits, this.groups); // TODO(bckenny): until tsc adds @implements support, assert that Config is a ConfigJson. /** @type {LH.Config.Json} */ From c7af5b6a49af7bf9b6ecd7825f3b548b51099a96 Mon Sep 17 00:00:00 2001 From: Patrick Hulce Date: Thu, 18 Apr 2019 13:04:21 -0500 Subject: [PATCH 3/4] rename file too --- lighthouse-core/config/default-config.js | 2 +- .../{chrome-console-messages.js => console-messages.js} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename lighthouse-core/gather/gatherers/{chrome-console-messages.js => console-messages.js} (100%) diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js index 82b346a19ffd..96e89cc74ed3 100644 --- a/lighthouse-core/config/default-config.js +++ b/lighthouse-core/config/default-config.js @@ -101,7 +101,7 @@ const defaultConfig = { 'css-usage', 'viewport-dimensions', 'runtime-exceptions', - 'chrome-console-messages', + 'console-messages', 'anchor-elements', 'image-elements', 'link-elements', diff --git a/lighthouse-core/gather/gatherers/chrome-console-messages.js b/lighthouse-core/gather/gatherers/console-messages.js similarity index 100% rename from lighthouse-core/gather/gatherers/chrome-console-messages.js rename to lighthouse-core/gather/gatherers/console-messages.js From 2b59180fc1685c754e74c4285d04cd94f2589ee4 Mon Sep 17 00:00:00 2001 From: Patrick Hulce Date: Thu, 18 Apr 2019 13:05:36 -0500 Subject: [PATCH 4/4] udpate sample artifacts --- lighthouse-cli/test/cli/__snapshots__/index-test.js.snap | 2 +- lighthouse-core/test/results/artifacts/artifacts.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap index aeaddcff27cb..dda3b1305a86 100644 --- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap +++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap @@ -1079,7 +1079,7 @@ Object { "path": "runtime-exceptions", }, Object { - "path": "chrome-console-messages", + "path": "console-messages", }, Object { "path": "anchor-elements", diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json index d91391fc82e2..0aef7b229206 100644 --- a/lighthouse-core/test/results/artifacts/artifacts.json +++ b/lighthouse-core/test/results/artifacts/artifacts.json @@ -406,7 +406,7 @@ } } ], - "ChromeConsoleMessages": [ + "ConsoleMessages": [ { "entry": { "source": "other",