UNPKG

lighthouse

Version:

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

251 lines (220 loc) • 7.66 kB
/** * @license * Copyright 2019 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as i18n from '../lib/i18n/i18n.js'; /** * @param {unknown} arr * @return {arr is Array<Record<string, unknown>>} */ function isArrayOfUnknownObjects(arr) { return Array.isArray(arr) && arr.every(isObjectOfUnknownProperties); } /** * @param {unknown} val * @return {val is Record<string, unknown>} */ function isObjectOfUnknownProperties(val) { return typeof val === 'object' && val !== null && !Array.isArray(val); } /** * @param {unknown} str * @return {str is LH.Gatherer.GatherMode} */ function objectIsGatherMode(str) { if (typeof str !== 'string') return false; return str === 'navigation' || str === 'timespan' || str === 'snapshot'; } /** * @param {unknown} arr * @return {arr is Array<LH.Gatherer.GatherMode>} */ function isArrayOfGatherModes(arr) { if (!Array.isArray(arr)) return false; return arr.every(objectIsGatherMode); } /** * Asserts that obj has no own properties, throwing a nice error message if it does. * Plugin and object name are included for nicer logging. * @param {Record<string, unknown>} obj * @param {string} pluginName * @param {string=} objectName */ function assertNoExcessProperties(obj, pluginName, objectName = '') { if (objectName) { objectName += ' '; } const invalidKeys = Object.keys(obj); if (invalidKeys.length > 0) { const keys = invalidKeys.join(', '); throw new Error(`${pluginName} has unrecognized ${objectName}properties: [${keys}]`); } } /** * A set of methods for extracting and validating a Lighthouse plugin config. */ class ConfigPlugin { /** * Extract and validate the list of AuditDefns added by the plugin (or undefined * if no additional audits are being added by the plugin). * @param {unknown} auditsJson * @param {string} pluginName * @return {Array<{path: string}>|undefined} */ static _parseAuditsList(auditsJson, pluginName) { // Plugin audits aren't required (relying on LH default audits) so fall back to []. if (auditsJson === undefined) { return undefined; } else if (!isArrayOfUnknownObjects(auditsJson)) { throw new Error(`${pluginName} has an invalid audits array.`); } return auditsJson.map(auditDefnJson => { const {path, ...invalidRest} = auditDefnJson; assertNoExcessProperties(invalidRest, pluginName, 'audit'); if (typeof path !== 'string') { throw new Error(`${pluginName} has a missing audit path.`); } return { path, }; }); } /** * Extract and validate the list of category AuditRefs added by the plugin. * @param {unknown} auditRefsJson * @param {string} pluginName * @return {Array<LH.Config.AuditRefJson>} */ static _parseAuditRefsList(auditRefsJson, pluginName) { if (!isArrayOfUnknownObjects(auditRefsJson)) { throw new Error(`${pluginName} has no valid auditsRefs.`); } return auditRefsJson.map(auditRefJson => { const {id, weight, group, ...invalidRest} = auditRefJson; assertNoExcessProperties(invalidRest, pluginName, 'auditRef'); if (typeof id !== 'string') { throw new Error(`${pluginName} has an invalid auditRef id.`); } if (typeof weight !== 'number') { throw new Error(`${pluginName} has an invalid auditRef weight.`); } if (typeof group !== 'string' && typeof group !== 'undefined') { throw new Error(`${pluginName} has an invalid auditRef group.`); } const prependedGroup = group ? `${pluginName}-${group}` : group; return { id, weight, group: prependedGroup, }; }); } /** * Extract and validate the category added by the plugin. * @param {unknown} categoryJson * @param {string} pluginName * @return {LH.Config.CategoryJson} */ static _parseCategory(categoryJson, pluginName) { if (!isObjectOfUnknownProperties(categoryJson)) { throw new Error(`${pluginName} has no valid category.`); } const { title, description, manualDescription, auditRefs: auditRefsJson, supportedModes, ...invalidRest } = categoryJson; assertNoExcessProperties(invalidRest, pluginName, 'category'); if (!i18n.isStringOrIcuMessage(title)) { throw new Error(`${pluginName} has an invalid category tile.`); } if (!i18n.isStringOrIcuMessage(description) && description !== undefined) { throw new Error(`${pluginName} has an invalid category description.`); } if (!i18n.isStringOrIcuMessage(manualDescription) && manualDescription !== undefined) { throw new Error(`${pluginName} has an invalid category manualDescription.`); } if (!isArrayOfGatherModes(supportedModes) && supportedModes !== undefined) { throw new Error( `${pluginName} supportedModes must be an array, ` + `valid array values are "navigation", "timespan", and "snapshot".` ); } const auditRefs = ConfigPlugin._parseAuditRefsList(auditRefsJson, pluginName); return { title, auditRefs, description: description, manualDescription: manualDescription, supportedModes, }; } /** * Extract and validate groups JSON added by the plugin. * @param {unknown} groupsJson * @param {string} pluginName * @return {Record<string, LH.Config.GroupJson>|undefined} */ static _parseGroups(groupsJson, pluginName) { if (groupsJson === undefined) { return undefined; } if (!isObjectOfUnknownProperties(groupsJson)) { throw new Error(`${pluginName} groups json is not defined as an object.`); } const groups = Object.entries(groupsJson); /** @type {Record<string, LH.Config.GroupJson>} */ const parsedGroupsJson = {}; groups.forEach(([groupId, groupJson]) => { if (!isObjectOfUnknownProperties(groupJson)) { throw new Error(`${pluginName} has a group not defined as an object.`); } const {title, description, ...invalidRest} = groupJson; assertNoExcessProperties(invalidRest, pluginName, 'group'); if (!i18n.isStringOrIcuMessage(title)) { throw new Error(`${pluginName} has an invalid group title.`); } if (!i18n.isStringOrIcuMessage(description) && description !== undefined) { throw new Error(`${pluginName} has an invalid group description.`); } parsedGroupsJson[`${pluginName}-${groupId}`] = { title, description, }; }); return parsedGroupsJson; } /** * Extracts and validates a config from the provided plugin input, throwing * if it deviates from the expected object shape. * @param {unknown} pluginJson * @param {string} pluginName * @return {LH.Config} */ static parsePlugin(pluginJson, pluginName) { // Clone to prevent modifications of original and to deactivate any live properties. pluginJson = JSON.parse(JSON.stringify(pluginJson)); if (!isObjectOfUnknownProperties(pluginJson)) { throw new Error(`${pluginName} is not defined as an object.`); } const { audits: pluginAuditsJson, category: pluginCategoryJson, groups: pluginGroupsJson, ...invalidRest } = pluginJson; assertNoExcessProperties(invalidRest, pluginName); return { audits: ConfigPlugin._parseAuditsList(pluginAuditsJson, pluginName), categories: { [pluginName]: ConfigPlugin._parseCategory(pluginCategoryJson, pluginName), }, groups: ConfigPlugin._parseGroups(pluginGroupsJson, pluginName), }; } } export default ConfigPlugin;