From 885838d4ea5abebe8986aa49a6db802625cf0021 Mon Sep 17 00:00:00 2001 From: Katie Hempenius Date: Fri, 4 Jan 2019 12:09:57 -0500 Subject: [PATCH] Add LightWallet --- lighthouse-core/audits/light-wallet.js | 332 ++++++++++++++++++ lighthouse-core/config/constants.js | 52 +++ lighthouse-core/config/default-config.js | 13 + .../config/default-light-wallet-budget.js | 43 +++ lighthouse-core/config/light-wallet-budget.js | 140 ++++++++ .../report/html/html-report-assets.js | 1 + .../html/renderer/light-wallet-renderer.js | 263 ++++++++++++++ .../renderer/performance-category-renderer.js | 32 +- .../report/html/renderer/report-renderer.js | 3 +- lighthouse-core/report/html/report-styles.css | 118 +++++++ lighthouse-core/report/html/templates.html | 75 ++++ lighthouse-core/test/config/config-test.js | 2 +- .../html/renderer/report-renderer-test.js | 2 + .../html/renderer/report-ui-features-test.js | 2 + types/html-renderer.d.ts | 3 + types/light-wallet.d.ts | 74 ++++ 16 files changed, 1137 insertions(+), 18 deletions(-) create mode 100644 lighthouse-core/audits/light-wallet.js create mode 100644 lighthouse-core/config/default-light-wallet-budget.js create mode 100644 lighthouse-core/config/light-wallet-budget.js create mode 100644 lighthouse-core/report/html/renderer/light-wallet-renderer.js create mode 100644 types/light-wallet.d.ts diff --git a/lighthouse-core/audits/light-wallet.js b/lighthouse-core/audits/light-wallet.js new file mode 100644 index 000000000000..a220bb824c61 --- /dev/null +++ b/lighthouse-core/audits/light-wallet.js @@ -0,0 +1,332 @@ +/** + * @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 Audit = require('./audit'); +const throttlingSettings = require('../config/constants').lightWalletThrottling; +const NetworkRecords = require('../computed/network-records.js'); +const ComputedFcp = require('../computed/metrics/first-contentful-paint.js'); +const ComputedFci = require('../computed/metrics/first-cpu-idle.js'); +const ComputedFmp = require('../computed/metrics/first-meaningful-paint.js'); +const Interactive = require('../computed/metrics/interactive.js'); +const i18n = require('../lib/i18n/i18n.js'); +const MainResource = require('../computed/main-resource.js'); +const URL = require('../lib/url-shim'); +const LightWalletBudget = require('../config/light-wallet-budget'); + +const UIStrings = { + /** LightWallet is the name of Lighthouse's performance budget feature. The name dervices from "Lighthouse" & "Wallet", because financial budgets are somewhat similar to performance budgets. */ + title: 'LightWallet', + /** Description of the LightWallet section, which evaluates whether the resources and page load times fall within the budgets set for the page. This is displayed under the LightWallet heading. No character length limits. */ + description: 'A performance budget sets thresholds for the resource sizes' + + ' and/or loading metrics of a page. This helps achieve and maintain performance goals.', + /** The name of the metric that marks the time at which the first text or image is painted by the browser. Shown to users as the label for the numeric metric value. Ideally fits within a ~40 character limit. */ + firstContentfulPaint: 'First Contentful Paint', + /** The name of the metric that marks when the page has displayed content and the CPU is not busy executing the page's scripts. Shown to users as the label for the numeric metric value. Ideally fits within a ~40 character limit. */ + firstCpuIdle: 'First CPU Idle', + /** The name of the metric that marks the time at which the page is fully loaded and is able to quickly respond to user input (clicks, taps, and keypresses feel responsive). Shown to users as the label for the numeric metric value. Ideally fits within a ~40 character limit. */ + timeToInteractive: 'Time to Interactive', + /** The name of the metric that marks the time at which a majority of the content has been painted by the browser. Shown to users as the label for the numeric metric value. Ideally fits within a ~40 character limit. */ + firstMeaningfulPaint: 'First Meaningful Paint', +}; + +const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings); + +class LightWallet extends Audit { + /** + * @return {LH.Audit.Meta} + */ + static get meta() { + return { + id: 'light-wallet', + title: str_(UIStrings.title), + description: str_(UIStrings.description), + scoreDisplayMode: Audit.SCORING_MODES.INFORMATIVE, + requiredArtifacts: ['devtoolsLogs'], + }; + } + + /** + * @param {LH.Artifacts.NetworkRequest} record + * @return string + */ + static determineResourceType(record) { + switch (record.resourceType) { + case 'Stylesheet': + case 'Image': + case 'Media': + case 'Font': + case 'Script': + case 'Document': + // budgets.json uses lowercase for resource types, unlike NetworkRequest.resourceType + return record.resourceType.toLowerCase(); + default: + return 'other'; + } + } + + /** + * @param {Array} results + * @return string + */ + static getScore(results) { + let hasWarnings = false; + for (const environment of results) { + const sections = [environment.timings, environment.requests, environment.pageWeight]; + for (const section of sections) { + for (const result of section || []) { + if (result.score === 'fail') return 'fail'; + if (result.score === 'warn') hasWarnings = true; + } + } + } + return hasWarnings ? 'warn' : 'pass'; + } + + /** + * @param {LH.LightWallet.TimingBudget} timingBudget + * @param {LH.Config.Settings} settings + * @param {LH.Artifacts} artifacts + * @param {LH.Audit.Context} context + * @return {Promise} + */ + static async auditTimingMetric(timingBudget, settings, artifacts, context) { + let fn; + let id; + let label; + let timingMeasurement; + let score; + + switch (timingBudget.metric) { + case 'firstContentfulPaint': + fn = ComputedFcp.request; + id = 'fcp'; + label = str_(UIStrings.firstContentfulPaint); + break; + case 'firstCpuIdle': + fn = ComputedFci.request; + id = 'fci'; + label = str_(UIStrings.firstCpuIdle); + break; + case 'timeToInteractive': + fn = Interactive.request; + id = 'tti'; + label = str_(UIStrings.timeToInteractive); + break; + case 'firstMeaningfulPaint': + fn = ComputedFmp.request; + id = 'fmp'; + label = str_(UIStrings.firstMeaningfulPaint); + break; + default: + throw new Error('Timing metric not found'); + } + + try { + const trace = artifacts.traces[Audit.DEFAULT_PASS]; + const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS]; + const metricComputationData = {trace, devtoolsLog, settings}; + + timingMeasurement = (await fn(metricComputationData, context)).timing; + } catch (err) { + throw new Error(err); + } + + switch (true) { + case (timingMeasurement < timingBudget.budget): + score = 'pass'; + break; + case (timingMeasurement < timingBudget.budget + (timingBudget.tolerance || 0)): + score = 'average'; + break; + default: + score = 'fail'; + break; + } + + /** @type {LH.LightWallet.TimingResult} */ + const result = { + id: id, + label: label, + budget: timingBudget.budget, + actual: timingMeasurement, + difference: timingMeasurement - timingBudget.budget, + score: score, + }; + + if (timingBudget.tolerance !== undefined) { + result.tolerance = timingBudget.tolerance; + } + + return result; + } + + /** + * @param {Array} networkRecords + * @param {string} mainResourceURL + * @return {Map} + */ + static requestCountsByResourceType(networkRecords, mainResourceURL) { + /** @type {Map} */ + const summary = new Map(); + networkRecords.forEach(record => { + const type = this.determineResourceType(record); + summary.set(type, (summary.get(type) || 0) + 1); + summary.set('total', (summary.get('total') || 0) + 1); + if (!URL.rootDomainsMatch(record.url, mainResourceURL)) { + summary.set('thirdParty', (summary.get('thirdParty') || 0) + 1); + } + }); + return summary; + } + + /** + * @param {Array} networkRecords + * @param {string} mainResourceURL + * @return {Map} + */ + static pageWeightByResourceType(networkRecords, mainResourceURL) { + /** @type {Map} */ + const summary = new Map(); + networkRecords.forEach(record => { + const type = this.determineResourceType(record); + const size = (summary.get(type) || 0) + record.transferSize; + summary.set(type, size); + const total = (summary.get('total') || 0) + record.transferSize; + summary.set('total', total); + if (!URL.rootDomainsMatch(record.url, mainResourceURL)) { + const thirdParty = (summary.get('thirdParty') || 0) + record.transferSize; + summary.set('thirdParty', thirdParty); + } + }); + return summary; + } + + /** + * @param {Array} pageWeightBudgets + * @param {Map} bytesSummary + * @return {Array|Array} + */ + static pageWeightAnalysis(pageWeightBudgets, bytesSummary) { + /** @type {Array} */ + return pageWeightBudgets.map(pageWeightBudget => { + // TODO: Double-check x1024 vs. x1000 + const budget = (pageWeightBudget.budget || 0) * 1024; + const actual = bytesSummary.get(pageWeightBudget.resourceType) || 0; + return { + id: pageWeightBudget.resourceType, + label: pageWeightBudget.resourceType, + budget: budget, + actual: actual, + difference: actual - budget, + score: actual > budget ? 'fail' : 'pass', + }; + }).sort(function(a, b) { + return b.difference - a.difference; + }); + } + + /** + * @param {Array} requestBudgets + * @param {Map} requestCounts + * @return {Array|Array} + */ + static requestAnalysis(requestBudgets, requestCounts) { + /** @type {Array} */ + return requestBudgets.map(requestBudget => { + const actual = requestCounts.get(requestBudget.resourceType) || 0; + return { + id: requestBudget.resourceType, + // TODO: Localize? + label: requestBudget.resourceType, + budget: requestBudget.budget, + actual: actual, + difference: actual - requestBudget.budget, + score: actual > requestBudget.budget ? 'fail' : 'pass', + }; + }).sort(function(a, b) { + return b.difference - a.difference; + }); + } + + /** + * @param {Array} timingBudgets + * @param {LH.Config.Settings} settings + * @param {LH.Artifacts} artifacts + * @param {LH.Audit.Context} context + * @return {Promise>} + */ + static async timingAnalysis(timingBudgets, settings, artifacts, context) { + /** @type {Array} */ + const results = await Promise.all(timingBudgets.map(async (timingBudget) => { + return (await this.auditTimingMetric(timingBudget, settings, artifacts, context)); + })); + return results.sort(function(a, b) { + return b.difference - a.difference; + }); + } + + /** + * @param {LH.LightWallet.Budget} budget + * @param {LH.Artifacts} artifacts + * @param {LH.Audit.Context} context + * @return {Promise} + */ + static async performanceAudit(budget, artifacts, context) { + const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS]; + const networkRecords = await NetworkRecords.request(devtoolsLog, context); + const mainResource = await (MainResource.request({devtoolsLog, URL: artifacts.URL}, context)); + + /** @type {LH.LightWallet.Result} */ + const result = { + cpuThrottling: budget.cpuThrottling, + connectionType: throttlingSettings[budget.connectionType], + }; + if (budget.timings !== undefined) { + const throttling = throttlingSettings[budget.connectionType]; + throttling.cpuSlowdownMultiplier = budget.cpuThrottling; + + const settingOverrides = {throttlingMethod: 'simulate', throttling: throttling}; + const settings = Object.assign({}, context.settings, settingOverrides); + + result.timings = await this.timingAnalysis(budget.timings, settings, artifacts, context); + } + + if (budget.requests !== undefined) { + const requestSummary = this.requestCountsByResourceType(networkRecords, mainResource.url); + result.requests = this.requestAnalysis(budget.requests, requestSummary); + } + + if (budget.pageWeight !== undefined) { + const pageWeightSummary = this.pageWeightByResourceType(networkRecords, mainResource.url); + result.pageWeight = this.pageWeightAnalysis(budget.pageWeight, pageWeightSummary); + } + return result; + } + + /** + * @param {LH.Artifacts} artifacts + * @param {LH.Audit.Context} context + * @return {Promise} + */ + static async audit(artifacts, context) { + /** @type { LH.LightWallet.Json } */ + const config = new LightWalletBudget(); + + /** @type {Array} */ + const results = await Promise.all(config.budgets.map(async (budget) => { + return await this.performanceAudit(budget, artifacts, context); + })); + + return { + rawValue: null, + displayValue: this.getScore(results), + details: results, + }; + } +} + +module.exports = LightWallet; diff --git a/lighthouse-core/config/constants.js b/lighthouse-core/config/constants.js index 46d19987f17e..07362aaf1fa6 100644 --- a/lighthouse-core/config/constants.js +++ b/lighthouse-core/config/constants.js @@ -26,6 +26,57 @@ const throttling = { }, }; +const lightWalletThrottling = { + slow3G: { + rttMs: 400, + throughputKbps: 0.4 * 1024, + requestLatencyMs: 400 * DEVTOOLS_RTT_ADJUSTMENT_FACTOR, + downloadThroughputKbps: 0.4 * 1024 * DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR, + uploadThroughputKbps: 0.4 * 1024 * DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR, + networkLabel: 'Slow 3G', + }, + regular3G: { + rttMs: 300, + throughputKbps: 1.6 * 1024, + requestLatencyMs: 300 * DEVTOOLS_RTT_ADJUSTMENT_FACTOR, + downloadThroughputKbps: 1.6 * 1024 * DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR, + uploadThroughputKbps: 0.8 * DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR, + networkLabel: '3G', + }, + fast3G: { + rttMs: 150, + throughputKbps: 1.6 * 1024, + requestLatencyMs: 150 * DEVTOOLS_RTT_ADJUSTMENT_FACTOR, + downloadThroughputKbps: 1.6 * 1024 * DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR, + uploadThroughputKbps: 0.8 * DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR, + networkLabel: 'Fast 3G', + }, + slow4G: { + rttMs: 150, + throughputKbps: 1.6 * 1024, + requestLatencyMs: 150 * DEVTOOLS_RTT_ADJUSTMENT_FACTOR, + downloadThroughputKbps: 1.6 * 1024 * DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR, + uploadThroughputKbps: 0.8 * DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR, + networkLabel: 'Slow 4G', + }, + regular4G: { + rttMs: 170, + throughputKbps: 9 * 1024, + requestLatencyMs: 170 * DEVTOOLS_RTT_ADJUSTMENT_FACTOR, + downloadThroughputKbps: 9 * 1024 * DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR, + uploadThroughputKbps: 9 * 1024 * DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR, + networkLabel: '4G', + }, + wifi: { + rttMs: 28, + throughputKbps: 5 * 1024, + requestLatencyMs: 28 * DEVTOOLS_RTT_ADJUSTMENT_FACTOR, + downloadThroughputKbps: 5 * 1024 * DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR, + uploadThroughputKbps: 1000 * DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR, + networkLabel: 'Wifi', + }, +}; + /** @type {LH.Config.Settings} */ const defaultSettings = { output: 'json', @@ -73,4 +124,5 @@ module.exports = { defaultSettings, defaultPassConfig, nonSimulatedPassConfigOverrides, + lightWalletThrottling, }; diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js index 59821c6e370c..b97d848890d9 100644 --- a/lighthouse-core/config/default-config.js +++ b/lighthouse-core/config/default-config.js @@ -11,6 +11,11 @@ const constants = require('./constants'); const i18n = require('../lib/i18n/i18n.js'); const UIStrings = { + /** Title of the LightWallet category of audits. LightWallet is a tool for performance budgets. Performance budgets are somewhat similar to financial budgets, hence the name ('Lighthouse' + 'Wallet'). */ + lightWalletCategoryTitle: 'LightWallet', + /** Description of the LightWallet category. This section audits that a page meets performance budget(s). */ + lightWalletCategoryDescription: 'A performance budget sets thresholds for the resource sizes' + + ' and/or loading metrics of a page. This helps achieve and maintain performance goals.', /** 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 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. */ @@ -144,6 +149,7 @@ const defaultConfig = { 'metrics/speed-index', 'screenshot-thumbnails', 'final-screenshot', + 'light-wallet', 'metrics/estimated-input-latency', 'errors-in-console', 'time-to-first-byte', @@ -320,6 +326,13 @@ const defaultConfig = { }, }, categories: { + 'lightwallet': { + title: str_(UIStrings.lightWalletCategoryTitle), + description: str_(UIStrings.lightWalletCategoryDescription), + auditRefs: [ + {id: 'light-wallet', weight: 1}, + ], + }, 'performance': { title: str_(UIStrings.performanceCategoryTitle), auditRefs: [ diff --git a/lighthouse-core/config/default-light-wallet-budget.js b/lighthouse-core/config/default-light-wallet-budget.js new file mode 100644 index 000000000000..db8104bb6359 --- /dev/null +++ b/lighthouse-core/config/default-light-wallet-budget.js @@ -0,0 +1,43 @@ +/** + * @license Copyright 2018 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'; + +/** @type {LH.LightWallet.Json} */ +const lightWalletConfig = { + budgets: [{ + cpuThrottling: 1, + connectionType: 'wifi', + pageWeight: [ + { + resourceType: 'total', + budget: 750, + }, + { + resourceType: 'script', + budget: 300, + }, + { + resourceType: 'image', + budget: 250, + }, + { + resourceType: 'font', + budget: 100, + }, + { + resourceType: 'stylesheet', + budget: 50, + }, + { + resourceType: 'document', + budget: 50, + }, + ], + }, + ], +}; + +module.exports = lightWalletConfig; diff --git a/lighthouse-core/config/light-wallet-budget.js b/lighthouse-core/config/light-wallet-budget.js new file mode 100644 index 000000000000..9de8460dde1c --- /dev/null +++ b/lighthouse-core/config/light-wallet-budget.js @@ -0,0 +1,140 @@ +/** + * @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 fs = require('fs'); +const defaultLightWalletBudget = require('./default-light-wallet-budget.js'); + +class LightWalletBudget { + /** + * @constructor + * @implements {LH.LightWallet.Json} + */ + constructor() { + /** @type {LH.LightWallet.Json} */ + let budgetsJSON; + + if (fs.existsSync('./budgets.json')) { + try { + budgetsJSON = JSON.parse(fs.readFileSync('./budgets.json', {encoding: 'utf8'})); + } catch (err) { + throw new Error(`Invalid budgets.json. ${err}`); + } + } else { + budgetsJSON = JSON.parse(JSON.stringify(defaultLightWalletBudget)); + } + + /** @type {Array} */ + this.budgets = []; + + if (budgetsJSON.budgets === undefined) { + throw new Error('Invalid budgets.json'); + } + + budgetsJSON.budgets.forEach((b) => { + const cpuThrottling = b.cpuThrottling; + + if (typeof cpuThrottling !== 'number' || cpuThrottling < 1 || cpuThrottling > 4) { + throw new Error('A valid CPU Throttling must be specified.'); + } + + const connectionTypes = [ + 'slow3G', + 'regular3G', + 'fast3G', + 'slow4G', + 'regular4G', + 'wifi', + ]; + + if (b.connectionType === undefined) { + throw new Error('A connection type must be specified.'); + } else if (connectionTypes.indexOf(b.connectionType) === -1) { + throw new Error(`Invalid connection type: ${b.connectionType}`); + } + + /** @type {LH.LightWallet.Budget} */ + const budget = { + cpuThrottling: cpuThrottling, + connectionType: b.connectionType, + }; + + const resourceTypes = [ + 'total', + 'document', + 'script', + 'stylesheet', + 'image', + 'media', + 'font', + 'other', + 'thirdParty' + ]; + + if (b.pageWeight !== undefined) { + /** @type {Array} */ + budget.pageWeight = b.pageWeight.map((r) => { + if (resourceTypes.indexOf(r.resourceType) === -1) { + throw new Error(`Invalid resource type: ${r.resourceType}`); + } + return { + resourceType: r.resourceType, + budget: r.budget, + }; + }); + } + + if (b.requests !== undefined) { + /** @type {Array} */ + budget.requests = b.requests.map((r) => { + if (resourceTypes.indexOf(r.resourceType) === -1) { + throw new Error(`Invalid resource type: ${r.resourceType}`); + } + return { + resourceType: r.resourceType, + budget: r.budget, + }; + }); + } + + const metrics = [ + 'firstContentfulPaint', + 'firstCpuIdle', + 'timeToInteractive', + 'firstMeaningfulPaint', + ]; + + if (b.timings !== undefined) { + /** @type {Array} */ + budget.timings = b.timings.map((t) => { + if (metrics.indexOf(t.metric) === -1) { + throw new Error(`Invalid timing metric: ${t.metric}`); + } + + if (t.budget === undefined) { + throw new Error(`Missing budget for: ${t.metric}`); + } + + if (t.tolerance !== undefined) { + return { + metric: t.metric, + budget: t.budget, + tolerance: t.tolerance, + }; + } else { + return { + metric: t.metric, + budget: t.budget, + }; + } + }); + } + this.budgets.push(budget); + }); + } +} + +module.exports = LightWalletBudget; diff --git a/lighthouse-core/report/html/html-report-assets.js b/lighthouse-core/report/html/html-report-assets.js index 8ac7d203ea42..d29e017772ab 100644 --- a/lighthouse-core/report/html/html-report-assets.js +++ b/lighthouse-core/report/html/html-report-assets.js @@ -23,6 +23,7 @@ const REPORT_JAVASCRIPT = [ fs.readFileSync(__dirname + '/renderer/performance-category-renderer.js', 'utf8'), fs.readFileSync(__dirname + '/renderer/pwa-category-renderer.js', 'utf8'), fs.readFileSync(__dirname + '/renderer/report-renderer.js', 'utf8'), + fs.readFileSync(__dirname + '/renderer/light-wallet-renderer.js', 'utf8'), ].join(';\n'); const REPORT_CSS = fs.readFileSync(__dirname + '/report-styles.css', 'utf8'); const REPORT_TEMPLATES = fs.readFileSync(__dirname + '/templates.html', 'utf8'); diff --git a/lighthouse-core/report/html/renderer/light-wallet-renderer.js b/lighthouse-core/report/html/renderer/light-wallet-renderer.js new file mode 100644 index 000000000000..ad2cba7d925e --- /dev/null +++ b/lighthouse-core/report/html/renderer/light-wallet-renderer.js @@ -0,0 +1,263 @@ +/** + * @license + * Copyright 2018 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'; + +/* globals self, Util, CategoryRenderer */ + +/** @typedef {import('./dom.js')} DOM */ + +class LightWalletRenderer extends CategoryRenderer { + /** + * @param {LH.ReportResult.Category} category + * @return {DocumentFragment} + */ + renderScoreGauge(category) { + const tmpl = this.dom.cloneTemplate('#tmpl-lh-gauge--light-wallet', this.templateContext); + const wrapper = /** @type {HTMLAnchorElement} */ (this.dom.find('.lh-lw__gauge-wrapper', + tmpl)); + wrapper.href = `#${category.id}`; + + const score = category.auditRefs[0].result.displayValue; + wrapper.classList.add(`lh-gauge__wrapper--${score}`); + this.dom.find('.lh-lw__gauge-label', tmpl).textContent = category.title; + return tmpl; + } + + /** + * + * @param {number} value + * @param {number} maxValue + * @return {string} + */ + sparklineWidthStr(value, maxValue) { + const width = (value * 100 / maxValue) > 2 ? (value * 100 / maxValue) : 2; + return `${width}%`; + } + + /** + * + * @param {number} difference + * @return {string} + */ + plusOrMinusStr(difference) { + if (difference === 0) return ''; + return difference > 0 ? '+ ' : '- '; + } + + /** + * @param {string} rowType + * @param {LH.LightWallet.ResourceResult} result + * @param {number} sparklineScale + * @return {Element} + */ + renderResourceRow(rowType, result, sparklineScale) { + if (rowType !== 'pageWeight' && rowType !== 'requests') throw new Error('Invalid row type'); + + const tmplForRow = this.dom.cloneTemplate('#tmpl-lw-resources-table', this.templateContext); + const row = this.dom.find('.lh-metric', tmplForRow); + + row.id = `light-wallet--${result.id}`; + + const titleEl = this.dom.find('.lh-lw__resource-col--description', tmplForRow); + titleEl.textContent = result.label.charAt(0).toUpperCase() + result.label.slice(1); + + const budgetEl = this.dom.find('.lh-lw__resource-col--budget', tmplForRow); + budgetEl.textContent = rowType === 'pageWeight' ? + Util.formatBytesToKB(result.budget) : `${result.budget} requests`; + + const actualEl = this.dom.find('.lh-lw__resource-col--actual', tmplForRow); + actualEl.textContent = rowType === 'pageWeight' ? + Util.formatBytesToKB(result.actual) : `${result.actual} requests`; + + if (result.score === 'fail') { + const width = this.sparklineWidthStr(result.difference, sparklineScale); + this.dom.find('.lh-sparkline__bar', tmplForRow).style.width = width; + } + + const differenceEl = this.dom.find('.lh-lw__resource-col--difference', tmplForRow); + + const prefix = this.plusOrMinusStr(result.difference); + const absDifference = Math.abs(result.difference); + const formattedNumber = rowType === 'pageWeight' ? + // TODO Localize + Util.formatBytesToKB(absDifference) : + (absDifference !== 1 ? `${absDifference} requests` : `1 request`); + differenceEl.textContent = `${prefix}${formattedNumber}`; + + row.classList.add(`lh-audit--${result.score}`); + + return row; + } + + /** + * @param {LH.LightWallet.TimingResult} result + * @param {number} sparklineScale + * @return {Element} + */ + renderTimingRow(result, sparklineScale) { + const tmplForRow = this.dom.cloneTemplate('#tmpl-lw-timings-table', this.templateContext); + const row = this.dom.find('.lh-metric', tmplForRow); + + row.id = `light-wallet--${result.id}`; + + const titleEl = this.dom.find('.lh-lw__timings-col--description', tmplForRow); + titleEl.textContent = result.label; + + const budgetEl = this.dom.find('.lh-lw__timings-col--budget-value', tmplForRow); + budgetEl.textContent = Util.formatSeconds(result.budget); + + if (result.tolerance !== undefined) { + const toleranceEl = this.dom.find('.lh-lw__timings-col--budget-tolerance', tmplForRow); + // TODO: Localize + toleranceEl.textContent = Util.formatMilliseconds(result.tolerance) + ' tolerance'; + } + + const actualEl = this.dom.find('.lh-lw__timings-col--actual', tmplForRow); + actualEl.textContent = Util.formatSeconds(result.actual); + + if (result.score === 'fail') { + const width = this.sparklineWidthStr(result.difference, sparklineScale); + this.dom.find('.lh-sparkline__bar', tmplForRow).style.width = width; + } + + const differenceEl = this.dom.find('.lh-lw__timings-col--difference', tmplForRow); + const prefix = this.plusOrMinusStr(result.difference); + const formattedNumber = Util.formatSeconds(Math.abs(result.difference)); + differenceEl.textContent = `${prefix}${formattedNumber}`; + + row.classList.add(`lh-audit--${result.score}`); + return row; + } + + /** + * @param {LH.LightWallet.Result} result + * @return {Element} + */ + renderSectionHeader(result) { + const tmpl = this.dom.cloneTemplate('#tmpl-lw-section-header', this.templateContext); + const header = this.dom.find('.lh-lw__section-header', tmpl); + // TODO: Localize + const ordinal = result.cpuThrottling === 1 ? 'No' : `${result.cpuThrottling}x`; + header.textContent = `${result.connectionType.networkLabel}; ${ordinal} CPU Throttling`; + return header; + } + + /** +* @param {Array} timingResults +* @return {Element} +*/ + renderTimingsTable(timingResults) { + const table = this.dom.createElement('div', 'lh-lw__timings-table'); + const tmpl = this.dom.cloneTemplate('#tmpl-lw-timings-table', this.templateContext); + const header = this.dom.find('.lh-lw__timings-header', tmpl); + table.appendChild(header); + + const maxDifference = this.maxDifference(timingResults); + for (const result of timingResults) { + const row = this.renderTimingRow(result, maxDifference); + table.appendChild(row); + } + return table; + } + + /** +* @param {Array} weights +* @return {Element} +*/ + renderPageWeightTable(weights) { + const table = this.dom.createElement('div', 'lh-lw__resources-table'); + const tmpl = this.dom.cloneTemplate('#tmpl-lw-resources-table', this.templateContext); + const header = this.dom.find('.lh-lw__resources-header', tmpl); + table.appendChild(header); + + const maxDifference = this.maxDifference(weights); + for (const result of weights) { + const row = this.renderResourceRow('pageWeight', result, maxDifference); + table.appendChild(row); + } + return table; + } + + /** +* @param {Array} results +* @return {number} +*/ + maxDifference(results) { + let max = 0; + for (const result of results) { + if (result.difference > max) max = result.difference; + } + return max; + } + + /** +* @param {Array} results +* @return {Element} +*/ + renderRequestsTable(results) { + const table = this.dom.createElement('div', 'lh-lw__resources-table'); + const tmpl = this.dom.cloneTemplate('#tmpl-lw-resources-table', this.templateContext); + const tableHeader = this.dom.find('.lh-lw__resources-header', tmpl); + table.appendChild(tableHeader); + + const maxDifference = this.maxDifference(results); + for (const result of results) { + const row = this.renderResourceRow('requests', result, maxDifference); + table.appendChild(row); + } + return table; + } + + /** + * @param {LH.ReportResult.Category} category + * @param {Object} groups + * @return {Element} + * @override + */ + render(category, groups) { + const categoryElement = this.dom.createElement('div', 'lh-category'); + this.createPermalinkSpan(categoryElement, category.id); + categoryElement.appendChild(this.renderCategoryHeader(category, groups)); + + const lightWalletAudit = category.auditRefs[0]; + const lightWalletEl = this.dom.createChildOf(categoryElement, 'div', 'lh-lw'); + + console.log(lightWalletAudit.result); + for (const profile of lightWalletAudit.result.details) { + lightWalletEl.appendChild(this.renderSectionHeader(profile)); + + if (profile.pageWeight && profile.pageWeight.length > 0) { + lightWalletEl.appendChild(this.renderPageWeightTable(profile.pageWeight)); + } + + if (profile.requests && profile.requests.length > 0) { + lightWalletEl.appendChild(this.renderRequestsTable(profile.requests)); + } + + if (profile.timings && profile.timings.length > 0) { + lightWalletEl.appendChild(this.renderTimingsTable(profile.timings)); + } + } + return categoryElement; + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = LightWalletRenderer; +} else { + self.LightWalletRenderer = LightWalletRenderer; +} diff --git a/lighthouse-core/report/html/renderer/performance-category-renderer.js b/lighthouse-core/report/html/renderer/performance-category-renderer.js index 252b667016bb..5db28c423a9d 100644 --- a/lighthouse-core/report/html/renderer/performance-category-renderer.js +++ b/lighthouse-core/report/html/renderer/performance-category-renderer.js @@ -129,7 +129,7 @@ class PerformanceCategoryRenderer extends CategoryRenderer { // Metrics const metricAudits = category.auditRefs.filter(audit => audit.group === 'metrics'); - const metricAuditsEl = this.renderAuditGroup(groups.metrics, {expandable: false}); + const metricAuditsEl = this.renderAuditGroup(groups.metrics, { expandable: false }); const keyMetrics = metricAudits.filter(a => a.weight >= 3); const otherMetrics = metricAudits.filter(a => a.weight < 3); @@ -148,7 +148,7 @@ class PerformanceCategoryRenderer extends CategoryRenderer { // 'Values are estimated and may vary' is used as the category description for PSI if (environment !== 'PSI') { const estValuesEl = this.dom.createChildOf(metricsColumn2El, 'div', - 'lh-metrics__disclaimer lh-metrics__disclaimer'); + 'lh-metrics__disclaimer lh-metrics__disclaimer'); estValuesEl.textContent = Util.UIStrings.varianceDisclaimer; } @@ -167,8 +167,8 @@ class PerformanceCategoryRenderer extends CategoryRenderer { // Opportunities const opportunityAudits = category.auditRefs - .filter(audit => audit.group === 'load-opportunities' && !Util.showAsPassed(audit.result)) - .sort((auditA, auditB) => this._getWastedMs(auditB) - this._getWastedMs(auditA)); + .filter(audit => audit.group === 'load-opportunities' && !Util.showAsPassed(audit.result)) + .sort((auditA, auditB) => this._getWastedMs(auditB) - this._getWastedMs(auditA)); if (opportunityAudits.length) { // Scale the sparklines relative to savings, minimum 2s to not overstate small savings @@ -176,7 +176,7 @@ class PerformanceCategoryRenderer extends CategoryRenderer { const wastedMsValues = opportunityAudits.map(audit => this._getWastedMs(audit)); const maxWaste = Math.max(...wastedMsValues); const scale = Math.max(Math.ceil(maxWaste / 1000) * 1000, minimumScale); - const groupEl = this.renderAuditGroup(groups['load-opportunities'], {expandable: false}); + const groupEl = this.renderAuditGroup(groups['load-opportunities'], { expandable: false }); const tmpl = this.dom.cloneTemplate('#tmpl-lh-opportunity-header', this.templateContext); this.dom.find('.lh-load-opportunity__col--one', tmpl).textContent = @@ -187,22 +187,22 @@ class PerformanceCategoryRenderer extends CategoryRenderer { const headerEl = this.dom.find('.lh-load-opportunity__header', tmpl); groupEl.appendChild(headerEl); opportunityAudits.forEach((item, i) => - groupEl.appendChild(this._renderOpportunity(item, i, scale))); + groupEl.appendChild(this._renderOpportunity(item, i, scale))); groupEl.classList.add('lh-audit-group--load-opportunities'); element.appendChild(groupEl); } // Diagnostics const diagnosticAudits = category.auditRefs - .filter(audit => audit.group === 'diagnostics' && !Util.showAsPassed(audit.result)) - .sort((a, b) => { - const scoreA = a.result.scoreDisplayMode === 'informative' ? 100 : Number(a.result.score); - const scoreB = b.result.scoreDisplayMode === 'informative' ? 100 : Number(b.result.score); - return scoreA - scoreB; - }); + .filter(audit => audit.group === 'diagnostics' && !Util.showAsPassed(audit.result)) + .sort((a, b) => { + const scoreA = a.result.scoreDisplayMode === 'informative' ? 100 : Number(a.result.score); + const scoreB = b.result.scoreDisplayMode === 'informative' ? 100 : Number(b.result.score); + return scoreA - scoreB; + }); if (diagnosticAudits.length) { - const groupEl = this.renderAuditGroup(groups['diagnostics'], {expandable: false}); + const groupEl = this.renderAuditGroup(groups['diagnostics'], { expandable: false }); diagnosticAudits.forEach((item, i) => groupEl.appendChild(this.renderAudit(item, i))); groupEl.classList.add('lh-audit-group--diagnostics'); element.appendChild(groupEl); @@ -210,8 +210,8 @@ class PerformanceCategoryRenderer extends CategoryRenderer { // Passed audits const passedAudits = category.auditRefs - .filter(audit => (audit.group === 'load-opportunities' || audit.group === 'diagnostics') && - Util.showAsPassed(audit.result)); + .filter(audit => (audit.group === 'load-opportunities' || audit.group === 'diagnostics') && + Util.showAsPassed(audit.result)); if (!passedAudits.length) return element; @@ -229,4 +229,4 @@ if (typeof module !== 'undefined' && module.exports) { module.exports = PerformanceCategoryRenderer; } else { self.PerformanceCategoryRenderer = PerformanceCategoryRenderer; -} +} \ No newline at end of file diff --git a/lighthouse-core/report/html/renderer/report-renderer.js b/lighthouse-core/report/html/renderer/report-renderer.js index 8b9559fa4814..811c725b3360 100644 --- a/lighthouse-core/report/html/renderer/report-renderer.js +++ b/lighthouse-core/report/html/renderer/report-renderer.js @@ -26,7 +26,7 @@ /** @typedef {import('./dom.js')} DOM */ /** @typedef {import('./details-renderer.js').DetailsJSON} DetailsJSON */ -/* globals self, Util, DetailsRenderer, CategoryRenderer, PerformanceCategoryRenderer, PwaCategoryRenderer */ +/* globals self, Util, DetailsRenderer, CategoryRenderer, PerformanceCategoryRenderer, PwaCategoryRenderer, LightWalletRenderer */ class ReportRenderer { /** @@ -194,6 +194,7 @@ class ReportRenderer { const specificCategoryRenderers = { performance: new PerformanceCategoryRenderer(this._dom, detailsRenderer), pwa: new PwaCategoryRenderer(this._dom, detailsRenderer), + lightwallet: new LightWalletRenderer(this._dom, detailsRenderer), }; Object.values(specificCategoryRenderers).forEach(renderer => { renderer.setTemplateContext(this._templateContext); diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css index 4001365bc236..863dbfbbdff4 100644 --- a/lighthouse-core/report/html/report-styles.css +++ b/lighthouse-core/report/html/report-styles.css @@ -1124,3 +1124,121 @@ 75% { opacity: 1; } 100% { opacity: 1; filter: drop-shadow(1px 0px 1px #aaa) drop-shadow(0px 2px 4px hsla(206, 6%, 25%, 0.15)); pointer-events: auto; } } + +/* LightWallet */ + +.lh-lw__timings-table, .lh-lw__resources-table{ + margin-bottom: 30px; +} + +.lh-lw__light-wallet-header { + line-height: 24px; + font-size: 20px; +} + +.lh-lw__section-header { + margin-top: calc(var(--section-padding) * 1.5); + margin-bottom: var(--section-padding); + font-size: var(--subheader-font-size); + line-height: var(--subheader-line-height); + color: var(--subheader-color); + font-weight: bold; +} + +.lh-lw__section-header::before { + width: calc(var(--subheader-font-size) / 14 * 24); + height: calc(var(--subheader-font-size) / 14 * 24); + margin-right: calc(var(--subheader-font-size) / 2); + background: var(--medium-100-gray) none no-repeat center / 16px; + display: inline-block; + border-radius: 50%; + vertical-align: middle; + content: ''; + background-image: var(--content-paste-icon-url); +} + +.lh-lw__resources-header, +.lh-lw__timings-header{ + line-height: calc(2.3 * var(--body-font-size)); + display: flex; + justify-content: space-between; +} + +.lh-lw__resources-header-col, +.lh-lw__timings-header-col{ + padding-left: 10px; + background-color: var(--medium-50-gray); + color: var(--medium-75-gray); + display: inline-block; +} + +.lh-lw__resources-header-col--description, +.lh-lw__resource-col--description, +.lh-lw__timings-header-col--description, +.lh-lw__timings-col--description{ + width: 175px; +} + +.lh-lw__resources-header-col--budget, +.lh-lw__resource-col--budget, +.lh-lw__timings-header-col--budget, +.lh-lw__timings-col--budget{ + width: 150px; +} + +.lh-lw__resources-header-col--actual, +.lh-lw__resource-col--actual, +.lh-lw__timings-header-col--actual, +.lh-lw__timings-col--actual{ + width: 120px; +} + +.lh-lw__resources-header-col--sparkline, +.lh-lw__resource-col--sparkline, +.lh-lw__timings-header-col--sparkline, +.lh-lw__timings-col--sparkline{ + flex-grow: 1; +} + +.lh-lw__resources-header-col--difference, +.lh-lw__resource-col--difference, +.lh-lw__timings-header-col--difference, +.lh-lw__timings-col--difference{ + width: 120px; + padding-left: 20px; + text-align: right; +} + +.lh-lw__resource-header-col--icon, +.lh-lw__resource-col--icon, +.lh-lw__timings-header-col--icon, +.lh-lw__timings-col--icon{ + width: 40px; +} + +.lh-lw__timings-col--budget-tolerance { + color: var(--medium-75-gray); +} + +.lh-lw__gauge { + height: 60px; + width: 130px; + background-position: center; + background-repeat: no-repeat; +} + +a.lh-lw__gauge-wrapper { + color: white; +} + +.lh-gauge__wrapper--fail .lh-lw__gauge{ + background-image: var(--fail-icon-url); +} + +.lh-gauge__wrapper--average .lh-lw__gauge{ + background-image: var(--average-icon-url); +} + +.lh-gauge__wrapper--pass .lh-lw__gauge{ + background-image: var(--pass-icon-url); +} diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html index 2adbb3e488eb..75867f9a993c 100644 --- a/lighthouse-core/report/html/templates.html +++ b/lighthouse-core/report/html/templates.html @@ -81,6 +81,74 @@ + + + + + + + + + + + + + +