diff --git a/lighthouse-cli/test/smokehouse/perf/perf-config.js b/lighthouse-cli/test/smokehouse/perf/perf-config.js index f253a05022d3..be96152df19d 100644 --- a/lighthouse-cli/test/smokehouse/perf/perf-config.js +++ b/lighthouse-cli/test/smokehouse/perf/perf-config.js @@ -14,6 +14,7 @@ const perfConfig = { // A mixture of under, over, and meeting budget to exercise all paths. budgets: [{ + path: '/', resourceCounts: [ {resourceType: 'total', budget: 8}, {resourceType: 'stylesheet', budget: 1}, // meets budget diff --git a/lighthouse-core/audits/performance-budget.js b/lighthouse-core/audits/performance-budget.js index 9d520df6f2eb..14f357adde74 100644 --- a/lighthouse-core/audits/performance-budget.js +++ b/lighthouse-core/audits/performance-budget.js @@ -7,6 +7,8 @@ const Audit = require('./audit.js'); const ResourceSummary = require('../computed/resource-summary.js'); +const MainResource = require('../computed/main-resource.js'); +const Budget = require('../config/budget.js'); const i18n = require('../lib/i18n/i18n.js'); const UIStrings = { @@ -121,7 +123,14 @@ class ResourceBudget extends Audit { static async audit(artifacts, context) { const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS]; const summary = await ResourceSummary.request({devtoolsLog, URL: artifacts.URL}, context); - const budget = context.settings.budgets ? context.settings.budgets[0] : undefined; + const mainResource = await MainResource.request({URL: artifacts.URL, devtoolsLog}, context); + // Clones budget so that the user-supplied version is not mutated. + /** @type {Array} */ + const budgets = Array.from(context.settings.budgets || []); + // Applies the LAST matching budget + const budget = budgets ? budgets.reverse().find((b) => { + return Budget.urlMatchesPattern(mainResource.url, b.path); + }) : undefined; if (!budget) { return { @@ -130,7 +139,7 @@ class ResourceBudget extends Audit { }; } - /** @type { LH.Audit.Details.Table['headings'] } */ + /** @type {LH.Audit.Details.Table['headings']} */ const headers = [ {key: 'label', itemType: 'text', text: str_(i18n.UIStrings.columnResourceType)}, {key: 'requestCount', itemType: 'numeric', text: str_(i18n.UIStrings.columnRequests)}, diff --git a/lighthouse-core/config/budget.js b/lighthouse-core/config/budget.js index 0f5d1553aa32..91b85c97885f 100644 --- a/lighthouse-core/config/budget.js +++ b/lighthouse-core/config/budget.js @@ -96,6 +96,98 @@ class Budget { }; } + /** + * @param {unknown} path + * @param {string} error + */ + static throwInvalidPathError(path, error) { + throw new Error(`Invalid path ${path}. ${error}\n` + + `'Path' should be specified using the 'robots.txt' format.\n` + + `Learn more about the 'robots.txt' format here:\n` + + `https://developers.google.com/search/reference/robots_txt#url-matching-based-on-path-values`); + } + + + /** + * Validates that path is either: a) undefined or ) properly formed. + * Verifies the quantity and location of the two robot.txt regex characters: $, * + * @param {unknown} path + * @return {undefined|string} + */ + static validatePath(path) { + if (path === undefined) { + return undefined; + } else if (typeof path !== 'string') { + this.throwInvalidPathError(path, `Path should be a string.`); + return; + } else if (!path.startsWith('/')) { + this.throwInvalidPathError(path, `Path should start with '/'.`); + } else if ((path.match(/\*/g) || []).length > 1) { + this.throwInvalidPathError(path, `Path should only contain one '*'.`); + } else if ((path.match(/\$/g) || []).length > 1) { + this.throwInvalidPathError(path, `Path should only contain one '$' character.`); + } else if (path.includes('$') && !path.endsWith('$')) { + this.throwInvalidPathError(path, `'$' character should only occur at end of path.`); + } + return path; + } + + /** + * Determines whether a URL matches against a robots.txt-style "path". + * Pattern should use the robots.txt format. E.g. "/*-article.html" or "/". Reference: + * https://developers.google.com/search/reference/robots_txt#url-matching-based-on-path-values + * @param {string} url + * @param {string=} pattern + * @return {boolean} + */ + static urlMatchesPattern(url, pattern = '/') { + const urlObj = new URL(url); + const urlPath = urlObj.pathname + urlObj.search; + + const hasWildcard = pattern.includes('*'); + const hasDollarSign = pattern.includes('$'); + + /** + * There are 4 different cases of path strings. + * Paths should have already been validated with #validatePath. + * + * Case #1: No special characters + * Example: "/cat" + * Behavior: URL should start with given pattern. + */ + if (!hasWildcard && !hasDollarSign) { + return urlPath.startsWith(pattern); + /** + * Case #2: $ only + * Example: "/js$" + * Behavior: URL should be identical to pattern. + */ + } else if (!hasWildcard && hasDollarSign) { + return urlPath === pattern.slice(0, -1); + /** + * Case #3: * only + * Example: "/vendor*chunk" + * Behavior: URL should start with the string pattern that comes before the wildcard + * & later in the string contain the string pattern that comes after the wildcard. + */ + } else if (hasWildcard && !hasDollarSign) { + const [beforeWildcard, afterWildcard] = pattern.split('*'); + const remainingUrl = urlPath.slice(beforeWildcard.length); + return urlPath.startsWith(beforeWildcard) && remainingUrl.includes(afterWildcard); + /** + * Case #4: $ and * + * Example: "/vendor*chunk.js$" + * Behavior: URL should start with the string pattern that comes before the wildcard + * & later in the string end with the string pattern that comes after the wildcard. + */ + } else if (hasWildcard && hasDollarSign) { + const [beforeWildcard, afterWildcard] = pattern.split('*'); + const urlEnd = urlPath.slice(beforeWildcard.length); + return urlPath.startsWith(beforeWildcard) && urlEnd.endsWith(afterWildcard.slice(0, -1)); + } + return false; + } + /** * @param {Record} timingBudget * @return {LH.Budget.TimingBudget} @@ -147,9 +239,11 @@ class Budget { /** @type {LH.Budget} */ const budget = {}; - const {resourceSizes, resourceCounts, timings, ...invalidRest} = b; + const {path, resourceSizes, resourceCounts, timings, ...invalidRest} = b; Budget.assertNoExcessProperties(invalidRest, 'Budget'); + budget.path = Budget.validatePath(path); + if (isArrayOfUnknownObjects(resourceSizes)) { budget.resourceSizes = resourceSizes.map(Budget.validateResourceBudget); Budget.assertNoDuplicateStrings(budget.resourceSizes.map(r => r.resourceType), diff --git a/lighthouse-core/test/audits/performance-budget-test.js b/lighthouse-core/test/audits/performance-budget-test.js index 6cb3f09afbef..1aafcdb1948a 100644 --- a/lighthouse-core/test/audits/performance-budget-test.js +++ b/lighthouse-core/test/audits/performance-budget-test.js @@ -23,7 +23,7 @@ describe('Performance: Resource budgets audit', () => { {url: 'http://third-party.com/file.jpg', resourceType: 'Image', transferSize: 70}, ]), }, - URL: {requestedURL: 'http://example.com', finalURL: 'http://example.com'}, + URL: {requestedUrl: 'http://example.com', finalUrl: 'http://example.com'}, }; context = {computedCache: new Map(), settings: {}}; }); @@ -31,6 +31,7 @@ describe('Performance: Resource budgets audit', () => { describe('with a budget.json', () => { beforeEach(() => { context.settings.budgets = [{ + path: '/', resourceSizes: [ { resourceType: 'script', @@ -86,6 +87,7 @@ describe('Performance: Resource budgets audit', () => { it('convert budgets from kilobytes to bytes during calculations', async () => { context.settings.budgets = [{ + path: '/', resourceSizes: [ { resourceType: 'document', @@ -98,6 +100,13 @@ describe('Performance: Resource budgets audit', () => { }); }); + it('does not mutate the budget config', async () => { + const configBefore = JSON.parse(JSON.stringify(context.settings.budgets)); + await ResourceBudgetAudit.audit(artifacts, context); + const configAfter = JSON.parse(JSON.stringify(context.settings.budgets)); + expect(configBefore).toEqual(configAfter); + }); + it('only includes rows for resource types with budgets', async () => { const result = await ResourceBudgetAudit.audit(artifacts, context); expect(result.details.items).toHaveLength(2); @@ -105,6 +114,7 @@ describe('Performance: Resource budgets audit', () => { it('sorts rows by descending file size overage', async () => { context.settings.budgets = [{ + path: '/', resourceSizes: [ { resourceType: 'document', @@ -126,27 +136,54 @@ describe('Performance: Resource budgets audit', () => { expect(item.size).toBeGreaterThanOrEqual(items[index + 1].size); }); }); - - it('uses the first budget in budgets', async () => { - context.settings.budgets = [{ - resourceSizes: [ - { - resourceType: 'image', - budget: 0, - }, - ], - }, - { - resourceSizes: [ - { - resourceType: 'script', - budget: 0, - }, - ], - }, - ]; - const result = await ResourceBudgetAudit.audit(artifacts, context); - expect(result.details.items[0].resourceType).toBe('image'); + describe('budget path', () => { + it('applies the last matching budget', async () => { + context.settings.budgets = [{ + path: '/', + resourceSizes: [ + { + resourceType: 'script', + budget: 0, + }, + ], + }, + { + path: '/file.html', + resourceSizes: [ + { + resourceType: 'image', + budget: 0, + }, + ], + }, + { + path: '/not-a-match', + resourceSizes: [ + { + resourceType: 'document', + budget: 0, + }, + ], + }, + ]; + const result = await ResourceBudgetAudit.audit(artifacts, context); + expect(result.details.items[0].resourceType).toBe('image'); + }); + it('returns "audit does not apply" if no budget matches', async () => { + context.settings.budgets = [{ + path: '/not-a-match', + resourceSizes: [ + { + resourceType: 'script', + budget: 0, + }, + ], + }, + ]; + const result = await ResourceBudgetAudit.audit(artifacts, context); + expect(result.details).toBeUndefined(); + expect(result.notApplicable).toBe(true); + }); }); }); diff --git a/lighthouse-core/test/config/budget-test.js b/lighthouse-core/test/config/budget-test.js index 20b4843e84c4..ab3a5c40b5b5 100644 --- a/lighthouse-core/test/config/budget-test.js +++ b/lighthouse-core/test/config/budget-test.js @@ -10,9 +10,9 @@ const assert = require('assert'); /* eslint-env jest */ describe('Budget', () => { - let budget; + let budgets; beforeEach(() => { - budget = [ + budgets = [ { resourceSizes: [ { @@ -48,6 +48,7 @@ describe('Budget', () => { ], }, { + path: '/second-path', resourceSizes: [ { resourceType: 'script', @@ -59,136 +60,254 @@ describe('Budget', () => { }); it('initializes correctly', () => { - const budgets = Budget.initializeBudget(budget); - assert.equal(budgets.length, 2); + const result = Budget.initializeBudget(budgets); + assert.equal(result.length, 2); + + // Missing paths are not overwritten + assert.equal(result[0].path, undefined); + // Sets path correctly + assert.equal(result[1].path, '/second-path'); // 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); + assert.equal(result[0].resourceSizes.length, 2); + assert.equal(result[0].resourceSizes[0].resourceType, 'script'); + assert.equal(result[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); + assert.equal(result[0].resourceCounts.length, 2); + assert.equal(result[0].resourceCounts[0].resourceType, 'total'); + assert.equal(result[0].resourceCounts[0].budget, 100); // Sets timings correctly - assert.equal(budgets[0].timings.length, 2); - assert.deepStrictEqual(budgets[0].timings[0], { + assert.equal(result[0].timings.length, 2); + assert.deepStrictEqual(result[0].timings[0], { metric: 'interactive', budget: 2000, tolerance: 1000, }); - assert.equal(budgets[0].timings[1].metric, 'first-contentful-paint'); - assert.equal(budgets[0].timings[1].budget, 1000); + assert.deepStrictEqual(result[0].timings[1], { + metric: 'first-contentful-paint', + budget: 1000, + tolerance: undefined, + }); - // Does not set unsupplied budgets - assert.equal(budgets[1].timings, null); + // Does not set unsupplied result + assert.equal(result[1].timings, null); }); it('accepts an empty array', () => { - const budgets = Budget.initializeBudget([]); - assert.deepStrictEqual(budgets, []); + const result = Budget.initializeBudget([]); + assert.deepStrictEqual(result, []); }); it('throws error if an unsupported budget property is used', () => { - budget[0].sizes = []; - assert.throws(_ => Budget.initializeBudget(budget), + budgets[0].sizes = []; + assert.throws(_ => Budget.initializeBudget(budgets), /Budget has unrecognized properties: \[sizes\]/); }); describe('top-level validation', () => { it('throws when provided an invalid budget array', () => { assert.throws(_ => Budget.initializeBudget(55), - /Budget file is not defined as an array of budgets/); + /Budget file is not defined as an array of/); assert.throws(_ => Budget.initializeBudget(['invalid123']), - /Budget file is not defined as an array of budgets/); + /Budget file is not defined as an array of/); assert.throws(_ => Budget.initializeBudget([null]), - /Budget file is not defined as an array of budgets/); + /Budget file is not defined as an array of/); }); it('throws when budget contains invalid resourceSizes entry', () => { - budget[0].resourceSizes = 55; - assert.throws(_ => Budget.initializeBudget(budget), + budgets[0].resourceSizes = 55; + assert.throws(_ => Budget.initializeBudget(budgets), /^Error: Invalid resourceSizes entry in budget at index 0$/); }); it('throws when budget contains invalid resourceCounts entry', () => { - budget[0].resourceCounts = 'A string'; - assert.throws(_ => Budget.initializeBudget(budget), + budgets[0].resourceCounts = 'A string'; + assert.throws(_ => Budget.initializeBudget(budgets), /^Error: Invalid resourceCounts entry in budget at index 0$/); }); it('throws when budget contains invalid timings entry', () => { - budget[1].timings = false; - assert.throws(_ => Budget.initializeBudget(budget), + budgets[1].timings = false; + assert.throws(_ => Budget.initializeBudget(budgets), /^Error: Invalid timings entry in budget at index 1$/); }); }); describe('resource budget validation', () => { it('throws when an invalid resource type is supplied', () => { - budget[0].resourceSizes[0].resourceType = 'movies'; - assert.throws(_ => Budget.initializeBudget(budget), + budgets[0].resourceSizes[0].resourceType = 'movies'; + assert.throws(_ => Budget.initializeBudget(budgets), // eslint-disable-next-line max-len /Invalid resource type: movies. \nValid resource types are: total, document,/); }); it('throws when an invalid budget is supplied', () => { - budget[0].resourceSizes[0].budget = '100 MB'; - assert.throws(_ => Budget.initializeBudget(budget), /Invalid budget: 100 MB/); + budgets[0].resourceSizes[0].budget = '100 MB'; + assert.throws(_ => Budget.initializeBudget(budgets), /Invalid budget: 100 MB/); }); it('throws when an invalid property is supplied', () => { - budget[0].resourceSizes[0].browser = 'Chrome'; - assert.throws(_ => Budget.initializeBudget(budget), + budgets[0].resourceSizes[0].browser = 'Chrome'; + assert.throws(_ => Budget.initializeBudget(budgets), /Resource Budget has unrecognized properties: \[browser\]/); }); it('throws when a duplicate resourceType is specified in resourceSizes', () => { - budget[1].resourceSizes.push({resourceType: 'script', budget: 100}); - assert.throws(_ => Budget.initializeBudget(budget), - /budgets\[1\]\.resourceSizes has duplicate entry of type 'script'/); + budgets[1].resourceSizes.push({resourceType: 'script', budget: 100}); + assert.throws(_ => Budget.initializeBudget(budgets), + /has duplicate entry of type 'script'/); }); it('throws when a duplicate resourceType is specified in resourceCounts', () => { - budget[0].resourceCounts.push({resourceType: 'third-party', budget: 100}); - assert.throws(_ => Budget.initializeBudget(budget), - /budgets\[0\]\.resourceCounts has duplicate entry of type 'third-party'/); + budgets[0].resourceCounts.push({resourceType: 'third-party', budget: 100}); + assert.throws(_ => Budget.initializeBudget(budgets), + /has duplicate entry of type 'third-party'/); }); }); describe('timing budget validation', () => { it('throws when an invalid metric is supplied', () => { - budget[0].timings[0].metric = 'medianMeaningfulPaint'; - assert.throws(_ => Budget.initializeBudget(budget), + budgets[0].timings[0].metric = 'medianMeaningfulPaint'; + assert.throws(_ => Budget.initializeBudget(budgets), // eslint-disable-next-line max-len /Invalid timing metric: medianMeaningfulPaint. \nValid timing metrics are: first-contentful-paint, /); }); it('throws when an invalid budget is supplied', () => { - budget[0].timings[0].budget = '100KB'; - assert.throws(_ => Budget.initializeBudget(budget), /Invalid budget: 100KB/); + budgets[0].timings[0].budget = '100KB'; + assert.throws(_ => Budget.initializeBudget(budgets), /Invalid budget: 100KB/); }); it('throws when an invalid tolerance is supplied', () => { - budget[0].timings[0].tolerance = '100ms'; - assert.throws(_ => Budget.initializeBudget(budget), /Invalid tolerance: 100ms/); + budgets[0].timings[0].tolerance = '100ms'; + assert.throws(_ => Budget.initializeBudget(budgets), /Invalid tolerance: 100ms/); }); it('throws when an invalid property is supplied', () => { - budget[0].timings[0].device = 'Phone'; - budget[0].timings[0].location = 'The middle somewhere, I don\'t know'; - assert.throws(_ => Budget.initializeBudget(budget), + budgets[0].timings[0].device = 'Phone'; + budgets[0].timings[0].location = 'The middle somewhere, I don\'t know'; + assert.throws(_ => Budget.initializeBudget(budgets), /Timing Budget has unrecognized properties: \[device, location\]/); }); it('throws when a duplicate metric type is specified in timings', () => { - budget[0].timings.push({metric: 'interactive', budget: 1000}); - assert.throws(_ => Budget.initializeBudget(budget), - /budgets\[0\]\.timings has duplicate entry of type 'interactive'/); + budgets[0].timings.push({metric: 'interactive', budget: 1000}); + assert.throws(_ => Budget.initializeBudget(budgets), + /has duplicate entry of type 'interactive'/); + }); + }); + + describe('path validation', () => { + it('recognizes valid budgets', () => { + let budgets = [{path: '/'}]; + let result = Budget.initializeBudget(budgets); + assert.equal(budgets[0].path, result[0].path); + + budgets = [{path: '/*'}]; + result = Budget.initializeBudget(budgets); + assert.equal(budgets[0].path, result[0].path); + + budgets = [{path: '/end$'}]; + result = Budget.initializeBudget(budgets); + assert.equal(budgets[0].path, result[0].path); + + budgets = [{path: '/fish*.php'}]; + result = Budget.initializeBudget(budgets); + assert.equal(budgets[0].path, result[0].path); + + budgets = [{path: '/*.php$'}]; + result = Budget.initializeBudget(budgets); + assert.equal(budgets[0].path, result[0].path); + }); + + it('invalidates paths missing leading "/"', () => { + let budgets = [{path: ''}]; + assert.throws(_ => Budget.initializeBudget(budgets), /Invalid path/); + + budgets = [{path: 'cat'}]; + assert.throws(_ => Budget.initializeBudget(budgets), /Invalid path/); + }); + + it('invalidates paths with multiple * characters', () => { + budgets = [{path: '/cat*cat*cat'}]; + assert.throws(_ => Budget.initializeBudget(budgets), /Invalid path/); + }); + + it('invalidates paths with multiple $ characters', () => { + budgets = [{path: '/cat$cat$'}]; + assert.throws(_ => Budget.initializeBudget(budgets), /Invalid path/); + }); + + it('invalidates paths with $ character in the wrong location', () => { + budgets = [{path: '/cat$html'}]; + assert.throws(_ => Budget.initializeBudget(budgets), /Invalid path/); + }); + + it('does not throw if no path is specified', () => { + const budgets = [{}]; + const result = Budget.initializeBudget(budgets); + assert.equal(result[0].path, undefined); + }); + }); + + describe('path matching', () => { + const pathMatch = (path, pattern) => { + const origin = 'https://example.com'; + return Budget.urlMatchesPattern(origin + path, pattern); + }; + + it('matches root', () => { + assert.ok(Budget.urlMatchesPattern('https://google.com', '/')); + }); + + it('ignores origin', () => { + assert.equal(Budget.urlMatchesPattern('https://go.com/dogs', '/go'), false); + assert.equal(Budget.urlMatchesPattern('https://yt.com/videos?id=', '/videos'), true); + }); + + it('is case-sensitive', () => { + assert.equal(Budget.urlMatchesPattern('https://abc.com/aaa', '/aaa'), true); + assert.equal(Budget.urlMatchesPattern('https://abc.com/AAA', '/aaa'), false); + assert.equal(Budget.urlMatchesPattern('https://abc.com/aaa', '/AAA'), false); + }); + + it('matches all pages if path is not defined', () => { + assert.ok(Budget.urlMatchesPattern('https://example.com', undefined), true); + assert.ok(Budget.urlMatchesPattern('https://example.com/dogs', undefined), true); + }); + + it('handles patterns that do not contain * or $', () => { + assert.equal(pathMatch('/anything', '/'), true); + assert.equal(pathMatch('/anything', '/any'), true); + assert.equal(pathMatch('/anything', '/anything'), true); + assert.equal(pathMatch('/anything', '/anything1'), false); + }); + + it('handles patterns that do not contain * but contain $', () => { + assert.equal(pathMatch('/fish.php', '/fish.php$'), true); + assert.equal(pathMatch('/Fish.PHP', '/fish.php$'), false); + }); + + it('handles patterns that contain * but do not contain $', () => { + assert.equal(pathMatch('/anything', '/*'), true); + assert.equal(pathMatch('/fish', '/fish*'), true); + assert.equal(pathMatch('/fishfood', '/*food'), true); + assert.equal(pathMatch('/fish/food/and/other/things', '/*food'), true); + assert.equal(pathMatch('/fis/', '/fish*'), false); + assert.equal(pathMatch('/fish', '/fish*fish'), false); + }); + + it('handles patterns that contain * and $', () => { + assert.equal(pathMatch('/fish.php', '/*.php$'), true); + assert.equal(pathMatch('/folder/filename.php', '/folder*.php$'), true); + assert.equal(pathMatch('/folder/filename.php', '/folder/filename*.php$'), true); + assert.equal(pathMatch('/fish.php?species=', '/*.php$'), false); + assert.equal(pathMatch('/filename.php/', '/folder*.php$'), false); + assert.equal(pathMatch('/folder', '/folder*folder$'), false); }); }); }); diff --git a/lighthouse-core/test/config/config-test.js b/lighthouse-core/test/config/config-test.js index c3700008fe77..712fab886bee 100644 --- a/lighthouse-core/test/config/config-test.js +++ b/lighthouse-core/test/config/config-test.js @@ -796,6 +796,7 @@ describe('Config', () => { const configJson = { settings: { budgets: [{ + path: '/', resourceCounts: [{ resourceType: 'image', budget: 500, diff --git a/lighthouse-core/test/fixtures/simple-budget.json b/lighthouse-core/test/fixtures/simple-budget.json index e8f3030e67b4..0146f997735c 100644 --- a/lighthouse-core/test/fixtures/simple-budget.json +++ b/lighthouse-core/test/fixtures/simple-budget.json @@ -1,5 +1,6 @@ [ { + "path": "/", "resourceSizes": [ { "resourceType": "script", diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json index 871054f5d364..7c3f04e1811c 100644 --- a/lighthouse-core/test/results/artifacts/artifacts.json +++ b/lighthouse-core/test/results/artifacts/artifacts.json @@ -31,27 +31,108 @@ "channel": "cli", "budgets": [ { + "path": "/", "resourceSizes": [ + { + "resourceType": "total", + "budget": 100 + }, + { + "resourceType": "stylesheet", + "budget": 5 + }, + { + "resourceType": "image", + "budget": 30 + }, + { + "resourceType": "media", + "budget": 0 + }, + { + "resourceType": "font", + "budget": 20 + }, { "resourceType": "script", - "budget": 125 + "budget": 30 }, { - "resourceType": "total", - "budget": 500 + "resourceType": "document", + "budget": 15 + }, + { + "resourceType": "other", + "budget": 5 + }, + { + "resourceType": "third-party", + "budget": 25 } ], "resourceCounts": [ { - "resourceType": "third-party", + "resourceType": "total", + "budget": 10 + }, + { + "resourceType": "stylesheet", + "budget": 2 + }, + { + "resourceType": "image", + "budget": 2 + }, + { + "resourceType": "media", "budget": 0 + }, + { + "resourceType": "font", + "budget": 1 + }, + { + "resourceType": "script", + "budget": 2 + }, + { + "resourceType": "document", + "budget": 1 + }, + { + "resourceType": "other", + "budget": 2 + }, + { + "resourceType": "third-party", + "budget": 1 } ], "timings": [ + { + "metric": "first-contentful-paint", + "budget": 3000, + "tolerance": 100 + }, + { + "metric": "first-cpu-idle", + "budget": 2900, + "tolerance": 100 + }, { "metric": "interactive", - "budget": 5000, - "tolerance": 1000 + "budget": 2900, + "tolerance": 100 + }, + { + "metric": "first-meaningful-paint", + "budget": 2000, + "tolerance": 100 + }, + { + "metric": "max-potential-fid", + "budget": 100, + "tolerance": 100 } ] } diff --git a/lighthouse-core/test/results/sample-config.js b/lighthouse-core/test/results/sample-config.js index 8f63818ceebe..1a0272de1145 100644 --- a/lighthouse-core/test/results/sample-config.js +++ b/lighthouse-core/test/results/sample-config.js @@ -15,6 +15,7 @@ const budgetedConfig = { settings: { throttlingMethod: 'devtools', budgets: [{ + path: '/', resourceCounts: [ {resourceType: 'total', budget: 10}, {resourceType: 'stylesheet', budget: 2}, diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json index cd1b6ab82e27..184f5a353451 100644 --- a/lighthouse-core/test/results/sample_v2.json +++ b/lighthouse-core/test/results/sample_v2.json @@ -3264,6 +3264,7 @@ "channel": "cli", "budgets": [ { + "path": "/", "resourceSizes": [ { "resourceType": "total", diff --git a/types/budget.d.ts b/types/budget.d.ts index 4c1d058b21ae..b0387851d755 100644 --- a/types/budget.d.ts +++ b/types/budget.d.ts @@ -11,6 +11,12 @@ declare global { * More info: https://github.com/GoogleChrome/lighthouse/issues/6053#issuecomment-428385930 */ export interface Budget { + /** + * Indicates which pages a budget applies to. Uses the robots.txt format. + * If it is not supplied, the budget applies to all pages. + * More info on robots.txt: https://developers.google.com/search/reference/robots_txt#url-matching-based-on-path-values + */ + path?: string; /** Budgets based on resource count. */ resourceCounts?: Array; /** Budgets based on resource size. */