Repository: GoogleChrome/lighthouse
Pull request #8661 (2019-04-27 05:00:36)

report(redesign): add toggle control to show metric descriptions
#8185

Hoten: @brendankenny added tests. Also tried a refactor (`render fn`) but not sure if better

Patch:
diff --git a/.travis.yml b/.travis.yml
index eb9afebd5..d99b65ea5 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -26,6 +26,7 @@ before_script:
   # see comment above about puppeteer
   - export CHROME_PATH="$(which google-chrome-stable)"
   - sh -e /etc/init.d/xvfb start
+  # Print out the Chrome version so we know what we're working with
   - google-chrome-stable --version
   - yarn build-all
 script:
diff --git a/docs/releasing.md b/docs/releasing.md
index a1a026f0e..eabdf233d 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -32,17 +32,17 @@ We follow [semver](https://semver.org/) versioning semantics (`vMajor.Minor.Patc
 
 ```sh
 # Run the tests
-bash ./lighthouse-core/scripts/release/release-test.sh
+bash ./lighthouse-core/scripts/release/test.sh
 # Prepare the commit, replace x.x.x with the desired version
-bash ./lighthouse-core/scripts/release/release-prepare-commit.sh x.x.x
+bash ./lighthouse-core/scripts/release/prepare-commit.sh x.x.x
 
 # Open the PR and await merge...
 echo "It's been merged! 🎉"
 
 # Run the tests again :)
-bash ./lighthouse-core/scripts/release/release-test.sh
+bash ./lighthouse-core/scripts/release/test.sh
 # Package everything for publishing
-bash ./lighthouse-core/scripts/release/release-prepare-package.sh
+bash ./lighthouse-core/scripts/release/prepare-package.sh
 
 # Upload the extension
 node build/build-extension.js package; cd dist/extension-package/
diff --git a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
index 0d4738055..79322f4d0 100644
--- a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
+++ b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
@@ -152,11 +152,12 @@ class CacheHeaders extends Audit {
       NetworkRequest.TYPES.Stylesheet,
     ]);
 
-    const resourceUrl = record.url;
+    // It's not a request loaded over the network, caching makes no sense
+    if (URL.NON_NETWORK_PROTOCOLS.includes(record.protocol)) return false;
+
     return (
       CACHEABLE_STATUS_CODES.has(record.statusCode) &&
-      STATIC_RESOURCE_TYPES.has(record.resourceType || 'Other') &&
-      !resourceUrl.includes('data:')
+      STATIC_RESOURCE_TYPES.has(record.resourceType || 'Other')
     );
   }
 
diff --git a/lighthouse-core/audits/font-display.js b/lighthouse-core/audits/font-display.js
index f6460c28e..32a219b4a 100644
--- a/lighthouse-core/audits/font-display.js
+++ b/lighthouse-core/audits/font-display.js
@@ -116,6 +116,7 @@ class FontDisplay extends Audit {
       .filter(record => !passingFontURLs.has(record.url))
       // ...and that aren't data URLs, the blocking concern doesn't really apply
       .filter(record => !/^data:/.test(record.url))
+      .filter(record => !/^blob:/.test(record.url))
       .map(record => {
         // In reality the end time should be calculated with paint time included
         // all browsers wait 3000ms to block text so we make sure 3000 is our max wasted time
diff --git a/lighthouse-core/computed/resource-summary.js b/lighthouse-core/computed/resource-summary.js
new file mode 100644
index 000000000..a2c9b6b59
--- /dev/null
+++ b/lighthouse-core/computed/resource-summary.js
@@ -0,0 +1,84 @@
+/**
+ * @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 makeComputedArtifact = require('./computed-artifact.js');
+const NetworkRecords = require('./network-records.js');
+const MainResource = require('./main-resource.js');
+const URL = require('../lib/url-shim.js');
+
+/** @typedef {{count: number, size: number}} ResourceEntry */
+class ResourceSummary {
+  /**
+   * @param {LH.Artifacts.NetworkRequest} record
+   * @return {LH.Budget.ResourceType}
+   */
+  static determineResourceType(record) {
+    if (!record.resourceType) return 'other';
+    /** @type {Partial<Record<LH.Crdp.Page.ResourceType, LH.Budget.ResourceType>>} */
+    const requestToResourceType = {
+      'Stylesheet': 'stylesheet',
+      'Image': 'image',
+      'Media': 'media',
+      'Font': 'font',
+      'Script': 'script',
+      'Document': 'document',
+    };
+    return requestToResourceType[record.resourceType] || 'other';
+  }
+
+  /**
+   * @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
+   * @param {string} mainResourceURL
+   * @return {Record<LH.Budget.ResourceType,ResourceEntry>}
+   */
+  static summarize(networkRecords, mainResourceURL) {
+    /** @type {Record<LH.Budget.ResourceType,ResourceEntry>} */
+    const resourceSummary = {
+      'stylesheet': {count: 0, size: 0},
+      'image': {count: 0, size: 0},
+      'media': {count: 0, size: 0},
+      'font': {count: 0, size: 0},
+      'script': {count: 0, size: 0},
+      'document': {count: 0, size: 0},
+      'other': {count: 0, size: 0},
+      'total': {count: 0, size: 0},
+      'third-party': {count: 0, size: 0},
+    };
+
+    for (const record of networkRecords) {
+      const type = this.determineResourceType(record);
+      resourceSummary[type].count++;
+      resourceSummary[type].size += record.transferSize;
+
+      resourceSummary.total.count++;
+      resourceSummary.total.size += record.transferSize;
+
+      // Ignores subdomains: i.e. blog.example.com & example.com would match
+      if (!URL.rootDomainsMatch(record.url, mainResourceURL)) {
+        resourceSummary['third-party'].count++;
+        resourceSummary['third-party'].size += record.transferSize;
+      }
+    }
+    return resourceSummary;
+  }
+
+  /**
+   * @param {{URL: LH.Artifacts['URL'], devtoolsLog: LH.DevtoolsLog}} data
+   * @param {LH.Audit.Context} context
+   * @return {Promise<Record<LH.Budget.ResourceType,ResourceEntry>>}
+   */
+  static async compute_(data, context) {
+    const [networkRecords, mainResource] = await Promise.all([
+      NetworkRecords.request(data.devtoolsLog, context),
+      MainResource.request(data, context),
+    ]);
+
+    return ResourceSummary.summarize(networkRecords, mainResource.url);
+  }
+}
+
+module.exports = makeComputedArtifact(ResourceSummary);
diff --git a/lighthouse-core/config/budget.js b/lighthouse-core/config/budget.js
index 820dda302..e788e8f49 100644
--- a/lighthouse-core/config/budget.js
+++ b/lighthouse-core/config/budget.js
@@ -5,10 +5,36 @@
  */
 'use strict';
 
+/**
+ * @param {unknown} arr
+ * @return {arr is Array<Record<string, unknown>>}
+ */
+function isArrayOfUnknownObjects(arr) {
+  return Array.isArray(arr) && arr.every(isObjectOfUnknownProperties);
+}
+
+/**
+ * @param {unknown} val
+ * @return {val is Record<string, unknown>}
+ */
+function isObjectOfUnknownProperties(val) {
+  return typeof val === 'object' && val !== null && !Array.isArray(val);
+}
+
+/**
+ * Returns whether `val` is numeric. Will not coerce to a number. `NaN` will
+ * return false, however ±Infinity will return true.
+ * @param {unknown} val
+ * @return {val is number}
+ */
+function isNumber(val) {
+  return typeof val === 'number' && !isNaN(val);
+}
+
 class Budget {
   /**
    * Asserts that obj has no own properties, throwing a nice error message if it does.
-   * Plugin and object name are included for nicer logging.
+   * `objectName` is included for nicer logging.
    * @param {Record<string, unknown>} obj
    * @param {string} objectName
    */
@@ -21,13 +47,14 @@ class Budget {
   }
 
   /**
-   * @param {LH.Budget.ResourceBudget} resourceBudget
+   * @param {Record<string, unknown>} resourceBudget
    * @return {LH.Budget.ResourceBudget}
    */
   static validateResourceBudget(resourceBudget) {
     const {resourceType, budget, ...invalidRest} = resourceBudget;
     Budget.assertNoExcessProperties(invalidRest, 'Resource Budget');
 
+    /** @type {Array<LH.Budget.ResourceType>} */
     const validResourceTypes = [
       'total',
       'document',
@@ -39,27 +66,29 @@ class Budget {
       'other',
       'third-party',
     ];
-    if (!validResourceTypes.includes(resourceBudget.resourceType)) {
-      throw new Error(`Invalid resource type: ${resourceBudget.resourceType}. \n` +
+    // Assume resourceType is an allowed string, throw if not.
+    if (!validResourceTypes.includes(/** @type {LH.Budget.ResourceType} */ (resourceType))) {
+      throw new Error(`Invalid resource type: ${resourceType}. \n` +
         `Valid resource types are: ${ validResourceTypes.join(', ') }`);
     }
-    if (isNaN(resourceBudget.budget)) {
-      throw new Error('Invalid budget: ${resourceBudget.budget}');
+    if (!isNumber(budget)) {
+      throw new Error(`Invalid budget: ${budget}`);
     }
     return {
-      resourceType,
+      resourceType: /** @type {LH.Budget.ResourceType} */ (resourceType),
       budget,
     };
   }
 
   /**
-   * @param {LH.Budget.TimingBudget} timingBudget
+   * @param {Record<string, unknown>} timingBudget
    * @return {LH.Budget.TimingBudget}
    */
   static validateTimingBudget(timingBudget) {
     const {metric, budget, tolerance, ...invalidRest} = timingBudget;
     Budget.assertNoExcessProperties(invalidRest, 'Timing Budget');
 
+    /** @type {Array<LH.Budget.TimingMetric>} */
     const validTimingMetrics = [
       'first-contentful-paint',
       'first-cpu-idle',
@@ -67,18 +96,19 @@ class Budget {
       'first-meaningful-paint',
       'estimated-input-latency',
     ];
-    if (!validTimingMetrics.includes(timingBudget.metric)) {
-      throw new Error(`Invalid timing metric: ${timingBudget.metric}. \n` +
+    // Assume metric is an allowed string, throw if not.
+    if (!validTimingMetrics.includes(/** @type {LH.Budget.TimingMetric} */ (metric))) {
+      throw new Error(`Invalid timing metric: ${metric}. \n` +
         `Valid timing metrics are: ${validTimingMetrics.join(', ')}`);
     }
-    if (isNaN(timingBudget.budget)) {
-      throw new Error('Invalid budget: ${timingBudget.budget}');
+    if (!isNumber(budget)) {
+      throw new Error(`Invalid budget: ${budget}`);
     }
-    if (timingBudget.tolerance !== undefined && isNaN(timingBudget.tolerance)) {
-      throw new Error('Invalid tolerance: ${timingBudget.tolerance}');
+    if (typeof tolerance !== 'undefined' && !isNumber(tolerance)) {
+      throw new Error(`Invalid tolerance: ${tolerance}`);
     }
     return {
-      metric,
+      metric: /** @type {LH.Budget.TimingMetric} */ (metric),
       budget,
       tolerance,
     };
@@ -87,43 +117,44 @@ class Budget {
   /**
    * More info on the Budget format:
    * https://github.com/GoogleChrome/lighthouse/issues/6053#issuecomment-428385930
-   * @param {Array<LH.Budget>} budgetArr
+   * @param {unknown} budgetJson
    * @return {Array<LH.Budget>}
    */
-  static initializeBudget(budgetArr) {
-    /** @type {Array<LH.Budget>} */
-    const budgets = [];
+  static initializeBudget(budgetJson) {
+    // Clone to prevent modifications of original and to deactivate any live properties.
+    budgetJson = JSON.parse(JSON.stringify(budgetJson));
+    if (!isArrayOfUnknownObjects(budgetJson)) {
+      throw new Error('Budget file is not defined as an array of budgets.');
+    }
 
-    budgetArr.forEach((b) => {
+    const budgets = budgetJson.map((b, index) => {
       /** @type {LH.Budget} */
       const budget = {};
 
       const {resourceSizes, resourceCounts, timings, ...invalidRest} = b;
       Budget.assertNoExcessProperties(invalidRest, 'Budget');
 
-      if (b.resourceSizes !== undefined) {
-        budget.resourceSizes = b.resourceSizes.map((r) => {
-          return Budget.validateResourceBudget(r);
-        });
+      if (isArrayOfUnknownObjects(resourceSizes)) {
+        budget.resourceSizes = resourceSizes.map(Budget.validateResourceBudget);
+      } else if (resourceSizes !== undefined) {
+        throw new Error(`Invalid resourceSizes entry in budget at index ${index}`);
       }
 
-      if (b.resourceCounts !== undefined) {
-        budget.resourceCounts = b.resourceCounts.map((r) => {
-          return Budget.validateResourceBudget(r);
-        });
+      if (isArrayOfUnknownObjects(resourceCounts)) {
+        budget.resourceCounts = resourceCounts.map(Budget.validateResourceBudget);
+      } else if (resourceCounts !== undefined) {
+        throw new Error(`Invalid resourceCounts entry in budget at index ${index}`);
       }
 
-      if (b.timings !== undefined) {
-        budget.timings = b.timings.map((t) => {
-          return Budget.validateTimingBudget(t);
-        });
+      if (isArrayOfUnknownObjects(timings)) {
+        budget.timings = timings.map(Budget.validateTimingBudget);
+      } else if (timings !== undefined) {
+        throw new Error(`Invalid timings entry in budget at index ${index}`);
       }
-      budgets.push({
-        resourceSizes,
-        resourceCounts,
-        timings,
-      });
+
+      return budget;
     });
+
     return budgets;
   }
 }
diff --git a/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js b/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js
index d536ffefb..876b27945 100644
--- a/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js
+++ b/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js
@@ -56,6 +56,7 @@ function collectTagsThatBlockFirstPaint() {
               !scriptTag.hasAttribute('async') &&
               !scriptTag.hasAttribute('defer') &&
               !/^data:/.test(scriptTag.src) &&
+              !/^blob:/.test(scriptTag.src) &&
               scriptTag.getAttribute('type') !== 'module'
             );
           } else if (tag.tagName === 'LINK') {
diff --git a/lighthouse-core/lib/network-request.js b/lighthouse-core/lib/network-request.js
index b93c7eeb1..c1be94fed 100644
--- a/lighthouse-core/lib/network-request.js
+++ b/lighthouse-core/lib/network-request.js
@@ -428,41 +428,8 @@ class NetworkRequest {
       return;
     }
 
-    // Init timing.
-    this.timing = {
-      requestTime: this.startTime,
-      proxyStart: -1,
-      proxyEnd: -1,
-      dnsStart: -1,
-      dnsEnd: -1,
-      connectStart: -1,
-      connectEnd: -1,
-      sslStart: -1,
-      sslEnd: -1,
-      workerStart: -1,
-      workerReady: -1,
-      sendStart: -1,
-      sendEnd: -1,
-      pushStart: -1,
-      pushEnd: -1,
-      receiveHeadersEnd: -1,
-    };
-
-    const origEnd = this.endTime;
-    // `this.endTime` and `this.responseReceivedTime` are in seconds, so convert from milliseconds.
-    this.endTime = this.startTime + (totalMs / 1000);
-    this.responseReceivedTime = this.startTime + ((TCPMs + requestMs) / 1000);
-
-    this.timing.connectStart = 0;
-    this.timing.connectEnd = TCPMs;
-    this.timing.sslStart = TCPMs - SSLMs;
-    this.timing.sslEnd = TCPMs;
-    this.timing.sendStart = TCPMs;
-    this.timing.sendEnd = TCPMs;
-    this.timing.receiveHeadersEnd = TCPMs + requestMs;
-
     this.lrStatistics = {
-      endTimeDeltaMs: (origEnd - this.endTime) * 1000,
+      endTimeDeltaMs: (this.endTime - (this.startTime + (totalMs / 1000))) * 1000,
       TCPMs: TCPMs,
       requestMs: requestMs,
       responseMs: responseMs,
diff --git a/lighthouse-core/report/html/renderer/performance-category-renderer.js b/lighthouse-core/report/html/renderer/performance-category-renderer.js
index 55673fbbe..05115ee48 100644
--- a/lighthouse-core/report/html/renderer/performance-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/performance-category-renderer.js
@@ -122,10 +122,16 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
       element.appendChild(this.renderCategoryHeader(category, groups));
     }
 
-    // Metrics
+    // Metrics.
     const metricAudits = category.auditRefs.filter(audit => audit.group === 'metrics');
     const metricAuditsEl = this.renderAuditGroup(groups.metrics);
 
+    // Metric descriptions toggle.
+    const auditGroupHeader = this.dom.find('.lh-audit-group__header', metricAuditsEl);
+    const toggleTmpl = this.dom.cloneTemplate('#tmpl-lh-metrics-toggle', this.templateContext);
+    const toggleEl = this.dom.find('.lh-metrics-toggle', toggleTmpl);
+    auditGroupHeader.appendChild(toggleEl);
+
     const keyMetrics = metricAudits.filter(a => a.weight >= 3);
     const otherMetrics = metricAudits.filter(a => a.weight < 3);
 
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index 4144966f5..bc252bccf 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -58,6 +58,10 @@ class ReportUIFeatures {
     this.stickyHeaderEl; // eslint-disable-line no-unused-expressions
     /** @type {HTMLElement} */
     this.highlightEl; // eslint-disable-line no-unused-expressions
+    /** @type {HTMLInputElement} */
+    this.metricDescriptionToggleEl; // eslint-disable-line no-unused-expressions
+    /** @type {HTMLElement} */
+    this.metricAuditGroup; // eslint-disable-line no-unused-expressions
 
     this.onMediaQueryChange = this.onMediaQueryChange.bind(this);
     this.onCopy = this.onCopy.bind(this);
@@ -70,6 +74,7 @@ class ReportUIFeatures {
     this.expandAllDetails = this.expandAllDetails.bind(this);
     this._toggleDarkTheme = this._toggleDarkTheme.bind(this);
     this._updateStickyHeaderOnScroll = this._updateStickyHeaderOnScroll.bind(this);
+    this._toggleMetricDescription = this._toggleMetricDescription.bind(this);
   }
 
   /**
@@ -92,6 +97,7 @@ class ReportUIFeatures {
     this._document.addEventListener('copy', this.onCopy);
     this._document.addEventListener('scroll', this._updateStickyHeaderOnScroll);
     window.addEventListener('resize', this._updateStickyHeaderOnScroll);
+    this._setupMetricDescriptionToggleElements();
     const topbarLogo = this._dom.find('.lh-topbar__logo', this._document);
     topbarLogo.addEventListener('click', this._toggleDarkTheme);
   }
@@ -254,6 +260,26 @@ class ReportUIFeatures {
     this._copyAttempt = false;
   }
 
+  _setupMetricDescriptionToggleElements() {
+    const metricDescriptionToggleEl = this._document.querySelector('.lh-metrics-toggle__input');
+    // No metrics if performance category wasn't run.
+    if (!metricDescriptionToggleEl) return;
+
+    this.metricDescriptionToggleEl = /** @type {HTMLInputElement} */ (metricDescriptionToggleEl);
+    this.metricAuditGroup = this._dom.find('.lh-audit-group--metrics', this._document);
+    this.metricDescriptionToggleEl.addEventListener('input', this._toggleMetricDescription);
+    this.metricAuditGroup.addEventListener('click', e => {
+      const el = /** @type {HTMLElement} */ (e.target);
+      if (el.closest('.lh-metric__title')) this.metricDescriptionToggleEl.click();
+    });
+  }
+
+  _toggleMetricDescription() {
+    this.metricDescriptionToggleEl.blur();
+    const show = this.metricDescriptionToggleEl.checked;
+    this.metricAuditGroup.classList.toggle('lh-audit-group--metrics__show-descriptions', show);
+  }
+
   /**
    * Copies the report JSON to the clipboard (if supported by the browser).
    */
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index 7421805b3..552ca9d64 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -86,6 +86,7 @@
   --color-black-800: #424242;
   --color-black-900: #212121;
   --color-black: #000000;
+  --color-blue: #2962FF;
   --color-green-700: #018642;
   --color-green: #0CCE6B;
   --color-orange-700: #D04900;
@@ -102,6 +103,7 @@
   --env-item-bg: var(--color-black-100);
   --env-name-min-width: 220px;
   --env-tem-padding: 10px 0px;
+  --expandable-padding: 0 0 2px calc(var(--score-shape-margin-left) + var(--score-shape-size) + var(--score-shape-margin-right));
   --gauge-circle-size-big: 120px;
   --gauge-circle-size: 96px;
   --header-padding: 20px 0 20px 0;
@@ -117,7 +119,10 @@
   --score-container-width: 160px;
   --score-number-font-size-big: 42px;
   --score-number-font-size: 34px;
-  --score-shape-margin: 7px 12px 0 4px;
+  --score-shape-margin-left: 4px;
+  --score-shape-margin-right: 12px;
+  --score-shape-margin-top: 7px;
+  --score-shape-margin: var(--score-shape-margin-top) var(--score-shape-margin-right) 0 var(--score-shape-margin-left);
   --score-shape-size: 12px;
   --score-title-font-size-big: 28px;
   --score-title-font-size: 20px;
@@ -141,6 +146,7 @@
   --color-highlighter-bg: var(--body-text-color);
 
   --color-hover: #FAFAFA;
+  --color-metric-toggle-lines: #7F7F7F;
 
   --plugin-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="%23757575"><path d="M0 0h24v24H0z" fill="none"/><path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>');
   --plugin-icon-url-dark: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="%23FFFFFF"><path d="M0 0h24v24H0z" fill="none"/><path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>');
@@ -210,7 +216,8 @@
     --score-container-width: 112px;
     --score-number-font-size-big: 34px;
     --score-number-font-size: 26px;
-    --score-shape-margin: 5px 12px 0 2px;
+    --score-shape-margin-left: 2px;
+    --score-shape-margin-top: 7px;
     --score-shape-size: 10px;
     --score-title-font-size-big: 22px;
     --score-title-font-size: 14px;
@@ -525,11 +532,14 @@
 .lh-metric {
   border-bottom: 1px solid var(--report-secondary-border-color);
 }
+.lh-metric:first-of-type {
+  border-top: 1px solid var(--report-secondary-border-color);
+}
 
 .lh-metric__innerwrap {
   display: flex;
+  flex-wrap: wrap;
   justify-content: space-between;
-  font-weight: 500;
   padding: 8px var(--text-indent);
 }
 
@@ -539,10 +549,7 @@
 
 .lh-metric__title {
   flex: 1;
-}
-
-.lh-metric__name {
-  flex: 1;
+  font-weight: 500;
 }
 
 .lh-metrics__disclaimer {
@@ -553,13 +560,68 @@
 }
 
 .lh-metric__description {
+  display: none;
   color: var(--secondary-text-color);
+  padding: var(--expandable-padding);
+}
+.lh-audit-group--metrics__show-descriptions .lh-metric__description {
+  display: block;
 }
 
 .lh-metric__value {
   white-space: nowrap; /* No wrapping between metric value and the icon */
+  font-weight: 500;
+}
+
+.lh-metrics-toggle {
+  position: relative;
+}
+.lh-metrics-toggle__input {
+  cursor: pointer;
+  opacity: 0;
+  position: absolute;
+  width: 100%;
+  height: 100%;
+}
+.lh-metrics-toggle label {
+  display: flex;
+  background-color: #eee;
+  border-radius: 20px;
+  overflow: hidden;
+}
+.lh-metrics-toggle__input:focus + label {
+  outline: -webkit-focus-ring-color auto 3px;
+}
+.lh-metrics-toggle__icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 2px 5px;
+  width: 50%;
+  height: 28px;
+}
+.lh-metrics-toggle__input:not(:checked) + label .lh-metrics-toggle__icon--less,
+.lh-metrics-toggle__input:checked + label .lh-metrics-toggle__icon--more {
+  background-color: var(--color-blue);
+  --color-metric-toggle-lines: var(--color-white);
+}
+.lh-metrics-toggle__lines {
+  fill: var(--color-metric-toggle-lines);
+}
+.lh-metrics-toggle label .lh-metrics-toggle__icon--less {
+  padding-left: 8px;
+}
+.lh-metrics-toggle label .lh-metrics-toggle__icon--more {
+  padding-right: 8px;
 }
 
+/* Pushes the metric description toggle button to the right. */
+.lh-audit-group--metrics .lh-audit-group__header {
+  display: flex;
+}
+.lh-audit-group--metrics .lh-audit-group__header span.lh-audit-group__title {
+  flex: 1;
+}
 
 .lh-metric .lh-metric__innerwrap::before {
   content: '';
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index 7306d0621..09ca0479d 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -60,6 +60,7 @@ limitations under the License.
           <span class="lh-audit-group__title"></span>
           <span class="lh-audit-group__itemcount"></span>
           <!-- .lh-audit-group__description will be added here -->
+          <!-- .lh-metrics-toggle will be added here -->
         </div>
         <div class=""></div>
       </div>
@@ -67,6 +68,25 @@ limitations under the License.
   </details>
 </template>
 
+<!-- Lighthouse metrics toggle -->
+<template id="tmpl-lh-metrics-toggle">
+  <div class="lh-metrics-toggle">
+    <input class="lh-metrics-toggle__input" type="checkbox" name="toggle-metric-descriptions">
+    <label for="toggle-metric-descriptions">
+      <div class="lh-metrics-toggle__icon lh-metrics-toggle__icon--less" aria-hidden="true">
+        <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
+          <path class="lh-metrics-toggle__lines" d="M4 9h16v2H4zm0 4h10v2H4z" />
+        </svg>
+      </div>
+      <div class="lh-metrics-toggle__icon lh-metrics-toggle__icon--more" aria-hidden="true">
+        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+          <path class="lh-metrics-toggle__lines" d="M3 18h12v-2H3v2zM3 6v2h18V6H3zm0 7h18v-2H3v2z" />
+        </svg>
+      </div>
+    </label>
+  </div>
+</template>
+
 <!-- Lighthouse audit -->
 <template id="tmpl-lh-audit">
   <div class="lh-audit">
@@ -88,10 +108,10 @@ limitations under the License.
 <!-- Lighthouse perf metric -->
 <template id="tmpl-lh-metric">
   <div class="lh-metric">
-    <div class="lh-metric__innerwrap tooltip-boundary">
+    <div class="lh-metric__innerwrap">
       <span class="lh-metric__title"></span>
       <div class="lh-metric__value"></div>
-      <div class="lh-metric__description tooltip"></div>
+      <div class="lh-metric__description"></div>
     </div>
   </div>
 </template>
diff --git a/lighthouse-core/scripts/release/publish-clean-pristine.sh b/lighthouse-core/scripts/release/clean-pristine.sh
similarity index 100%
rename from lighthouse-core/scripts/release/publish-clean-pristine.sh
rename to lighthouse-core/scripts/release/clean-pristine.sh
diff --git a/lighthouse-core/scripts/release/publish-prepare-commit.sh b/lighthouse-core/scripts/release/prepare-commit.sh
similarity index 100%
rename from lighthouse-core/scripts/release/publish-prepare-commit.sh
rename to lighthouse-core/scripts/release/prepare-commit.sh
diff --git a/lighthouse-core/scripts/release/publish-prepare-package.sh b/lighthouse-core/scripts/release/prepare-package.sh
similarity index 94%
rename from lighthouse-core/scripts/release/publish-prepare-package.sh
rename to lighthouse-core/scripts/release/prepare-package.sh
index c5398c5d7..ac175968e 100755
--- a/lighthouse-core/scripts/release/publish-prepare-package.sh
+++ b/lighthouse-core/scripts/release/prepare-package.sh
@@ -5,7 +5,7 @@ LH_PRISTINE_ROOT="$DIRNAME/../../../../lighthouse-pristine"
 
 set -euxo pipefail
 
-bash "$DIRNAME/release-prepare-pristine.sh"
+bash "$DIRNAME/prepare-pristine.sh"
 
 cd "$LH_PRISTINE_ROOT"
 
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 3212277ce..5c36f363d 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -2659,13 +2659,6 @@
                         "totalBytes": 144.0, 
                         "url": "http://localhost:10200/dobetterweb/empty_module.js?delay=500", 
                         "wastedBytes": 144.0
-                    }, 
-                    {
-                        "cacheHitProbability": 0.0, 
-                        "cacheLifetimeMs": 0.0, 
-                        "totalBytes": 0.0, 
-                        "url": "blob:http://localhost:10200/ae0eac03-ab9b-4a6a-b299-f5212153e277", 
-                        "wastedBytes": 0.0
                     }
                 ], 
                 "summary": {
@@ -2673,7 +2666,7 @@
                 }, 
                 "type": "table"
             }, 
-            "displayValue": "11 resources found", 
+            "displayValue": "10 resources found", 
             "id": "uses-long-cache-ttl", 
             "numericValue": 103455.0, 
             "score": 0.58, 
diff --git a/types/budget.d.ts b/types/budget.d.ts
index b1da9e8de..fa8632f1b 100644
--- a/types/budget.d.ts
+++ b/types/budget.d.ts
@@ -39,8 +39,8 @@ declare global {
       /** Supported timing metrics. */
       export type TimingMetric = 'first-contentful-paint' | 'first-cpu-idle' | 'interactive' | 'first-meaningful-paint' | 'estimated-input-latency';
 
-      /** Supported resource types. */
-      export type ResourceType = 'stylesheet' | 'image' | 'media' | 'font' | 'script' | 'document' | 'other';
+      /** Supported values for the resourceType property of a ResourceBudget. */
+      export type ResourceType = 'stylesheet' | 'image' | 'media' | 'font' | 'script' | 'document' | 'other' | 'total' | 'third-party';
     }
   }
 }


Test patch:
diff --git a/lighthouse-cli/test/smokehouse/perf/lantern-expectations.js b/lighthouse-cli/test/smokehouse/perf/lantern-expectations.js
index 5186beb4b..49a768160 100644
--- a/lighthouse-cli/test/smokehouse/perf/lantern-expectations.js
+++ b/lighthouse-cli/test/smokehouse/perf/lantern-expectations.js
@@ -26,4 +26,50 @@ module.exports = [
       },
     },
   },
+  {
+    lhr: {
+      requestedUrl: 'http://localhost:10200/tricky-main-thread.html?setTimeout',
+      finalUrl: 'http://localhost:10200/tricky-main-thread.html?setTimeout',
+      audits: {
+        'interactive': {
+          // Make sure all of the CPU time is reflected in the perf metrics as well.
+          score: '<.2',
+        },
+        'bootup-time': {
+          details: {
+            items: {
+              0: {
+              // FIXME: Appveyor finds this particular assertion very flaky for some reason :(
+                url: process.env.APPVEYOR ? /main/ : /main-thread-consumer/,
+                scripting: '>1000',
+              },
+            },
+          },
+        },
+      },
+    },
+  },
+  {
+    lhr: {
+      requestedUrl: 'http://localhost:10200/tricky-main-thread.html?fetch',
+      finalUrl: 'http://localhost:10200/tricky-main-thread.html?fetch',
+      audits: {
+        'interactive': {
+          // Make sure all of the CPU time is reflected in the perf metrics as well.
+          score: '<.2',
+        },
+        'bootup-time': {
+          details: {
+            items: {
+              0: {
+              // TODO: requires sampling profiler and async stacks, see https://github.com/GoogleChrome/lighthouse/issues/8526
+              // url: /main-thread-consumer/,
+                scripting: '>1000',
+              },
+            },
+          },
+        },
+      },
+    },
+  },
 ];
diff --git a/lighthouse-cli/test/smokehouse/tricky-metrics/expectations.js b/lighthouse-cli/test/smokehouse/tricky-metrics/expectations.js
index b7050ef4a..c94244363 100644
--- a/lighthouse-cli/test/smokehouse/tricky-metrics/expectations.js
+++ b/lighthouse-cli/test/smokehouse/tricky-metrics/expectations.js
@@ -36,42 +36,4 @@ module.exports = [
       },
     },
   },
-  {
-    lhr: {
-      requestedUrl: 'http://localhost:10200/tricky-main-thread.html?setTimeout',
-      finalUrl: 'http://localhost:10200/tricky-main-thread.html?setTimeout',
-      audits: {
-        'bootup-time': {
-          details: {
-            items: {
-              0: {
-              // FIXME: Appveyor finds this particular assertion very flaky for some reason :(
-                url: process.env.APPVEYOR ? /main/ : /main-thread-consumer/,
-                scripting: '>1000',
-              },
-            },
-          },
-        },
-      },
-    },
-  },
-  {
-    lhr: {
-      requestedUrl: 'http://localhost:10200/tricky-main-thread.html?fetch',
-      finalUrl: 'http://localhost:10200/tricky-main-thread.html?fetch',
-      audits: {
-        'bootup-time': {
-          details: {
-            items: {
-              0: {
-              // TODO: requires async stacks, https://github.com/GoogleChrome/lighthouse/pull/5504
-              // url: /main-thread-consumer/,
-                scripting: '>1000',
-              },
-            },
-          },
-        },
-      },
-    },
-  },
 ];
diff --git a/lighthouse-core/scripts/release/publish-prepare-pristine.sh b/lighthouse-core/scripts/release/prepare-pristine.sh
similarity index 100%
rename from lighthouse-core/scripts/release/publish-prepare-pristine.sh
rename to lighthouse-core/scripts/release/prepare-pristine.sh
diff --git a/lighthouse-core/scripts/release/publish-test.sh b/lighthouse-core/scripts/release/test.sh
similarity index 97%
rename from lighthouse-core/scripts/release/publish-test.sh
rename to lighthouse-core/scripts/release/test.sh
index 2a5b52765..271a08346 100755
--- a/lighthouse-core/scripts/release/publish-test.sh
+++ b/lighthouse-core/scripts/release/test.sh
@@ -9,7 +9,7 @@ LH_PRISTINE_ROOT="$DIRNAME/../../../../lighthouse-pristine"
 
 set -euxo pipefail
 
-bash "$DIRNAME/release-prepare-pristine.sh"
+bash "$DIRNAME/prepare-pristine.sh"
 
 cd "$LH_PRISTINE_ROOT"
 
diff --git a/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js b/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js
index aba0f68d0..4a3787183 100644
--- a/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/uses-long-cache-ttl-test.js
@@ -24,6 +24,7 @@ function networkRecord(options = {}) {
     statusCode: options.statusCode || 200,
     resourceType: options.resourceType || NetworkRequest.TYPES.Script,
     transferSize: options.transferSize || 10000,
+    protocol: options.protocol || 'http/1.1',
     responseHeaders: headers,
   };
 }
@@ -207,7 +208,8 @@ describe('Cache headers audit', () => {
     const networkRecords = [
       networkRecord({statusCode: 500}),
       networkRecord({url: 'https://example.com/dynamic.js?userId=crazy', transferSize: 10}),
-      networkRecord({url: 'data:image/jpeg;base64,what'}),
+      networkRecord({url: 'data:image/jpeg;base64,what', protocol: 'data'}),
+      networkRecord({url: 'blob:http://example.com/ca6df701-9c67-49fd-a787', protocol: 'blob'}),
       networkRecord({resourceType: NetworkRequest.TYPES.XHR}),
     ];
 
diff --git a/lighthouse-core/test/computed/resource-summary-test.js b/lighthouse-core/test/computed/resource-summary-test.js
new file mode 100644
index 000000000..87e2b98c7
--- /dev/null
+++ b/lighthouse-core/test/computed/resource-summary-test.js
@@ -0,0 +1,97 @@
+/**
+ * @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 ComputedResourceSummary = require('../../computed/resource-summary.js');
+const assert = require('assert');
+const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js');
+
+/* eslint-env jest */
+
+function mockArtifacts(networkRecords) {
+  return {
+    devtoolsLog: networkRecordsToDevtoolsLog(networkRecords),
+    URL: {requestedUrl: networkRecords[0].url, finalUrl: networkRecords[0].url},
+  };
+}
+
+describe('Resource summary computed', () => {
+  let artifacts;
+  let context;
+  beforeEach(() => {
+    artifacts = mockArtifacts([
+      {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
+      {url: 'http://example.com/app.js', resourceType: 'Script', transferSize: 10},
+      {url: 'http://cdn.example.com/script.js', resourceType: 'Script', transferSize: 50},
+      {url: 'http://third-party.com/file.jpg', resourceType: 'Image', transferSize: 70},
+    ]);
+    context = {computedCache: new Map()};
+  });
+
+  it('includes all resource types, regardless of whether page contains them', async () => {
+    const result = await ComputedResourceSummary.request(artifacts, context);
+    assert.equal(Object.keys(result).length, 9);
+  });
+
+  it('sets size and count correctly', async () => {
+    const result = await ComputedResourceSummary.request(artifacts, context);
+    assert.equal(result.script.count, 2);
+    assert.equal(result.script.size, 10 + 50);
+  });
+
+  it('sets "total" resource metrics correctly', async () => {
+    const result = await ComputedResourceSummary.request(artifacts, context);
+    assert.equal(result.total.count, 4);
+    assert.equal(result.total.size, 30 + 10 + 50 + 70);
+  });
+
+  it('sets "other" resource metrics correctly', async () => {
+    // networkRecordsToDevToolsLog errors with an 'other' resource type, so this test does not use it
+    const networkRecords = [
+      {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
+      {url: 'http://third-party.com/another-file.html', resourceType: 'manifest', transferSize: 50},
+    ];
+
+    const result = ComputedResourceSummary.summarize(networkRecords, networkRecords[0].url);
+    assert.equal(result.other.count, 1);
+    assert.equal(result.other.size, 50);
+  });
+
+  describe('determining third-party resources', () => {
+    it('with a third-party resource', async () => {
+      artifacts = mockArtifacts([
+        {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
+        {url: 'http://third-party.com/another-file.html', resourceType: 'Document', transferSize: 50},
+      ]);
+
+      const result = await ComputedResourceSummary.request(artifacts, context);
+      assert.equal(result['third-party'].count, 1);
+      assert.equal(result['third-party'].size, 50);
+    });
+
+    it('with a first-party resource', async () => {
+      artifacts = mockArtifacts([
+        {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
+        {url: 'http://example.com/another-file.html', resourceType: 'Document', transferSize: 50},
+      ]);
+
+      const result = await ComputedResourceSummary.request(artifacts, context);
+      assert.equal(result['third-party'].count, 0);
+      assert.equal(result['third-party'].size, 0);
+    });
+
+    it('with a first-party resource loaded from a subdomain', async () => {
+      artifacts = mockArtifacts([
+        {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
+        {url: 'http://blog.example.com/file.html', resourceType: 'Document', transferSize: 50},
+      ]);
+
+      const result = await ComputedResourceSummary.request(artifacts, context);
+      assert.equal(result['third-party'].count, 0);
+      assert.equal(result['third-party'].size, 0);
+    });
+  });
+});
diff --git a/lighthouse-core/test/config/budget-test.js b/lighthouse-core/test/config/budget-test.js
index 7a56d587b..19de4fb00 100644
--- a/lighthouse-core/test/config/budget-test.js
+++ b/lighthouse-core/test/config/budget-test.js
@@ -43,7 +43,7 @@ describe('Budget', () => {
           {
             metric: 'first-contentful-paint',
             budget: 1000,
-            tolerance: 500,
+            // tolerance is optional, so not defined here.
           },
         ],
       },
@@ -74,55 +74,103 @@ describe('Budget', () => {
 
     // Sets timings correctly
     assert.equal(budgets[0].timings.length, 2);
+    assert.deepStrictEqual(budgets[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.equal(budgets[0].timings[1].tolerance, 500);
 
     // Does not set unsupplied budgets
     assert.equal(budgets[1].timings, null);
   });
 
+  it('accepts an empty array', () => {
+    const budgets = Budget.initializeBudget([]);
+    assert.deepStrictEqual(budgets, []);
+  });
+
   it('throws error if an unsupported budget property is used', () => {
     budget[0].sizes = [];
-    assert.throws(_ => Budget.initializeBudget(budget), /[sizes]/);
+    assert.throws(_ => Budget.initializeBudget(budget),
+      /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/);
+
+      assert.throws(_ => Budget.initializeBudget(['invalid123']),
+        /Budget file is not defined as an array of budgets/);
+
+      assert.throws(_ => Budget.initializeBudget([null]),
+        /Budget file is not defined as an array of budgets/);
+    });
+
+    it('throws when budget contains invalid resourceSizes entry', () => {
+      budget[0].resourceSizes = 55;
+      assert.throws(_ => Budget.initializeBudget(budget),
+        /^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),
+        /^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),
+        /^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), /Invalid resource type/);
+      assert.throws(_ => Budget.initializeBudget(budget),
+        // 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/);
+      assert.throws(_ => Budget.initializeBudget(budget), /Invalid budget: 100 MB/);
     });
 
     it('throws when an invalid property is supplied', () => {
       budget[0].resourceSizes[0].browser = 'Chrome';
-      assert.throws(_ => Budget.initializeBudget(budget), /[browser]/);
+      assert.throws(_ => Budget.initializeBudget(budget),
+        /Resource Budget has unrecognized properties: \[browser\]/);
     });
   });
 
   describe('timing budget validation', () => {
     it('throws when an invalid metric is supplied', () => {
-      budget[0].timings[0].metric = 'lastMeaningfulPaint';
-      assert.throws(_ => Budget.initializeBudget(budget), /Invalid timing metric/);
+      budget[0].timings[0].metric = 'medianMeaningfulPaint';
+      assert.throws(_ => Budget.initializeBudget(budget),
+        // 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/);
+      assert.throws(_ => Budget.initializeBudget(budget), /Invalid budget: 100KB/);
     });
 
     it('throws when an invalid tolerance is supplied', () => {
       budget[0].timings[0].tolerance = '100ms';
-      assert.throws(_ => Budget.initializeBudget(budget), /Invalid tolerance/);
+      assert.throws(_ => Budget.initializeBudget(budget), /Invalid tolerance: 100ms/);
     });
 
     it('throws when an invalid property is supplied', () => {
       budget[0].timings[0].device = 'Phone';
-      assert.throws(_ => Budget.initializeBudget(budget), /[device]/);
+      budget[0].timings[0].location = 'The middle somewhere, I don\'t know';
+      assert.throws(_ => Budget.initializeBudget(budget),
+        /Timing Budget has unrecognized properties: \[device, location\]/);
     });
   });
 });
diff --git a/lighthouse-core/test/config/config-test.js b/lighthouse-core/test/config/config-test.js
index c3c29045b..4bbb22ea0 100644
--- a/lighthouse-core/test/config/config-test.js
+++ b/lighthouse-core/test/config/config-test.js
@@ -761,7 +761,7 @@ describe('Config', () => {
 
       it('should throw when provided an invalid budget', () => {
         assert.throws(() => new Config({settings: {budgets: ['invalid123']}}),
-          /Budget has unrecognized properties/);
+          /Budget file is not defined as an array of budgets/);
       });
     });
 
diff --git a/lighthouse-core/test/lib/network-request-test.js b/lighthouse-core/test/lib/network-request-test.js
index 5abdfa5d6..542f676e4 100644
--- a/lighthouse-core/test/lib/network-request-test.js
+++ b/lighthouse-core/test/lib/network-request-test.js
@@ -90,7 +90,7 @@ describe('NetworkRequest', () => {
       };
     }
 
-    it('updates timings if in Lightrider', () => {
+    it('updates lrStatistics if in Lightrider', () => {
       const req = getRequest();
 
       const devtoolsLog = networkRecordsToDevtoolsLog([req]);
@@ -98,17 +98,8 @@ describe('NetworkRequest', () => {
       const record = NetworkRecorder.recordsFromLogs(devtoolsLog)[0];
 
       expect(record.startTime).toStrictEqual(0);
-      expect(record.endTime).toStrictEqual(10);
-      expect(record.responseReceivedTime).toStrictEqual(7.5);
-      expect(record.timing).toMatchObject({
-        connectStart: 0,
-        connectEnd: 5000,
-        sslStart: 4000,
-        sslEnd: 5000,
-        sendStart: 5000,
-        sendEnd: 5000,
-        receiveHeadersEnd: 7500,
-      });
+      expect(record.endTime).toStrictEqual(2);
+      expect(record.responseReceivedTime).toStrictEqual(1);
       expect(record.lrStatistics).toStrictEqual({
         endTimeDeltaMs: -8000,
         TCPMs: 5000,
@@ -198,18 +189,6 @@ describe('NetworkRequest', () => {
       global.isLightrider = true;
       const record = NetworkRecorder.recordsFromLogs(devtoolsLog)[0];
 
-      expect(record.startTime).toStrictEqual(0);
-      expect(record.endTime).toStrictEqual(10);
-      expect(record.responseReceivedTime).toStrictEqual(0);
-      expect(record.timing).toMatchObject({
-        connectStart: 0,
-        connectEnd: 0,
-        sslStart: 0,
-        sslEnd: 0,
-        sendStart: 0,
-        sendEnd: 0,
-        receiveHeadersEnd: 0,
-      });
       expect(record.lrStatistics).toStrictEqual({
         endTimeDeltaMs: -8000,
         TCPMs: 0,
@@ -228,18 +207,6 @@ describe('NetworkRequest', () => {
       global.isLightrider = true;
       const record = NetworkRecorder.recordsFromLogs(devtoolsLog)[0];
 
-      expect(record.startTime).toStrictEqual(0);
-      expect(record.endTime).toStrictEqual(10);
-      expect(record.responseReceivedTime).toStrictEqual(1);
-      expect(record.timing).toMatchObject({
-        connectStart: 0,
-        connectEnd: 1000,
-        sslStart: 1000,
-        sslEnd: 1000,
-        sendStart: 1000,
-        sendEnd: 1000,
-        receiveHeadersEnd: 1000,
-      });
       expect(record.lrStatistics).toStrictEqual({
         endTimeDeltaMs: -8000,
         TCPMs: 1000,
@@ -248,40 +215,7 @@ describe('NetworkRequest', () => {
       });
     });
 
-    it('created a new timing property if one did not exist', function() {
-      const req = getRequest();
-
-      const devtoolsLog = networkRecordsToDevtoolsLog([req]);
-
-      const noLRRecord = NetworkRecorder.recordsFromLogs(devtoolsLog)[0];
-      expect(noLRRecord.timing).toStrictEqual(undefined);
-
-      global.isLightrider = true;
-      const lrRecord = NetworkRecorder.recordsFromLogs(devtoolsLog)[0];
-      expect(lrRecord.timing.proxyStart).toStrictEqual(-1);
-
-      expect(lrRecord.startTime).toStrictEqual(0);
-      expect(lrRecord.endTime).toStrictEqual(10);
-      expect(lrRecord.responseReceivedTime).toStrictEqual(7.5);
-      expect(lrRecord.timing).toMatchObject({
-        proxyStart: -1,
-        connectStart: 0,
-        connectEnd: 5000,
-        sslStart: 4000,
-        sslEnd: 5000,
-        sendStart: 5000,
-        sendEnd: 5000,
-        receiveHeadersEnd: 7500,
-      });
-      expect(lrRecord.lrStatistics).toStrictEqual({
-        endTimeDeltaMs: -8000,
-        TCPMs: 5000,
-        requestMs: 2500,
-        responseMs: 2500,
-      });
-    });
-
-    it('overrides existing timing properties', function() {
+    it('does not override existing timing properties', function() {
       const req = getRequest();
       req.timing = {proxyStart: 17, sslStart: 35};
       const devtoolsLog = networkRecordsToDevtoolsLog([req]);
@@ -293,18 +227,9 @@ describe('NetworkRequest', () => {
       global.isLightrider = true;
       const lrRecord = NetworkRecorder.recordsFromLogs(devtoolsLog)[0];
 
-      expect(lrRecord.startTime).toStrictEqual(0);
-      expect(lrRecord.endTime).toStrictEqual(10);
-      expect(lrRecord.responseReceivedTime).toStrictEqual(7.5);
       expect(lrRecord.timing).toMatchObject({
-        proxyStart: -1,
-        connectStart: 0,
-        connectEnd: 5000,
-        sslStart: 4000,
-        sslEnd: 5000,
-        sendStart: 5000,
-        sendEnd: 5000,
-        receiveHeadersEnd: 7500,
+        proxyStart: 17,
+        sslStart: 35,
       });
       expect(lrRecord.lrStatistics).toStrictEqual({
         endTimeDeltaMs: -8000,
diff --git a/lighthouse-core/test/report/html/renderer/report-ui-features-test.js b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
index bf47b04d4..8f2b50d59 100644
--- a/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-ui-features-test.js
@@ -26,11 +26,23 @@ const TEMPLATE_FILE_REPORT = fs.readFileSync(__dirname +
   '/../../../../report/html/report-template.html', 'utf8');
 
 describe('ReportUIFeatures', () => {
-  let renderer;
-  let reportUIFeatures;
   let sampleResults;
   let dom;
 
+  /**
+   * @param {LH.JSON} lhr
+   */
+  function render(lhr) {
+    const detailsRenderer = new DetailsRenderer(dom);
+    const categoryRenderer = new CategoryRenderer(dom, detailsRenderer);
+    const renderer = new ReportRenderer(dom, categoryRenderer);
+    const reportUIFeatures = new ReportUIFeatures(dom);
+    const container = dom.find('main', dom._document);
+    renderer.renderReport(lhr, container);
+    reportUIFeatures.initFeatures(lhr);
+    return container;
+  }
+
   beforeAll(() => {
     global.Util = Util;
     global.ReportUIFeatures = ReportUIFeatures;
@@ -72,11 +84,7 @@ describe('ReportUIFeatures', () => {
     };
 
     dom = new DOM(document.window.document);
-    const detailsRenderer = new DetailsRenderer(dom);
-    const categoryRenderer = new CategoryRenderer(dom, detailsRenderer);
-    renderer = new ReportRenderer(dom, categoryRenderer);
     sampleResults = Util.prepareReportResult(sampleResultsOrig);
-    reportUIFeatures = new ReportUIFeatures(dom);
   });
 
   afterAll(() => {
@@ -96,16 +104,11 @@ describe('ReportUIFeatures', () => {
 
   describe('initFeatures', () => {
     it('should init a report', () => {
-      // render a report onto the UIFeature dom
-      const container = reportUIFeatures._dom._document.querySelector('main');
-      renderer.renderReport(sampleResults, container);
-
-      assert.equal(reportUIFeatures.json, undefined);
-      reportUIFeatures.initFeatures(sampleResults);
-      assert.ok(reportUIFeatures.json);
+      const container = render(sampleResults);
+      assert.equal(dom.findAll('.lh-category', container).length, 5);
     });
 
-    describe('thrid-party filtering', () => {
+    describe('third-party filtering', () => {
       let container;
 
       beforeAll(() => {
@@ -129,9 +132,7 @@ describe('ReportUIFeatures', () => {
         ];
 
         // render a report onto the UIFeature dom
-        container = dom.find('main', dom._document);
-        renderer.renderReport(lhr, container);
-        reportUIFeatures.initFeatures(lhr);
+        container = render(lhr);
       });
 
       it('filters out third party resources in details tables when checkbox is clicked', () => {
@@ -161,4 +162,41 @@ describe('ReportUIFeatures', () => {
       });
     });
   });
+
+  describe('metric description toggles', () => {
+    let container;
+    let metricsAuditGroup;
+    let toggle;
+    const metricsClass = 'lh-audit-group--metrics';
+    const toggleClass = 'lh-metrics-toggle__input';
+    const showClass = 'lh-audit-group--metrics__show-descriptions';
+
+    describe('works if there is a performance category', () => {
+      beforeAll(() => {
+        container = render(sampleResults);
+        metricsAuditGroup = dom.find(`.${metricsClass}`, container);
+        toggle = dom.find(`.${toggleClass}`, metricsAuditGroup);
+      });
+
+      it('descriptions hidden by default', () => {
+        assert.ok(!metricsAuditGroup.classList.contains(showClass));
+      });
+
+      it('can toggle description visibility', () => {
+        assert.ok(!metricsAuditGroup.classList.contains(showClass));
+        toggle.click();
+        assert.ok(metricsAuditGroup.classList.contains(showClass));
+        toggle.click();
+        assert.ok(!metricsAuditGroup.classList.contains(showClass));
+      });
+    });
+
+    it('report still works if performance category does not run', () => {
+      const lhr = JSON.parse(JSON.stringify(sampleResults));
+      delete lhr.categories.performance;
+      container = render(lhr);
+      assert.ok(!container.querySelector(`.${metricsClass}`));
+      assert.ok(!container.querySelector(`.${toggleClass}`));
+    });
+  });
 });
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 3a83a0721..f96b06073 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -1910,7 +1910,7 @@
       "score": 0.58,
       "scoreDisplayMode": "numeric",
       "numericValue": 103455,
-      "displayValue": "11 resources found",
+      "displayValue": "10 resources found",
       "details": {
         "type": "table",
         "headings": [
@@ -2003,13 +2003,6 @@
             "cacheHitProbability": 0,
             "totalBytes": 144,
             "wastedBytes": 144
-          },
-          {
-            "url": "blob:http://localhost:10200/ae0eac03-ab9b-4a6a-b299-f5212153e277",
-            "cacheLifetimeMs": 0,
-            "cacheHitProbability": 0,
-            "totalBytes": 0,
-            "wastedBytes": 0
           }
         ],
         "summary": {
@@ -5093,7 +5086,7 @@
       "lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js | displayValue": [
         {
           "values": {
-            "itemCount": 11
+            "itemCount": 10
           },
           "path": "audits[uses-long-cache-ttl].displayValue"
         }