diff --git a/lighthouse-cli/bin.js b/lighthouse-cli/bin.js index b442e85496f8..5fe06032f71a 100644 --- a/lighthouse-cli/bin.js +++ b/lighthouse-cli/bin.js @@ -77,6 +77,13 @@ async function begin() { configJson = require(`../lighthouse-core/config/${cliFlags.preset}-config.js`); } + if (cliFlags.budgetPath) { + cliFlags.budgetPath = path.resolve(process.cwd(), cliFlags.budgetPath); + /** @type {Array} */ + const parsedBudget = JSON.parse(fs.readFileSync(cliFlags.budgetPath, 'utf8')); + cliFlags.budgets = parsedBudget; + } + // set logging preferences cliFlags.logLevel = 'info'; if (cliFlags.verbose) { diff --git a/lighthouse-cli/cli-flags.js b/lighthouse-cli/cli-flags.js index 61bcdccfcc6f..c24354cae69c 100644 --- a/lighthouse-cli/cli-flags.js +++ b/lighthouse-cli/cli-flags.js @@ -59,7 +59,7 @@ function getFlags(manualArgv) { 'save-assets', 'list-all-audits', 'list-trace-categories', 'print-config', 'additional-trace-categories', 'config-path', 'preset', 'chrome-flags', 'port', 'hostname', 'emulated-form-factor', 'max-wait-for-load', 'enable-error-reporting', 'gather-mode', 'audit-mode', - 'only-audits', 'only-categories', 'skip-audits', + 'only-audits', 'only-categories', 'skip-audits', 'budget-path', ], 'Configuration:') .describe({ @@ -88,6 +88,7 @@ function getFlags(manualArgv) { 'Additional categories to capture with the trace (comma-delimited).', 'config-path': `The path to the config JSON. An example config file: lighthouse-core/config/lr-desktop-config.js`, + 'budget-path': `The path to the budget.json file for LightWallet.`, 'preset': `Use a built-in configuration. WARNING: If the --config-path flag is provided, this preset will be ignored.`, 'chrome-flags': @@ -141,6 +142,7 @@ function getFlags(manualArgv) { .string('channel') .string('precomputedLanternDataPath') .string('lanternDataOutputPath') + .string('budgetPath') // default values .default('chrome-flags', '') diff --git a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap index 593c3c48d078..dd4636eb9237 100644 --- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap +++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap @@ -1198,6 +1198,7 @@ Object { "additionalTraceCategories": null, "auditMode": false, "blockedUrlPatterns": null, + "budgets": null, "channel": "cli", "disableStorageReset": false, "emulatedFormFactor": "mobile", @@ -1327,6 +1328,7 @@ Object { "additionalTraceCategories": null, "auditMode": true, "blockedUrlPatterns": null, + "budgets": null, "channel": "cli", "disableStorageReset": false, "emulatedFormFactor": "mobile", diff --git a/lighthouse-cli/test/cli/bin-test.js b/lighthouse-cli/test/cli/bin-test.js index 70ef3e9544dd..b53983926ab7 100644 --- a/lighthouse-cli/test/cli/bin-test.js +++ b/lighthouse-cli/test/cli/bin-test.js @@ -99,6 +99,17 @@ describe('CLI bin', function() { }); }); + describe('budget', () => { + it('should load the config from the path', async () => { + const budgetPath = '../../../lighthouse-core/test/fixtures/simple-budget.json'; + cliFlags = {...cliFlags, budgetPath: require.resolve(budgetPath)}; + const budgetFile = require(budgetPath); + await bin.begin(); + + expect(getRunLighthouseArgs()[1].budgets).toEqual(budgetFile); + }); + }); + describe('logging', () => { it('should have info by default', async () => { await bin.begin(); diff --git a/lighthouse-core/config/budget.js b/lighthouse-core/config/budget.js new file mode 100644 index 000000000000..820dda302f4b --- /dev/null +++ b/lighthouse-core/config/budget.js @@ -0,0 +1,131 @@ +/** + * @license Copyright 2019 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'; + +class Budget { + /** + * 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} obj + * @param {string} objectName + */ + static assertNoExcessProperties(obj, objectName) { + const invalidKeys = Object.keys(obj); + if (invalidKeys.length > 0) { + const keys = invalidKeys.join(', '); + throw new Error(`${objectName} has unrecognized properties: [${keys}]`); + } + } + + /** + * @param {LH.Budget.ResourceBudget} resourceBudget + * @return {LH.Budget.ResourceBudget} + */ + static validateResourceBudget(resourceBudget) { + const {resourceType, budget, ...invalidRest} = resourceBudget; + Budget.assertNoExcessProperties(invalidRest, 'Resource Budget'); + + const validResourceTypes = [ + 'total', + 'document', + 'script', + 'stylesheet', + 'image', + 'media', + 'font', + 'other', + 'third-party', + ]; + if (!validResourceTypes.includes(resourceBudget.resourceType)) { + throw new Error(`Invalid resource type: ${resourceBudget.resourceType}. \n` + + `Valid resource types are: ${ validResourceTypes.join(', ') }`); + } + if (isNaN(resourceBudget.budget)) { + throw new Error('Invalid budget: ${resourceBudget.budget}'); + } + return { + resourceType, + budget, + }; + } + + /** + * @param {LH.Budget.TimingBudget} timingBudget + * @return {LH.Budget.TimingBudget} + */ + static validateTimingBudget(timingBudget) { + const {metric, budget, tolerance, ...invalidRest} = timingBudget; + Budget.assertNoExcessProperties(invalidRest, 'Timing Budget'); + + const validTimingMetrics = [ + 'first-contentful-paint', + 'first-cpu-idle', + 'interactive', + 'first-meaningful-paint', + 'estimated-input-latency', + ]; + if (!validTimingMetrics.includes(timingBudget.metric)) { + throw new Error(`Invalid timing metric: ${timingBudget.metric}. \n` + + `Valid timing metrics are: ${validTimingMetrics.join(', ')}`); + } + if (isNaN(timingBudget.budget)) { + throw new Error('Invalid budget: ${timingBudget.budget}'); + } + if (timingBudget.tolerance !== undefined && isNaN(timingBudget.tolerance)) { + throw new Error('Invalid tolerance: ${timingBudget.tolerance}'); + } + return { + metric, + budget, + tolerance, + }; + } + + /** + * More info on the Budget format: + * https://github.com/GoogleChrome/lighthouse/issues/6053#issuecomment-428385930 + * @param {Array} budgetArr + * @return {Array} + */ + static initializeBudget(budgetArr) { + /** @type {Array} */ + const budgets = []; + + budgetArr.forEach((b) => { + /** @type {LH.Budget} */ + const budget = {}; + + const {resourceSizes, resourceCounts, timings, ...invalidRest} = b; + Budget.assertNoExcessProperties(invalidRest, 'Budget'); + + if (b.resourceSizes !== undefined) { + budget.resourceSizes = b.resourceSizes.map((r) => { + return Budget.validateResourceBudget(r); + }); + } + + if (b.resourceCounts !== undefined) { + budget.resourceCounts = b.resourceCounts.map((r) => { + return Budget.validateResourceBudget(r); + }); + } + + if (b.timings !== undefined) { + budget.timings = b.timings.map((t) => { + return Budget.validateTimingBudget(t); + }); + } + budgets.push({ + resourceSizes, + resourceCounts, + timings, + }); + }); + return budgets; + } +} + +module.exports = Budget; diff --git a/lighthouse-core/config/config.js b/lighthouse-core/config/config.js index b7fb5f7b9101..a828f3abf19d 100644 --- a/lighthouse-core/config/config.js +++ b/lighthouse-core/config/config.js @@ -17,6 +17,7 @@ const path = require('path'); const Audit = require('../audits/audit.js'); const Runner = require('../runner.js'); const ConfigPlugin = require('./config-plugin.js'); +const Budget = require('./budget.js'); /** @typedef {typeof import('../gather/gatherers/gatherer.js')} GathererConstructor */ /** @typedef {InstanceType} Gatherer */ @@ -512,6 +513,9 @@ class Config { // Override any applicable settings with CLI flags const settingsWithFlags = merge(settingWithDefaults || {}, cleanFlagsForSettings(flags), true); + if (settingsWithFlags.budgets) { + settingsWithFlags.budgets = Budget.initializeBudget(settingsWithFlags.budgets); + } // Locale is special and comes only from flags/settings/lookupLocale. settingsWithFlags.locale = locale; diff --git a/lighthouse-core/config/constants.js b/lighthouse-core/config/constants.js index 331975751ece..9f5ce283b313 100644 --- a/lighthouse-core/config/constants.js +++ b/lighthouse-core/config/constants.js @@ -55,6 +55,7 @@ const defaultSettings = { // the following settings have no defaults but we still want ensure that `key in settings` // in config will work in a typechecked way + budgets: null, locale: 'en-US', // actual default determined by Config using lib/i18n blockedUrlPatterns: null, additionalTraceCategories: null, diff --git a/lighthouse-core/runner.js b/lighthouse-core/runner.js index 1cbbf12ce1cd..1504607bc99c 100644 --- a/lighthouse-core/runner.js +++ b/lighthouse-core/runner.js @@ -234,6 +234,7 @@ class Runner { gatherMode: undefined, auditMode: undefined, output: undefined, + budgets: undefined, }; const normalizedGatherSettings = Object.assign({}, artifacts.settings, overrides); const normalizedAuditSettings = Object.assign({}, settings, overrides); diff --git a/lighthouse-core/test/config/budget-test.js b/lighthouse-core/test/config/budget-test.js new file mode 100644 index 000000000000..7a56d587b49a --- /dev/null +++ b/lighthouse-core/test/config/budget-test.js @@ -0,0 +1,128 @@ +/** + * @license Copyright 2019 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 Budget = require('../../config/budget.js'); +const assert = require('assert'); +/* eslint-env jest */ + +describe('Budget', () => { + let budget; + beforeEach(() => { + budget = [ + { + resourceSizes: [ + { + resourceType: 'script', + budget: 123, + }, + { + resourceType: 'image', + budget: 456, + }, + ], + resourceCounts: [ + { + resourceType: 'total', + budget: 100, + }, + { + resourceType: 'third-party', + budget: 10, + }, + ], + timings: [ + { + metric: 'interactive', + budget: 2000, + tolerance: 1000, + }, + { + metric: 'first-contentful-paint', + budget: 1000, + tolerance: 500, + }, + ], + }, + { + resourceSizes: [ + { + resourceType: 'script', + budget: 1000, + }, + ], + }, + ]; + }); + + it('initializes correctly', () => { + const budgets = Budget.initializeBudget(budget); + assert.equal(budgets.length, 2); + + // Sets resources sizes correctly + assert.equal(budgets[0].resourceSizes.length, 2); + assert.equal(budgets[0].resourceSizes[0].resourceType, 'script'); + assert.equal(budgets[0].resourceSizes[0].budget, 123); + + // Sets resource counts correctly + assert.equal(budgets[0].resourceCounts.length, 2); + assert.equal(budgets[0].resourceCounts[0].resourceType, 'total'); + assert.equal(budgets[0].resourceCounts[0].budget, 100); + + // Sets timings correctly + assert.equal(budgets[0].timings.length, 2); + assert.equal(budgets[0].timings[1].metric, 'first-contentful-paint'); + assert.equal(budgets[0].timings[1].budget, 1000); + assert.equal(budgets[0].timings[1].tolerance, 500); + + // Does not set unsupplied budgets + assert.equal(budgets[1].timings, null); + }); + + it('throws error if an unsupported budget property is used', () => { + budget[0].sizes = []; + assert.throws(_ => Budget.initializeBudget(budget), /[sizes]/); + }); + + describe('resource budget validation', () => { + it('throws when an invalid resource type is supplied', () => { + budget[0].resourceSizes[0].resourceType = 'movies'; + assert.throws(_ => Budget.initializeBudget(budget), /Invalid resource type/); + }); + + it('throws when an invalid budget is supplied', () => { + budget[0].resourceSizes[0].budget = '100 MB'; + assert.throws(_ => Budget.initializeBudget(budget), /Invalid budget/); + }); + + it('throws when an invalid property is supplied', () => { + budget[0].resourceSizes[0].browser = 'Chrome'; + assert.throws(_ => Budget.initializeBudget(budget), /[browser]/); + }); + }); + + describe('timing budget validation', () => { + it('throws when an invalid metric is supplied', () => { + budget[0].timings[0].metric = 'lastMeaningfulPaint'; + assert.throws(_ => Budget.initializeBudget(budget), /Invalid timing metric/); + }); + + it('throws when an invalid budget is supplied', () => { + budget[0].timings[0].budget = '100KB'; + assert.throws(_ => Budget.initializeBudget(budget), /Invalid budget/); + }); + + it('throws when an invalid tolerance is supplied', () => { + budget[0].timings[0].tolerance = '100ms'; + assert.throws(_ => Budget.initializeBudget(budget), /Invalid tolerance/); + }); + + it('throws when an invalid property is supplied', () => { + budget[0].timings[0].device = 'Phone'; + assert.throws(_ => Budget.initializeBudget(budget), /[device]/); + }); + }); +}); diff --git a/lighthouse-core/test/config/config-test.js b/lighthouse-core/test/config/config-test.js index efeefb736485..c3c29045b09f 100644 --- a/lighthouse-core/test/config/config-test.js +++ b/lighthouse-core/test/config/config-test.js @@ -741,6 +741,30 @@ describe('Config', () => { assert.strictEqual(config.categories['lighthouse-plugin-simple'].title, 'Simple'); }); + describe('budget setting', () => { + it('should be initialized', () => { + const configJson = { + settings: { + budgets: [{ + resourceCounts: [{ + resourceType: 'image', + budget: 500, + }], + }], + }, + }; + const config = new Config(configJson); + assert.equal(config.settings.budgets[0].resourceCounts.length, 1); + assert.equal(config.settings.budgets[0].resourceCounts[0].resourceType, 'image'); + assert.equal(config.settings.budgets[0].resourceCounts[0].budget, 500); + }); + + it('should throw when provided an invalid budget', () => { + assert.throws(() => new Config({settings: {budgets: ['invalid123']}}), + /Budget has unrecognized properties/); + }); + }); + it('should load plugins from the config and from passed-in flags', () => { const baseConfigJson = { audits: ['installable-manifest'], diff --git a/lighthouse-core/test/fixtures/simple-budget.json b/lighthouse-core/test/fixtures/simple-budget.json new file mode 100644 index 000000000000..e8f3030e67b4 --- /dev/null +++ b/lighthouse-core/test/fixtures/simple-budget.json @@ -0,0 +1,45 @@ +[ + { + "resourceSizes": [ + { + "resourceType": "script", + "budget": 125 + }, + { + "resourceType": "image", + "budget": 300 + }, + { + "resourceType": "total", + "budget": 500 + }, + { + "resourceType": "third-party", + "budget": 200 + } + ], + "timings": [ + { + "metric": "interactive", + "budget": 5000, + "tolerance": 1000 + }, + { + "metric": "first-meaningful-paint", + "budget": 2000 + } + ] + }, + { + "resourceCounts": [ + { + "resourceType": "total", + "budget": 100 + }, + { + "resourceType": "third-party", + "budget": 0 + } + ] + } +] diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json index 0aef7b229206..c5a3d08948bc 100644 --- a/lighthouse-core/test/results/artifacts/artifacts.json +++ b/lighthouse-core/test/results/artifacts/artifacts.json @@ -29,6 +29,33 @@ "disableStorageReset": false, "emulatedFormFactor": "mobile", "channel": "cli", + "budgets": [ + { + "resourceSizes": [ + { + "resourceType": "script", + "budget": 125 + }, + { + "resourceType": "total", + "budget": 500 + } + ], + "timings": [ + { + "metric": "interactive", + "budget": 5000, + "tolerance": 1000 + } + ], + "resourceCounts": [ + { + "resourceType": "third-party", + "budget": 0 + } + ] + } + ], "locale": "en-US", "blockedUrlPatterns": null, "additionalTraceCategories": null, diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json index b9e493af335c..ff31c029d842 100644 --- a/lighthouse-core/test/results/sample_v2.json +++ b/lighthouse-core/test/results/sample_v2.json @@ -2963,6 +2963,7 @@ "disableStorageReset": false, "emulatedFormFactor": "mobile", "channel": "cli", + "budgets": null, "locale": "en-US", "blockedUrlPatterns": null, "additionalTraceCategories": null, diff --git a/types/budget.d.ts b/types/budget.d.ts new file mode 100644 index 000000000000..b1da9e8de36c --- /dev/null +++ b/types/budget.d.ts @@ -0,0 +1,49 @@ +/** + * @license Copyright 2019 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. + */ + +declare global { + module LH { + /** + * The performance budget interface. + * More info: https://github.com/GoogleChrome/lighthouse/issues/6053#issuecomment-428385930 + */ + export interface Budget { + /** Budgets based on resource count. */ + resourceCounts?: Array; + /** Budgets based on resource size. */ + resourceSizes?: Array; + /** Budgets based on timing metrics. */ + timings?: Array ; + } + + module Budget { + export interface ResourceBudget { + /** The resource type that a budget applies to. */ + resourceType: ResourceType; + /** Budget for resource. Depending on context, this is either the count or size (KB) of a resource. */ + budget: number; + } + + export interface TimingBudget { + /** The type of timing metric. */ + metric: TimingMetric; + /** Budget for timing measurement, in milliseconds. */ + budget: number; + /** Tolerance, i.e. buffer, to apply to a timing budget. Units: milliseconds. */ + tolerance?: number; + } + + /** Supported timing metrics. */ + export type TimingMetric = 'first-contentful-paint' | 'first-cpu-idle' | 'interactive' | 'first-meaningful-paint' | 'estimated-input-latency'; + + /** Supported resource types. */ + export type ResourceType = 'stylesheet' | 'image' | 'media' | 'font' | 'script' | 'document' | 'other'; + } + } +} + +// empty export to keep file a module +export {} diff --git a/types/externs.d.ts b/types/externs.d.ts index 8de82bbb0252..0b7780e13e8d 100644 --- a/types/externs.d.ts +++ b/types/externs.d.ts @@ -119,6 +119,8 @@ declare global { channel?: string /** Precomputed lantern estimates to use instead of observed analysis. */ precomputedLanternData?: PrecomputedLanternData | null; + /** The budget.json object for LightWallet. */ + budgets?: Array | null; } /** @@ -169,6 +171,8 @@ declare global { precomputedLanternDataPath?: string; /** Path to the file where precomputed lantern data should be written to. */ lanternDataOutputPath?: string; + /** Path to the budget.json file for LightWallet. */ + budgetPath?: string | null; // The following are given defaults in cli-flags, so are not optional like in Flags or SharedFlagsSettings. output: OutputMode[];