diff --git a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap index e6033bb2d710..d1e45ec5d810 100644 --- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap +++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap @@ -120,6 +120,9 @@ Object { Object { "path": "offline-start-url", }, + Object { + "path": "performance-budget", + }, Object { "path": "resource-summary", }, @@ -814,6 +817,10 @@ Object { "id": "font-display", "weight": 0, }, + Object { + "id": "performance-budget", + "weight": 0, + }, Object { "group": "diagnostics", "id": "resource-summary", @@ -1043,6 +1050,10 @@ Object { "description": "These are opportunities to to improve the experience of reading tabular or list data using assistive technology, like a screen reader.", "title": "Tables and lists", }, + "budgets": Object { + "description": "Performance budgets set standards for the performance of your site.", + "title": "Budgets", + }, "diagnostics": Object { "description": "More information about the performance of your application.", "title": "Diagnostics", @@ -1286,6 +1297,10 @@ Object { "description": "These are opportunities to to improve the experience of reading tabular or list data using assistive technology, like a screen reader.", "title": "Tables and lists", }, + "budgets": Object { + "description": "Performance budgets set standards for the performance of your site.", + "title": "Budgets", + }, "diagnostics": Object { "description": "More information about the performance of your application.", "title": "Diagnostics", diff --git a/lighthouse-core/audits/performance-budget.js b/lighthouse-core/audits/performance-budget.js new file mode 100644 index 000000000000..f47f4f1dec62 --- /dev/null +++ b/lighthouse-core/audits/performance-budget.js @@ -0,0 +1,148 @@ +/** + * @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 Audit = require('./audit.js'); +const ResourceSummary = require('../computed/resource-summary.js'); +const i18n = require('../lib/i18n/i18n.js'); + +const UIStrings = { + /** Title of a Lighthouse audit that compares the size and quantity of page resources against targets set by the user. These targets are thought of as "performance budgets" because these metrics impact page performance (i.e. how quickly a page loads). */ + title: 'Performance budget', + /** Description of a Lighthouse audit where a user sets budgets for the quantity and size of page resources. No character length limits. */ + description: 'Keep the quantity and size of network requests under the targets ' + + 'set by the provided performance budget.', + /** [ICU Syntax] Entry in a data table identifying the number of network requests of a particular type. Count will be a whole number. String should be as short as possible to be able to fit well into the table. */ + requestCountOverBudget: `{count, plural, + =1 {1 request} + other {# requests} + }`, +}; + +const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings); + +/** @typedef {{count: number, size: number}} ResourceEntry */ +/** @typedef {{resourceType: LH.Budget.ResourceType, label: string, requestCount: number, size: number, sizeOverBudget: number | undefined, countOverBudget: string | undefined}} BudgetItem */ + +class ResourceBudget extends Audit { + /** + * @return {LH.Audit.Meta} + */ + static get meta() { + return { + id: 'performance-budget', + title: str_(UIStrings.title), + description: str_(UIStrings.description), + scoreDisplayMode: Audit.SCORING_MODES.INFORMATIVE, + requiredArtifacts: ['devtoolsLogs', 'URL'], + }; + } + + /** + * @param {LH.Budget.ResourceType} resourceType + * @return {string} + */ + static getRowLabel(resourceType) { + /** @type {Record} */ + const strMappings = { + 'total': i18n.UIStrings.totalResourceType, + 'document': i18n.UIStrings.documentResourceType, + 'script': i18n.UIStrings.scriptResourceType, + 'stylesheet': i18n.UIStrings.stylesheetResourceType, + 'image': i18n.UIStrings.imageResourceType, + 'media': i18n.UIStrings.mediaResourceType, + 'font': i18n.UIStrings.fontResourceType, + 'other': i18n.UIStrings.otherResourceType, + 'third-party': i18n.UIStrings.thirdPartyResourceType, + }; + return strMappings[resourceType]; + } + + /** + * @param {LH.Budget} budget + * @param {Record} summary + * @return {Array} + */ + static tableItems(budget, summary) { + const resourceTypes = /** @type {Array} */ (Object.keys(summary)); + return resourceTypes.map((resourceType) => { + const label = str_(this.getRowLabel(resourceType)); + const requestCount = summary[resourceType].count; + const size = summary[resourceType].size; + + let sizeOverBudget; + let countOverBudget; + + if (budget.resourceSizes) { + const sizeBudget = budget.resourceSizes.find(b => b.resourceType === resourceType); + if (sizeBudget && (size > (sizeBudget.budget * 1024))) { + sizeOverBudget = size - (sizeBudget.budget * 1024); + } + } + if (budget.resourceCounts) { + const countBudget = budget.resourceCounts.find(b => b.resourceType === resourceType); + if (countBudget && (requestCount > countBudget.budget)) { + const requestDifference = requestCount - countBudget.budget; + countOverBudget = str_(UIStrings.requestCountOverBudget, {count: requestDifference}); + } + } + return { + resourceType, + label, + requestCount, + size, + countOverBudget, + sizeOverBudget, + }; + }).filter((row) => { + // Only resources with budgets should be included in the table + if (budget.resourceSizes) { + if (budget.resourceSizes.some(b => b.resourceType === row.resourceType)) return true; + } + if (budget.resourceCounts) { + if (budget.resourceCounts.some(b => b.resourceType === row.resourceType)) return true; + } + return false; + }).sort((a, b) => { + return (b.sizeOverBudget || 0) - (a.sizeOverBudget || 0); + }); + } + + /** + * @param {LH.Artifacts} artifacts + * @param {LH.Audit.Context} context + * @return {Promise} + */ + 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; + + if (!budget) { + return { + score: 0, + notApplicable: true, + }; + } + + /** @type { LH.Audit.Details.Table['headings'] } */ + const headers = [ + {key: 'label', itemType: 'text', text: 'Resource Type'}, + {key: 'requestCount', itemType: 'numeric', text: 'Requests'}, + {key: 'size', itemType: 'bytes', text: 'Transfer Size'}, + {key: 'countOverBudget', itemType: 'text', text: ''}, + {key: 'sizeOverBudget', itemType: 'bytes', text: 'Over Budget'}, + ]; + + return { + details: Audit.makeTableDetails(headers, this.tableItems(budget, summary)), + score: 1, + }; + } +} + +module.exports = ResourceBudget; +module.exports.UIStrings = UIStrings; diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js index f896cd423fb9..9184e54928f1 100644 --- a/lighthouse-core/config/default-config.js +++ b/lighthouse-core/config/default-config.js @@ -13,6 +13,10 @@ const i18n = require('../lib/i18n/i18n.js'); const UIStrings = { /** Title of the Performance category of audits. Equivalent to 'Web performance', this term is inclusive of all web page speed and loading optimization topics. Also used as a label of a score gauge; try to limit to 20 characters. */ performanceCategoryTitle: 'Performance', + /** Title of the Budgets section of the Performance Category. 'Budgets' refers to a budget (like a financial budget), but applied to the amount of resources on a page, rather than money. */ + budgetsGroupTitle: 'Budgets', + /** Description of the Budgets section of the Performance category. Within this section the budget results are displayed. */ + budgetsGroupDescription: 'Performance budgets set standards for the performance of your site.', /** Title of the speed metrics section of the Performance category. Within this section are various speed metrics which quantify the pageload performance into values presented in seconds and milliseconds. */ metricGroupTitle: 'Metrics', /** Title of the opportunity section of the Performance category. Within this section are audits with imperative titles that suggest actions the user can take to improve the loading performance of their web page. 'Suggestion'/'Optimization'/'Recommendation' are reasonable synonyms for 'opportunity' in this case. */ @@ -192,6 +196,7 @@ const defaultConfig = { 'main-thread-tasks', 'metrics', 'offline-start-url', + 'performance-budget', 'resource-summary', 'manual/pwa-cross-browser', 'manual/pwa-page-transitions', @@ -287,6 +292,10 @@ const defaultConfig = { title: str_(UIStrings.loadOpportunitiesGroupTitle), description: str_(UIStrings.loadOpportunitiesGroupDescription), }, + 'budgets': { + title: str_(UIStrings.budgetsGroupTitle), + description: str_(UIStrings.budgetsGroupDescription), + }, 'diagnostics': { title: str_(UIStrings.diagnosticsGroupTitle), description: str_(UIStrings.diagnosticsGroupDescription), @@ -379,6 +388,7 @@ const defaultConfig = { {id: 'bootup-time', weight: 0, group: 'diagnostics'}, {id: 'mainthread-work-breakdown', weight: 0, group: 'diagnostics'}, {id: 'font-display', weight: 0, group: 'diagnostics'}, + {id: 'performance-budget', weight: 0}, {id: 'resource-summary', weight: 0, group: 'diagnostics'}, // Audits past this point don't belong to a group and will not be shown automatically {id: 'network-requests', weight: 0}, diff --git a/lighthouse-core/lib/i18n/en-US.json b/lighthouse-core/lib/i18n/en-US.json index 0f82ba362b6a..e40e4f0483bd 100644 --- a/lighthouse-core/lib/i18n/en-US.json +++ b/lighthouse-core/lib/i18n/en-US.json @@ -743,6 +743,18 @@ "message": "Server Backend Latencies", "description": "Descriptive title of a Lighthouse audit that tells the user the server latencies observed from each origin the page connected to. This is displayed in a list of audit titles that Lighthouse generates." }, + "lighthouse-core/audits/performance-budget.js | description": { + "message": "Keep the quantity and size of network requests under the targets set by the provided performance budget.", + "description": "Description of a Lighthouse audit where a user sets budgets for the quantity and size of page resources. No character length limits." + }, + "lighthouse-core/audits/performance-budget.js | requestCountOverBudget": { + "message": "{count, plural,\n =1 {1 request}\n other {# requests}\n }", + "description": "[ICU Syntax] Entry in a data table identifying the number of network requests of a particular type. Count will be a whole number. String should be as short as possible to be able to fit well into the table." + }, + "lighthouse-core/audits/performance-budget.js | title": { + "message": "Performance budget", + "description": "Title of a Lighthouse audit that compares the size and quantity of page resources against targets set by the user. These targets are thought of as \"performance budgets\" because these metrics impact page performance (i.e. how quickly a page loads)." + }, "lighthouse-core/audits/redirects.js | description": { "message": "Redirects introduce additional delays before the page can be loaded. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/redirects).", "description": "Description of a Lighthouse audit that tells users why they should reduce the number of server-side redirects on their page. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation." @@ -1115,6 +1127,14 @@ "message": "Tables and lists", "description": "Title of the navigation section within the Accessibility category. Within this section are audits with descriptive titles that highlight opportunities to improve the experience of reading tabular or list data using assistive technology." }, + "lighthouse-core/config/default-config.js | budgetsGroupDescription": { + "message": "Performance budgets set standards for the performance of your site.", + "description": "Description of the Budgets section of the Performance category. Within this section the budget results are displayed." + }, + "lighthouse-core/config/default-config.js | budgetsGroupTitle": { + "message": "Budgets", + "description": "Title of the Budgets section of the Performance Category. 'Budgets' refers to a budget (like a financial budget), but applied to the amount of resources on a page, rather than money." + }, "lighthouse-core/config/default-config.js | diagnosticsGroupDescription": { "message": "More information about the performance of your application.", "description": "Description of the diagnostics section of the Performance category. Within this section are audits with non-imperative titles that provide more detail on the page's page load performance characteristics. Whereas the 'Opportunities' suggest an action along with expected time savings, diagnostics do not. Within this section, the user may read the details and deduce additional actions they could take." diff --git a/lighthouse-core/test/audits/performance-budget-test.js b/lighthouse-core/test/audits/performance-budget-test.js new file mode 100644 index 000000000000..6cb3f09afbef --- /dev/null +++ b/lighthouse-core/test/audits/performance-budget-test.js @@ -0,0 +1,164 @@ +/** + * @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 ResourceBudgetAudit = require('../../audits/performance-budget.js'); +const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js'); + +/* eslint-env jest */ + +describe('Performance: Resource budgets audit', () => { + let artifacts; + let context; + beforeEach(() => { + artifacts = { + devtoolsLogs: { + defaultPass: networkRecordsToDevtoolsLog([ + {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30}, + {url: 'http://example.com/app.js', resourceType: 'Script', transferSize: 10}, + {url: 'http://third-party.com/script.js', resourceType: 'Script', transferSize: 50}, + {url: 'http://third-party.com/file.jpg', resourceType: 'Image', transferSize: 70}, + ]), + }, + URL: {requestedURL: 'http://example.com', finalURL: 'http://example.com'}, + }; + context = {computedCache: new Map(), settings: {}}; + }); + + describe('with a budget.json', () => { + beforeEach(() => { + context.settings.budgets = [{ + resourceSizes: [ + { + resourceType: 'script', + budget: 0, + }, + { + resourceType: 'image', + budget: 1000, + }, + ], + resourceCounts: [ + { + resourceType: 'script', + budget: 0, + }, + { + resourceType: 'image', + budget: 1000, + }, + ], + }]; + }); + + it('includes table columns for requet & file size overages', async () => { + const result = await ResourceBudgetAudit.audit(artifacts, context); + expect(result.details.headings).toHaveLength(5); + }); + + it('table item information is correct', async () => { + const result = await ResourceBudgetAudit.audit(artifacts, context); + const item = result.details.items[0]; + expect(item.label).toBeDisplayString('Script'); + expect(item.requestCount).toBe(2); + expect(item.size).toBe(60); + expect(item.sizeOverBudget).toBe(60); + expect(item.countOverBudget).toBeDisplayString('2 requests'); + }); + + describe('request & transfer size overage', () => { + it('are displayed', async () => { + const result = await ResourceBudgetAudit.audit(artifacts, context); + const scriptRow = result.details.items.find(r => r.resourceType === 'script'); + expect(scriptRow.sizeOverBudget).toBe(60); + expect(scriptRow.countOverBudget).toBeDisplayString('2 requests'); + }); + + it('are empty for passing budgets', async () => { + const result = await ResourceBudgetAudit.audit(artifacts, context); + const imageRow = result.details.items.find(r => r.resourceType === 'image'); + expect(imageRow.sizeOverBudget).toBeUndefined(); + expect(imageRow.countOverBudget).toBeUndefined(); + }); + + it('convert budgets from kilobytes to bytes during calculations', async () => { + context.settings.budgets = [{ + resourceSizes: [ + { + resourceType: 'document', + budget: 20, + }, + ], + }]; + const result = await ResourceBudgetAudit.audit(artifacts, context); + expect(result.details.items[0].siveOverBudget).toBeUndefined(); + }); + }); + + it('only includes rows for resource types with budgets', async () => { + const result = await ResourceBudgetAudit.audit(artifacts, context); + expect(result.details.items).toHaveLength(2); + }); + + it('sorts rows by descending file size overage', async () => { + context.settings.budgets = [{ + resourceSizes: [ + { + resourceType: 'document', + budget: 0, + }, + { + resourceType: 'script', + budget: 0, + }, + { + resourceType: 'image', + budget: 0, + }, + ], + }]; + const result = await ResourceBudgetAudit.audit(artifacts, context); + const items = result.details.items; + items.slice(0, -1).forEach((item, index) => { + 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('without a budget.json', () => { + beforeEach(() => { + context.settings.budgets = null; + }); + + it('audit does not apply', async () => { + const result = await ResourceBudgetAudit.audit(artifacts, context); + expect(result.details).toBeUndefined(); + expect(result.notApplicable).toBe(true); + }); + }); +}); diff --git a/lighthouse-core/test/report/html/renderer/report-renderer-test.js b/lighthouse-core/test/report/html/renderer/report-renderer-test.js index d6d8e6a987f5..efa3966da230 100644 --- a/lighthouse-core/test/report/html/renderer/report-renderer-test.js +++ b/lighthouse-core/test/report/html/renderer/report-renderer-test.js @@ -265,8 +265,10 @@ describe('ReportRenderer', () => { const container = renderer._dom._document.body; const reportElement = renderer.renderReport(sampleResults, container); + // TODO(khempenius): Remove "+1" once budgets renderer code is added. + // Until budgets renderer code is added, JSON vs. DOM comparison will differ by 1. const notApplicableElementCount = reportElement .querySelectorAll('.lh-audit--notapplicable').length; - assert.strictEqual(notApplicableCount, notApplicableElementCount); + assert.strictEqual(notApplicableCount, notApplicableElementCount + 1); }); }); diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json index ada4c2fae507..c045c7cddc02 100644 --- a/lighthouse-core/test/results/sample_v2.json +++ b/lighthouse-core/test/results/sample_v2.json @@ -1273,6 +1273,13 @@ "explanation": "No usable web app manifest found on page.", "warnings": [] }, + "performance-budget": { + "id": "performance-budget", + "title": "Performance budget", + "description": "Keep the quantity and size of network requests under the targets set by the provided performance budget.", + "score": null, + "scoreDisplayMode": "notApplicable" + }, "resource-summary": { "id": "resource-summary", "title": "Keep request counts low and transfer sizes small", @@ -3200,6 +3207,10 @@ "weight": 0, "group": "diagnostics" }, + { + "id": "performance-budget", + "weight": 0 + }, { "id": "resource-summary", "weight": 0, @@ -3704,6 +3715,10 @@ "title": "Opportunities", "description": "These optimizations can speed up your page load." }, + "budgets": { + "title": "Budgets", + "description": "Performance budgets set standards for the performance of your site." + }, "diagnostics": { "title": "Diagnostics", "description": "More information about the performance of your application." @@ -4174,7 +4189,7 @@ }, { "startTime": 0, - "name": "lh:audit:resource-summary", + "name": "lh:audit:performance-budget", "duration": 100, "entryType": "measure" }, @@ -4184,6 +4199,12 @@ "duration": 100, "entryType": "measure" }, + { + "startTime": 0, + "name": "lh:audit:resource-summary", + "duration": 100, + "entryType": "measure" + }, { "startTime": 0, "name": "lh:audit:pwa-cross-browser", @@ -4960,6 +4981,12 @@ "lighthouse-core/audits/network-server-latency.js | description": [ "audits[network-server-latency].description" ], + "lighthouse-core/audits/performance-budget.js | title": [ + "audits[performance-budget].title" + ], + "lighthouse-core/audits/performance-budget.js | description": [ + "audits[performance-budget].description" + ], "lighthouse-core/audits/resource-summary.js | title": [ "audits[resource-summary].title" ], @@ -5497,6 +5524,12 @@ "lighthouse-core/config/default-config.js | loadOpportunitiesGroupDescription": [ "categoryGroups[load-opportunities].description" ], + "lighthouse-core/config/default-config.js | budgetsGroupTitle": [ + "categoryGroups.budgets.title" + ], + "lighthouse-core/config/default-config.js | budgetsGroupDescription": [ + "categoryGroups.budgets.description" + ], "lighthouse-core/config/default-config.js | diagnosticsGroupTitle": [ "categoryGroups.diagnostics.title" ], diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json index 1af6af0d7551..d9f5ba95c53b 100644 --- a/proto/sample_v2_round_trip.json +++ b/proto/sample_v2_round_trip.json @@ -1100,7 +1100,7 @@ "name": "WordPress" } ], - "summary": null, + "summary": {}, "type": "table" }, "id": "js-libraries", @@ -1227,7 +1227,7 @@ "details": { "headings": [], "items": [], - "summary": null, + "summary": {}, "type": "table" }, "id": "link-text", @@ -1883,7 +1883,7 @@ "vulnCount": 2.0 } ], - "summary": null, + "summary": {}, "type": "table" }, "displayValue": "2 vulnerabilities detected", @@ -2031,6 +2031,13 @@ "scoreDisplayMode": "binary", "title": "Prevents users to paste into password fields" }, + "performance-budget": { + "description": "Keep the quantity and size of network requests under the targets set by the provided performance budget.", + "id": "performance-budget", + "score": null, + "scoreDisplayMode": "notApplicable", + "title": "Performance budget" + }, "plugins": { "description": "Search engines can't index plugin content, and many devices restrict plugins or don't support them. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/plugins).", "details": { @@ -3436,6 +3443,10 @@ "id": "font-display", "weight": 0.0 }, + { + "id": "performance-budget", + "weight": 0.0 + }, { "group": "diagnostics", "id": "resource-summary", @@ -3671,6 +3682,10 @@ "description": "These are opportunities to to improve the experience of reading tabular or list data using assistive technology, like a screen reader.", "title": "Tables and lists" }, + "budgets": { + "description": "Performance budgets set standards for the performance of your site.", + "title": "Budgets" + }, "diagnostics": { "description": "More information about the performance of your application.", "title": "Diagnostics" @@ -4178,7 +4193,7 @@ { "duration": 100.0, "entryType": "measure", - "name": "lh:audit:resource-summary", + "name": "lh:audit:performance-budget", "startTime": 0.0 }, { @@ -4187,6 +4202,12 @@ "name": "lh:computed:ResourceSummary", "startTime": 0.0 }, + { + "duration": 100.0, + "entryType": "measure", + "name": "lh:audit:resource-summary", + "startTime": 0.0 + }, { "duration": 100.0, "entryType": "measure",