Repository: GoogleChrome/lighthouse
Pull request #10213 (2020-01-10 22:48:55)

core(largest-contentful-paint): update docs link
**Summary**

Updates the LCP `description` to point to https://web.dev/lighthouse-largest-contentful-paint

**Related Issues/PRs**

https://github.com/GoogleChrome/web.dev/pull/1639



Patch:
diff --git a/docs/scoring.md b/docs/scoring.md
index 3ebd0c72b..b52e8fe10 100644
--- a/docs/scoring.md
+++ b/docs/scoring.md
@@ -1,10 +1,10 @@
 # Goal
 The goal of this document is to explain how scoring works in Lighthouse and what to do to improve your Lighthouse scores across the four sections of the report.
 
-Note 1: if you want a **nice spreadsheet** version of this doc to understand weighting and scoring, check out the [scoring spreadsheet](https://docs.google.com/spreadsheets/d/1Cxzhy5ecqJCucdf1M0iOzM8mIxNc7mmx107o5nj38Eo/edit#gid=0)
+Note 1: if you want a **nice spreadsheet** version of this doc to understand weighting and scoring, check out the [scoring spreadsheet](https://docs.google.com/spreadsheets/d/1up5rxd4EMCoMaxH8cppcK1x76n6HLx0e7jxb0e0FXvc)
 
 ![alt text](https://user-images.githubusercontent.com/39191/32397461-2d20c87a-c0a7-11e7-99d8-61576113a710.png)
-*Screenshot of the [scoring spreadsheet](https://docs.google.com/spreadsheets/d/1Cxzhy5ecqJCucdf1M0iOzM8mIxNc7mmx107o5nj38Eo/edit#gid=0)*
+*Screenshot of the [scoring spreadsheet](https://docs.google.com/spreadsheets/d/1up5rxd4EMCoMaxH8cppcK1x76n6HLx0e7jxb0e0FXvc)*
 
 Note 2: receiving a **score of ?** in any Lighthouse category indicates an error occurred. Please file an [issue](https://github.com/GoogleChrome/lighthouse/issues) so our team can look into it.
 
diff --git a/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js b/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
index 75efa984b..abc7a12ae 100644
--- a/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
+++ b/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
@@ -13,7 +13,7 @@ const Audit = require('../audit.js');
 const i18n = require('../../lib/i18n/i18n.js');
 const BaseNode = require('../../lib/dependency-graph/base-node.js');
 const ByteEfficiencyAudit = require('./byte-efficiency-audit.js');
-const UnusedCSS = require('./unused-css-rules.js');
+const UnusedCSS = require('../../computed/unused-css.js');
 const NetworkRequest = require('../../lib/network-request.js');
 const TraceOfTab = require('../../computed/trace-of-tab.js');
 const LoadSimulator = require('../../computed/load-simulator.js');
@@ -187,11 +187,13 @@ class RenderBlockingResources extends Audit {
   static async computeWastedCSSBytes(artifacts, context) {
     const wastedBytesByUrl = new Map();
     try {
-      const results = await UnusedCSS.audit(artifacts, context);
-      if (results.details && results.details.type === 'opportunity') {
-        for (const item of results.details.items) {
-          wastedBytesByUrl.set(item.url, item.wastedBytes);
-        }
+      const unusedCssItems = await UnusedCSS.request({
+        CSSUsage: artifacts.CSSUsage,
+        URL: artifacts.URL,
+        devtoolsLog: artifacts.devtoolsLogs[Audit.DEFAULT_PASS],
+      }, context);
+      for (const item of unusedCssItems) {
+        wastedBytesByUrl.set(item.url, item.wastedBytes);
       }
     } catch (_) {}
 
diff --git a/lighthouse-core/audits/byte-efficiency/unminified-css.js b/lighthouse-core/audits/byte-efficiency/unminified-css.js
index 8fb2f155d..038c77d00 100644
--- a/lighthouse-core/audits/byte-efficiency/unminified-css.js
+++ b/lighthouse-core/audits/byte-efficiency/unminified-css.js
@@ -6,7 +6,7 @@
 'use strict';
 
 const ByteEfficiencyAudit = require('./byte-efficiency-audit.js');
-const UnusedCSSRules = require('./unused-css-rules.js');
+const UnusedCSS = require('../../computed/unused-css.js');
 const i18n = require('../../lib/i18n/i18n.js');
 const computeTokenLength = require('../../lib/minification-estimator.js').computeCSSTokenLength;
 
@@ -62,7 +62,7 @@ class UnminifiedCSS extends ByteEfficiencyAudit {
 
     let url = stylesheet.header.sourceURL;
     if (!url || url === pageUrl) {
-      const contentPreview = UnusedCSSRules.determineContentPreview(stylesheet.content);
+      const contentPreview = UnusedCSS.determineContentPreview(stylesheet.content);
       url = contentPreview;
     }
 
diff --git a/lighthouse-core/audits/byte-efficiency/unused-css-rules.js b/lighthouse-core/audits/byte-efficiency/unused-css-rules.js
index 01c189dd7..36c248af3 100644
--- a/lighthouse-core/audits/byte-efficiency/unused-css-rules.js
+++ b/lighthouse-core/audits/byte-efficiency/unused-css-rules.js
@@ -6,6 +6,7 @@
 'use strict';
 
 const ByteEfficiencyAudit = require('./byte-efficiency-audit.js');
+const UnusedCSS = require('../../computed/unused-css.js');
 const i18n = require('../../lib/i18n/i18n.js');
 
 const UIStrings = {
@@ -22,9 +23,6 @@ const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 // Allow 10KB of unused CSS to permit `:hover` and other styles not used on a non-interactive load.
 // @see https://github.com/GoogleChrome/lighthouse/issues/9353 for more discussion.
 const IGNORE_THRESHOLD_IN_BYTES = 10 * 1024;
-const PREVIEW_LENGTH = 100;
-
-/** @typedef {LH.Artifacts.CSSStyleSheetInfo & {networkRecord: LH.Artifacts.NetworkRequest, usedRules: Array<LH.Crdp.CSS.RuleUsage>}} StyleSheetInfo */
 
 class UnusedCSSRules extends ByteEfficiencyAudit {
   /**
@@ -40,156 +38,32 @@ class UnusedCSSRules extends ByteEfficiencyAudit {
     };
   }
 
-  /**
-   * @param {Array<LH.Artifacts.CSSStyleSheetInfo>} styles The output of the Styles gatherer.
-   * @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
-   * @return {Object<string, StyleSheetInfo>} A map of styleSheetId to stylesheet information.
-   */
-  static indexStylesheetsById(styles, networkRecords) {
-    const indexedNetworkRecords = networkRecords
-        // Some phantom network records appear with a 0 resourceSize that aren't real.
-        // A network record that has no size data is just as good as no network record at all for our
-        // purposes, so we'll just filter them out. https://github.com/GoogleChrome/lighthouse/issues/9684#issuecomment-532381611
-        .filter(record => record.resourceSize > 0)
-        .reduce((indexed, record) => {
-          indexed[record.url] = record;
-          return indexed;
-        }, /** @type {Object<string, LH.Artifacts.NetworkRequest>} */ ({}));
-
-    return styles.reduce((indexed, stylesheet) => {
-      indexed[stylesheet.header.styleSheetId] = Object.assign({
-        usedRules: [],
-        networkRecord: indexedNetworkRecords[stylesheet.header.sourceURL],
-      }, stylesheet);
-      return indexed;
-    }, /** @type {Object<string, StyleSheetInfo>} */ ({}));
-  }
-
-  /**
-   * Adds used rules to their corresponding stylesheet.
-   * @param {Array<LH.Crdp.CSS.RuleUsage>} rules The output of the CSSUsage gatherer.
-   * @param {Object<string, StyleSheetInfo>} indexedStylesheets Stylesheet information indexed by id.
-   */
-  static indexUsedRules(rules, indexedStylesheets) {
-    rules.forEach(rule => {
-      const stylesheetInfo = indexedStylesheets[rule.styleSheetId];
-
-      if (!stylesheetInfo) {
-        return;
-      }
-
-      if (rule.used) {
-        stylesheetInfo.usedRules.push(rule);
-      }
-    });
-  }
-
-  /**
-   * @param {StyleSheetInfo} stylesheetInfo
-   * @return {{wastedBytes: number, totalBytes: number, wastedPercent: number}}
-   */
-  static computeUsage(stylesheetInfo) {
-    let usedUncompressedBytes = 0;
-    const totalUncompressedBytes = stylesheetInfo.content.length;
-
-    for (const usedRule of stylesheetInfo.usedRules) {
-      usedUncompressedBytes += usedRule.endOffset - usedRule.startOffset;
-    }
-
-    const totalTransferredBytes = ByteEfficiencyAudit.estimateTransferSize(
-        stylesheetInfo.networkRecord, totalUncompressedBytes, 'Stylesheet');
-    const percentUnused = (totalUncompressedBytes - usedUncompressedBytes) / totalUncompressedBytes;
-    const wastedBytes = Math.round(percentUnused * totalTransferredBytes);
-
-    return {
-      wastedBytes,
-      wastedPercent: percentUnused * 100,
-      totalBytes: totalTransferredBytes,
-    };
-  }
-
-  /**
-   * Trims stylesheet content down to the first rule-set definition.
-   * @param {string=} content
-   * @return {string}
-   */
-  static determineContentPreview(content) {
-    let preview = (content || '')
-        .slice(0, PREVIEW_LENGTH * 5)
-        .replace(/( {2,}|\t)+/g, '  ') // remove leading indentation if present
-        .replace(/\n\s+}/g, '\n}') // completely remove indentation of closing braces
-        .trim(); // trim the leading whitespace
-
-    if (preview.length > PREVIEW_LENGTH) {
-      const firstRuleStart = preview.indexOf('{');
-      const firstRuleEnd = preview.indexOf('}');
-
-      if (firstRuleStart === -1 || firstRuleEnd === -1
-          || firstRuleStart > firstRuleEnd
-          || firstRuleStart > PREVIEW_LENGTH) {
-        // We couldn't determine the first rule-set or it's not within the preview
-        preview = preview.slice(0, PREVIEW_LENGTH) + '...';
-      } else if (firstRuleEnd < PREVIEW_LENGTH) {
-        // The entire first rule-set fits within the preview
-        preview = preview.slice(0, firstRuleEnd + 1) + ' ...';
-      } else {
-        // The first rule-set doesn't fit within the preview, just show as many as we can
-        const lastSemicolonIndex = preview.slice(0, PREVIEW_LENGTH).lastIndexOf(';');
-        preview = lastSemicolonIndex < firstRuleStart ?
-            preview.slice(0, PREVIEW_LENGTH) + '... } ...' :
-            preview.slice(0, lastSemicolonIndex + 1) + ' ... } ...';
-      }
-    }
-
-    return preview;
-  }
-
-  /**
-   * @param {StyleSheetInfo} stylesheetInfo The stylesheetInfo object.
-   * @param {string} pageUrl The URL of the page, used to identify inline styles.
-   * @return {LH.Audit.ByteEfficiencyItem}
-   */
-  static mapSheetToResult(stylesheetInfo, pageUrl) {
-    let url = stylesheetInfo.header.sourceURL;
-    if (!url || url === pageUrl) {
-      const contentPreview = UnusedCSSRules.determineContentPreview(stylesheetInfo.content);
-      url = contentPreview;
-    }
-
-    const usage = UnusedCSSRules.computeUsage(stylesheetInfo);
-    return {url, ...usage};
-  }
-
   /**
    * @param {LH.Artifacts} artifacts
-   * @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
+   * @param {LH.Artifacts.NetworkRequest[]} _
+   * @param {LH.Audit.Context} context
    * @return {Promise<ByteEfficiencyAudit.ByteEfficiencyProduct>}
    */
-  static audit_(artifacts, networkRecords) {
-    const styles = artifacts.CSSUsage.stylesheets;
-    const usage = artifacts.CSSUsage.rules;
-    const pageUrl = artifacts.URL.finalUrl;
-
-    return Promise.resolve(networkRecords).then(networkRecords => {
-      const indexedSheets = UnusedCSSRules.indexStylesheetsById(styles, networkRecords);
-      UnusedCSSRules.indexUsedRules(usage, indexedSheets);
+  static async audit_(artifacts, _, context) {
+    const unusedCssItems = await UnusedCSS.request({
+      CSSUsage: artifacts.CSSUsage,
+      URL: artifacts.URL,
+      devtoolsLog: artifacts.devtoolsLogs[ByteEfficiencyAudit.DEFAULT_PASS],
+    }, context);
+    const items = unusedCssItems
+      .filter(sheet => sheet && sheet.wastedBytes > IGNORE_THRESHOLD_IN_BYTES);
+
+    /** @type {LH.Audit.Details.Opportunity['headings']} */
+    const headings = [
+      {key: 'url', valueType: 'url', label: str_(i18n.UIStrings.columnURL)},
+      {key: 'totalBytes', valueType: 'bytes', label: str_(i18n.UIStrings.columnSize)},
+      {key: 'wastedBytes', valueType: 'bytes', label: str_(i18n.UIStrings.columnWastedBytes)},
+    ];
 
-      const items = Object.keys(indexedSheets)
-          .map(sheetId => UnusedCSSRules.mapSheetToResult(indexedSheets[sheetId], pageUrl))
-          .filter(sheet => sheet && sheet.wastedBytes > IGNORE_THRESHOLD_IN_BYTES);
-
-      /** @type {LH.Audit.Details.Opportunity['headings']} */
-      const headings = [
-        {key: 'url', valueType: 'url', label: str_(i18n.UIStrings.columnURL)},
-        {key: 'totalBytes', valueType: 'bytes', label: str_(i18n.UIStrings.columnSize)},
-        {key: 'wastedBytes', valueType: 'bytes', label: str_(i18n.UIStrings.columnWastedBytes)},
-      ];
-
-      return {
-        items,
-        headings,
-      };
-    });
+    return {
+      items,
+      headings,
+    };
   }
 }
 
diff --git a/lighthouse-core/audits/metrics/largest-contentful-paint.js b/lighthouse-core/audits/metrics/largest-contentful-paint.js
index 61ad7f4d0..99308fd98 100644
--- a/lighthouse-core/audits/metrics/largest-contentful-paint.js
+++ b/lighthouse-core/audits/metrics/largest-contentful-paint.js
@@ -14,7 +14,7 @@ const UIStrings = {
   title: 'Largest Contentful Paint',
   /** Description of the Largest Contentful Paint (LCP) metric, which marks the time at which the largest text or image is painted by the browser. This is displayed within a tooltip when the user hovers on the metric name to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Largest Contentful Paint marks the time at which the largest text or image is ' +
-      `painted. [Learn More](https://web.dev/largest-contentful-paint)`, // TODO: waiting on LH specific doc.
+      `painted. [Learn More](https://web.dev/lighthouse-largest-contentful-paint)`,
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
diff --git a/lighthouse-core/audits/seo/font-size.js b/lighthouse-core/audits/seo/font-size.js
index cff60a696..9f5a6e42b 100644
--- a/lighthouse-core/audits/seo/font-size.js
+++ b/lighthouse-core/audits/seo/font-size.js
@@ -24,9 +24,6 @@ const UIStrings = {
   /** Explanatory message stating that there was a failure in an audit caused by a missing page viewport meta tag configuration. "viewport" and "meta" are HTML terms and should not be translated. */
   explanationViewport: 'Text is illegible because there\'s no viewport meta tag optimized ' +
     'for mobile screens.',
-  /** Explanatory message stating that there was a failure in an audit caused by a certain percentage of the text on the page being too small, based on a sample size of text that was less than 100% of the text on the page. "decimalProportion" will be replaced by a percentage between 0 and 100%. */
-  explanationWithDisclaimer: '{decimalProportion, number, extendedPercent} of text is too ' +
-    'small (based on {decimalProportionVisited, number, extendedPercent} sample).',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
@@ -254,7 +251,6 @@ class FontSize extends Audit {
       analyzedFailingNodesData,
       analyzedFailingTextLength,
       failingTextLength,
-      visitedTextLength,
       totalTextLength,
     } = artifacts.FontSize;
 
@@ -266,7 +262,7 @@ class FontSize extends Audit {
 
     const failingRules = getUniqueFailingRules(analyzedFailingNodesData);
     const percentageOfPassingText =
-      (visitedTextLength - failingTextLength) / visitedTextLength * 100;
+      (totalTextLength - failingTextLength) / totalTextLength * 100;
     const pageUrl = artifacts.URL.finalUrl;
 
     /** @type {LH.Audit.Details.Table['headings']} */
@@ -279,7 +275,7 @@ class FontSize extends Audit {
 
     const tableData = failingRules.sort((a, b) => b.textLength - a.textLength)
       .map(({cssRule, textLength, fontSize, node}) => {
-        const percentageOfAffectedText = textLength / visitedTextLength * 100;
+        const percentageOfAffectedText = textLength / totalTextLength * 100;
         const origin = findStyleRuleSource(pageUrl, cssRule, node);
 
         return {
@@ -293,7 +289,7 @@ class FontSize extends Audit {
     // all failing nodes that were not fully analyzed will be displayed in a single row
     if (analyzedFailingTextLength < failingTextLength) {
       const percentageOfUnanalyzedFailingText =
-        (failingTextLength - analyzedFailingTextLength) / visitedTextLength * 100;
+        (failingTextLength - analyzedFailingTextLength) / totalTextLength * 100;
 
       tableData.push({
         // Overrides default `source-location`
@@ -319,26 +315,10 @@ class FontSize extends Audit {
     const details = Audit.makeTableDetails(headings, tableData);
     const passed = percentageOfPassingText >= MINIMAL_PERCENTAGE_OF_LEGIBLE_TEXT;
 
-    let explanation;
-    if (!passed) {
-      const percentageOfFailingText = (100 - percentageOfPassingText) / 100;
-
-      // if we were unable to visit all text nodes we should disclose that information
-      if (visitedTextLength < totalTextLength) {
-        const percentageOfVisitedText = (visitedTextLength / totalTextLength);
-        explanation = str_(UIStrings.explanationWithDisclaimer,
-          {
-            decimalProportion: percentageOfFailingText,
-            decimalProportionVisited: percentageOfVisitedText,
-          });
-      }
-    }
-
     return {
       score: Number(passed),
       details,
       displayValue,
-      explanation,
     };
   }
 }
diff --git a/lighthouse-core/audits/seo/link-text.js b/lighthouse-core/audits/seo/link-text.js
index 0eac23289..a01b4d33c 100644
--- a/lighthouse-core/audits/seo/link-text.js
+++ b/lighthouse-core/audits/seo/link-text.js
@@ -13,11 +13,16 @@ const BLOCKLIST = new Set([
   'click this',
   'go',
   'here',
-  'this',
-  'start',
-  'right here',
-  'more',
+  'information',
   'learn more',
+  'more',
+  'more info',
+  'more information',
+  'right here',
+  'read more',
+  'see more',
+  'start',
+  'this',
   // Japanese
   'ここをクリック',
   'こちらをクリック',
@@ -63,6 +68,13 @@ const BLOCKLIST = new Set([
   '계속',
   '이동',
   '전체 보기',
+  // Swedish
+  'här',
+  'klicka här',
+  'läs mer',
+  'mer',
+  'mer info',
+  'mer information',
 ]);
 const i18n = require('../../lib/i18n/i18n.js');
 
diff --git a/lighthouse-core/computed/resource-summary.js b/lighthouse-core/computed/resource-summary.js
index b1d943bdc..4f8bf86a2 100644
--- a/lighthouse-core/computed/resource-summary.js
+++ b/lighthouse-core/computed/resource-summary.js
@@ -51,6 +51,12 @@ class ResourceSummary {
 
     for (const record of networkRecords) {
       const type = this.determineResourceType(record);
+      if (type === 'other' && record.url.endsWith('/favicon.ico')) {
+        // Headless Chrome does not request /favicon.ico, so don't consider this request.
+        // Makes resource summary consistent across LR / other channels.
+        continue;
+      }
+
       resourceSummary[type].count++;
       resourceSummary[type].size += record.transferSize;
 
diff --git a/lighthouse-core/computed/unused-css.js b/lighthouse-core/computed/unused-css.js
new file mode 100644
index 000000000..1bb08c1d5
--- /dev/null
+++ b/lighthouse-core/computed/unused-css.js
@@ -0,0 +1,154 @@
+/**
+ * @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 ByteEfficiencyAudit = require('../audits/byte-efficiency/byte-efficiency-audit.js');
+const NetworkRecords = require('./network-records.js');
+
+const PREVIEW_LENGTH = 100;
+
+/** @typedef {LH.Artifacts.CSSStyleSheetInfo & {networkRecord: LH.Artifacts.NetworkRequest, usedRules: Array<LH.Crdp.CSS.RuleUsage>}} StyleSheetInfo */
+
+class UnusedCSS {
+  /**
+   * @param {Array<LH.Artifacts.CSSStyleSheetInfo>} styles The output of the Styles gatherer.
+   * @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
+   * @return {Object<string, StyleSheetInfo>} A map of styleSheetId to stylesheet information.
+   */
+  static indexStylesheetsById(styles, networkRecords) {
+    const indexedNetworkRecords = networkRecords
+        // Some phantom network records appear with a 0 resourceSize that aren't real.
+        // A network record that has no size data is just as good as no network record at all for our
+        // purposes, so we'll just filter them out. https://github.com/GoogleChrome/lighthouse/issues/9684#issuecomment-532381611
+        .filter(record => record.resourceSize > 0)
+        .reduce((indexed, record) => {
+          indexed[record.url] = record;
+          return indexed;
+        }, /** @type {Object<string, LH.Artifacts.NetworkRequest>} */ ({}));
+
+    return styles.reduce((indexed, stylesheet) => {
+      indexed[stylesheet.header.styleSheetId] = Object.assign({
+        usedRules: [],
+        networkRecord: indexedNetworkRecords[stylesheet.header.sourceURL],
+      }, stylesheet);
+      return indexed;
+    }, /** @type {Object<string, StyleSheetInfo>} */ ({}));
+  }
+
+  /**
+   * Adds used rules to their corresponding stylesheet.
+   * @param {Array<LH.Crdp.CSS.RuleUsage>} rules The output of the CSSUsage gatherer.
+   * @param {Object<string, StyleSheetInfo>} indexedStylesheets Stylesheet information indexed by id.
+   */
+  static indexUsedRules(rules, indexedStylesheets) {
+    rules.forEach(rule => {
+      const stylesheetInfo = indexedStylesheets[rule.styleSheetId];
+
+      if (!stylesheetInfo) {
+        return;
+      }
+
+      if (rule.used) {
+        stylesheetInfo.usedRules.push(rule);
+      }
+    });
+  }
+
+  /**
+   * @param {StyleSheetInfo} stylesheetInfo
+   * @return {{wastedBytes: number, totalBytes: number, wastedPercent: number}}
+   */
+  static computeUsage(stylesheetInfo) {
+    let usedUncompressedBytes = 0;
+    const totalUncompressedBytes = stylesheetInfo.content.length;
+
+    for (const usedRule of stylesheetInfo.usedRules) {
+      usedUncompressedBytes += usedRule.endOffset - usedRule.startOffset;
+    }
+
+    const totalTransferredBytes = ByteEfficiencyAudit.estimateTransferSize(
+        stylesheetInfo.networkRecord, totalUncompressedBytes, 'Stylesheet');
+    const percentUnused = (totalUncompressedBytes - usedUncompressedBytes) / totalUncompressedBytes;
+    const wastedBytes = Math.round(percentUnused * totalTransferredBytes);
+
+    return {
+      wastedBytes,
+      wastedPercent: percentUnused * 100,
+      totalBytes: totalTransferredBytes,
+    };
+  }
+
+  /**
+   * Trims stylesheet content down to the first rule-set definition.
+   * @param {string=} content
+   * @return {string}
+   */
+  static determineContentPreview(content) {
+    let preview = (content || '')
+        .slice(0, PREVIEW_LENGTH * 5)
+        .replace(/( {2,}|\t)+/g, '  ') // remove leading indentation if present
+        .replace(/\n\s+}/g, '\n}') // completely remove indentation of closing braces
+        .trim(); // trim the leading whitespace
+
+    if (preview.length > PREVIEW_LENGTH) {
+      const firstRuleStart = preview.indexOf('{');
+      const firstRuleEnd = preview.indexOf('}');
+
+      if (firstRuleStart === -1 || firstRuleEnd === -1
+          || firstRuleStart > firstRuleEnd
+          || firstRuleStart > PREVIEW_LENGTH) {
+        // We couldn't determine the first rule-set or it's not within the preview
+        preview = preview.slice(0, PREVIEW_LENGTH) + '...';
+      } else if (firstRuleEnd < PREVIEW_LENGTH) {
+        // The entire first rule-set fits within the preview
+        preview = preview.slice(0, firstRuleEnd + 1) + ' ...';
+      } else {
+        // The first rule-set doesn't fit within the preview, just show as many as we can
+        const lastSemicolonIndex = preview.slice(0, PREVIEW_LENGTH).lastIndexOf(';');
+        preview = lastSemicolonIndex < firstRuleStart ?
+            preview.slice(0, PREVIEW_LENGTH) + '... } ...' :
+            preview.slice(0, lastSemicolonIndex + 1) + ' ... } ...';
+      }
+    }
+
+    return preview;
+  }
+
+  /**
+   * @param {StyleSheetInfo} stylesheetInfo The stylesheetInfo object.
+   * @param {string} pageUrl The URL of the page, used to identify inline styles.
+   * @return {LH.Audit.ByteEfficiencyItem}
+   */
+  static mapSheetToResult(stylesheetInfo, pageUrl) {
+    let url = stylesheetInfo.header.sourceURL;
+    if (!url || url === pageUrl) {
+      const contentPreview = UnusedCSS.determineContentPreview(stylesheetInfo.content);
+      url = contentPreview;
+    }
+
+    const usage = UnusedCSS.computeUsage(stylesheetInfo);
+    return {url, ...usage};
+  }
+
+  /**
+   * @param {{CSSUsage: LH.Artifacts['CSSUsage'], URL: LH.Artifacts['URL'], devtoolsLog: LH.DevtoolsLog}} data
+   * @param {LH.Audit.Context} context
+   * @return {Promise<LH.Audit.ByteEfficiencyItem[]>}
+  */
+  static async compute_(data, context) {
+    const {CSSUsage, URL, devtoolsLog} = data;
+    const networkRecords = await NetworkRecords.request(devtoolsLog, context);
+    const indexedSheets = UnusedCSS.indexStylesheetsById(CSSUsage.stylesheets, networkRecords);
+    UnusedCSS.indexUsedRules(CSSUsage.rules, indexedSheets);
+
+    const items = Object.keys(indexedSheets)
+      .map(sheetId => UnusedCSS.mapSheetToResult(indexedSheets[sheetId], URL.finalUrl));
+    return items;
+  }
+}
+
+module.exports = makeComputedArtifact(UnusedCSS);
diff --git a/lighthouse-core/gather/gather-runner.js b/lighthouse-core/gather/gather-runner.js
index e9b73a50b..b8b3f0044 100644
--- a/lighthouse-core/gather/gather-runner.js
+++ b/lighthouse-core/gather/gather-runner.js
@@ -13,6 +13,20 @@ const NetworkAnalyzer = require('../lib/dependency-graph/simulator/network-analy
 const NetworkRecorder = require('../lib/network-recorder.js');
 const constants = require('../config/constants.js');
 const i18n = require('../lib/i18n/i18n.js');
+const URL = require('../lib/url-shim.js');
+
+const UIStrings = {
+  /**
+   * @description Warning that the web page redirected during testing and that may have affected the load.
+   * @example {https://example.com/requested/page} requested
+   * @example {https://example.com/final/resolved/page} final
+   */
+  warningRedirected: 'The page may not be loading as expected because your test URL ' +
+  `({requested}) was redirected to {final}. ` +
+  'Try testing the second URL directly.',
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 
 /** @typedef {import('../gather/driver.js')} Driver */
 
@@ -490,6 +504,13 @@ class GatherRunner {
 
     // Copy redirected URL to artifact.
     baseArtifacts.URL.finalUrl = passContext.url;
+    /* eslint-disable max-len */
+    if (!URL.equalWithExcludedFragments(baseArtifacts.URL.requestedUrl, baseArtifacts.URL.finalUrl)) {
+      baseArtifacts.LighthouseRunWarnings.push(str_(UIStrings.warningRedirected, {
+        requested: baseArtifacts.URL.requestedUrl,
+        final: baseArtifacts.URL.finalUrl,
+      }));
+    }
 
     // Fetch the manifest, if it exists.
     baseArtifacts.WebAppManifest = await GatherRunner.getWebAppManifest(passContext);
@@ -682,3 +703,4 @@ class GatherRunner {
 }
 
 module.exports = GatherRunner;
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/gather/gatherers/script-elements.js b/lighthouse-core/gather/gatherers/script-elements.js
index cb01c66c4..c2199056a 100644
--- a/lighthouse-core/gather/gatherers/script-elements.js
+++ b/lighthouse-core/gather/gatherers/script-elements.js
@@ -38,6 +38,27 @@ function collectAllScriptElements() {
   });
 }
 
+/**
+ * @template T, U
+ * @param {Array<T>} values
+ * @param {(value: T) => Promise<U>} promiseMapper
+ * @param {boolean} runInSeries
+ * @return {Promise<Array<U>>}
+ */
+async function runInSeriesOrParallel(values, promiseMapper, runInSeries) {
+  if (runInSeries) {
+    const results = [];
+    for (const value of values) {
+      const result = await promiseMapper(value);
+      results.push(result);
+    }
+    return results;
+  } else {
+    const promises = values.map(promiseMapper);
+    return await Promise.all(promises);
+  }
+}
+
 /**
  * @fileoverview Gets JavaScript file contents.
  */
@@ -68,29 +89,35 @@ class ScriptElements extends Gatherer {
       // Only get the content of script requests
       .filter(record => record.resourceType === NetworkRequest.TYPES.Script);
 
-    for (const record of scriptRecords) {
-      try {
-        const content = await driver.getRequestContent(record.requestId);
-        if (!content) continue;
+    // If run on a mobile device, be sensitive to memory limitations and only request one
+    // record at a time.
+    const scriptRecordContents = await runInSeriesOrParallel(
+      scriptRecords,
+      record => driver.getRequestContent(record.requestId).catch(() => ''),
+      passContext.baseArtifacts.HostFormFactor === 'mobile' /* runInSeries*/ );
+
+    for (let i = 0; i < scriptRecords.length; i++) {
+      const record = scriptRecords[i];
+      const content = scriptRecordContents[i];
+      if (!content) continue;
 
-        const matchedScriptElement = scripts.find(script => script.src === record.url);
-        if (matchedScriptElement) {
-          matchedScriptElement.requestId = record.requestId;
-          matchedScriptElement.content = content;
-        } else {
-          scripts.push({
-            devtoolsNodePath: '',
-            type: null,
-            src: record.url,
-            id: null,
-            async: false,
-            defer: false,
-            source: 'network',
-            requestId: record.requestId,
-            content,
-          });
-        }
-      } catch (e) {}
+      const matchedScriptElement = scripts.find(script => script.src === record.url);
+      if (matchedScriptElement) {
+        matchedScriptElement.requestId = record.requestId;
+        matchedScriptElement.content = content;
+      } else {
+        scripts.push({
+          devtoolsNodePath: '',
+          type: null,
+          src: record.url,
+          id: null,
+          async: false,
+          defer: false,
+          source: 'network',
+          requestId: record.requestId,
+          content,
+        });
+      }
     }
 
     return scripts;
diff --git a/lighthouse-core/gather/gatherers/seo/font-size.js b/lighthouse-core/gather/gatherers/seo/font-size.js
index 3e54da271..58f6b4648 100644
--- a/lighthouse-core/gather/gatherers/seo/font-size.js
+++ b/lighthouse-core/gather/gatherers/seo/font-size.js
@@ -17,20 +17,15 @@
  */
 
 const Gatherer = require('../gatherer.js');
-const Sentry = require('../../../lib/sentry.js');
 const FONT_SIZE_PROPERTY_NAME = 'font-size';
-const TEXT_NODE_BLOCK_LIST = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT']);
 const MINIMAL_LEGIBLE_FONT_SIZE_PX = 12;
 // limit number of protocol calls to make sure that gatherer doesn't take more than 1-2s
-const MAX_NODES_VISITED = 500; // number of nodes to get the text length and compute font-size
 const MAX_NODES_SOURCE_RULE_FETCHED = 50; // number of nodes to fetch the source font-size rule
 
-/** DevTools uses a numeric enum for nodeType */
-const TEXT_NODE_TYPE = 3;
-
 /** @typedef {import('../../driver.js')} Driver */
 /** @typedef {LH.Artifacts.FontSize['analyzedFailingNodesData'][0]} NodeFontData */
 /** @typedef {LH.Artifacts.FontSize.DomNodeMaybeWithParent} DomNodeMaybeWithParent*/
+/** @typedef {Map<number, {fontSize: number, textLength: number}>} BackendIdsToFontData */
 
 /**
  * @param {LH.Artifacts.FontSize.DomNodeMaybeWithParent=} node
@@ -50,7 +45,7 @@ function nodeInBody(node) {
  * Get list of all nodes from the document body.
  *
  * @param {Driver} driver
- * @returns {Promise<Array<LH.Artifacts.FontSize.DomNodeWithParent>>}
+ * @returns {Promise<LH.Artifacts.FontSize.DomNodeWithParent[]>}
  */
 async function getAllNodesFromBody(driver) {
   const nodes = /** @type {DomNodeMaybeWithParent[]} */ (await driver.getNodesInDocument());
@@ -181,12 +176,12 @@ function getEffectiveFontRule({attributesStyle, inlineStyle, matchedCSSRules, in
 }
 
 /**
- * @param {LH.Crdp.DOM.Node} node
+ * @param {string} text
  * @returns {number}
  */
-function getNodeTextLength(node) {
+function getTextLength(text) {
   // Array.from to count symbols not unicode code points. See: #6973
-  return !node.nodeValue ? 0 : Array.from(node.nodeValue.trim()).length;
+  return !text ? 0 : Array.from(text.trim()).length;
 }
 
 /**
@@ -212,79 +207,7 @@ async function fetchSourceRule(driver, node) {
   };
 }
 
-/**
- * @param {Driver} driver
- * @param {LH.Artifacts.FontSize.DomNodeWithParent} textNode
- * @returns {Promise<?NodeFontData>}
- */
-async function fetchComputedFontSize(driver, textNode) {
-  try {
-    const {computedStyle} = await driver.sendCommand('CSS.getComputedStyleForNode', {
-      nodeId: textNode.parentId,
-    });
-
-    const fontSizeProperty = computedStyle.find(({name}) => name === FONT_SIZE_PROPERTY_NAME);
-
-    return {
-      // @ts-ignore - font size property guaranteed to be returned in getComputedStyle
-      fontSize: parseInt(fontSizeProperty.value, 10),
-      textLength: getNodeTextLength(textNode),
-      node: textNode.parentNode,
-    };
-  } catch (err) {
-    Sentry.captureException(err, {tags: {gatherer: 'FontSize'}});
-    return null;
-  }
-}
-
-/**
- * @param {LH.Artifacts.FontSize.DomNodeWithParent} node
- * @returns {boolean}
- */
-function isNonEmptyTextNode(node) {
-  return (
-    node.nodeType === TEXT_NODE_TYPE &&
-    !TEXT_NODE_BLOCK_LIST.has(node.parentNode.nodeName) &&
-    getNodeTextLength(node) > 0
-  );
-}
-
 class FontSize extends Gatherer {
-  /**
-   * @param {LH.Gatherer.PassContext['driver']} driver
-   */
-  static async fetchNodesToAnalyze(driver) {
-    let failingTextLength = 0;
-    let visitedTextLength = 0;
-    let totalTextLength = 0;
-
-    const nodes = await getAllNodesFromBody(driver);
-
-    const textNodes = nodes.filter(isNonEmptyTextNode);
-    totalTextLength = textNodes.reduce((sum, node) => (sum += getNodeTextLength(node)), 0);
-
-    const nodesToVisit = textNodes
-      .sort((a, b) => getNodeTextLength(b) - getNodeTextLength(a))
-      .slice(0, MAX_NODES_VISITED);
-
-    const nodePromises = nodesToVisit.map(node => fetchComputedFontSize(driver, node));
-    const visitedNodes = await Promise.all(nodePromises);
-
-    /** @type {Array<NodeFontData>} */
-    const failingNodes = [];
-    for (const visitedNode of visitedNodes) {
-      if (!visitedNode) continue;
-      visitedTextLength += visitedNode.textLength;
-
-      if (visitedNode.fontSize < MINIMAL_LEGIBLE_FONT_SIZE_PX) {
-        failingNodes.push(visitedNode);
-        failingTextLength += visitedNode.textLength;
-      }
-    }
-
-    return {totalTextLength, visitedTextLength, failingTextLength, failingNodes};
-  }
-
   /**
    * @param {LH.Gatherer.PassContext['driver']} driver
    * @param {Array<NodeFontData>} failingNodes
@@ -315,6 +238,80 @@ class FontSize extends Gatherer {
     return {analyzedFailingNodesData, analyzedFailingTextLength};
   }
 
+  /**
+   * Maps backendNodeId of TextNodes to {fontSize, textLength}.
+   *
+   * @param {LH.Crdp.DOMSnapshot.CaptureSnapshotResponse} snapshot
+   * @return {BackendIdsToFontData}
+   */
+  calculateBackendIdsToFontData(snapshot) {
+    const strings = snapshot.strings;
+
+    /** @type {BackendIdsToFontData} */
+    const backendIdsToFontData = new Map();
+
+    for (let j = 0; j < snapshot.documents.length; j++) {
+      // `doc` is a flattened property list describing all the Nodes in a document, with all string
+      // values deduped in the `strings` array.
+      const doc = snapshot.documents[j];
+
+      if (!doc.nodes.backendNodeId) {
+        throw new Error('Unexpected response from DOMSnapshot.captureSnapshot.');
+      }
+
+      for (const layoutIndex of doc.textBoxes.layoutIndex) {
+        const text = strings[doc.layout.text[layoutIndex]];
+        if (!text) continue;
+
+        const nodeIndex = doc.layout.nodeIndex[layoutIndex];
+        const styles = doc.layout.styles[layoutIndex];
+        const [fontSizeStringId] = styles;
+
+        const fontSize = parseFloat(strings[fontSizeStringId]);
+        backendIdsToFontData.set(doc.nodes.backendNodeId[nodeIndex], {
+          fontSize,
+          textLength: getTextLength(text),
+        });
+      }
+    }
+
+    return backendIdsToFontData;
+  }
+
+  /**
+   * The only connection between a snapshot Node and an actual Protocol Node is backendId,
+   * so that is used to join the two data structures. DOMSnapshot.captureSnapshot doesn't
+   * give the entire Node object, so DOM.getFlattenedDocument is used.
+   * @param {BackendIdsToFontData} backendIdsToFontData
+   * @param {LH.Artifacts.FontSize.DomNodeWithParent[]} crdpNodes
+   */
+  findFailingNodes(backendIdsToFontData, crdpNodes) {
+    /** @type {NodeFontData[]} */
+    const failingNodes = [];
+    let totalTextLength = 0;
+    let failingTextLength = 0;
+
+    for (const crdpNode of crdpNodes) {
+      const partialFontData = backendIdsToFontData.get(crdpNode.backendNodeId);
+      if (!partialFontData) continue;
+      // `crdpNode` is a non-empty TextNode that is in the layout tree (not display: none).
+
+      const {fontSize, textLength} = partialFontData;
+      totalTextLength += textLength;
+      if (fontSize < MINIMAL_LEGIBLE_FONT_SIZE_PX) {
+        // Once a bad TextNode is identified, its parent Node is needed.
+        failingTextLength += textLength;
+        failingNodes.push({
+          node: crdpNode.parentNode,
+          textLength,
+          fontSize,
+        });
+      }
+    }
+
+    return {totalTextLength, failingTextLength, failingNodes};
+  }
+
   /**
    * @param {LH.Gatherer.PassContext} passContext
    * @return {Promise<LH.Artifacts.FontSize>} font-size analysis
@@ -323,27 +320,36 @@ class FontSize extends Gatherer {
     /** @type {Map<string, LH.Crdp.CSS.CSSStyleSheetHeader>} */
     const stylesheets = new Map();
     /** @param {LH.Crdp.CSS.StyleSheetAddedEvent} sheet */
-    const onStylesheetAdd = sheet => stylesheets.set(sheet.header.styleSheetId, sheet.header);
-    passContext.driver.on('CSS.styleSheetAdded', onStylesheetAdd);
+    const onStylesheetAdded = sheet => stylesheets.set(sheet.header.styleSheetId, sheet.header);
+    passContext.driver.on('CSS.styleSheetAdded', onStylesheetAdded);
 
     await Promise.all([
+      passContext.driver.sendCommand('DOMSnapshot.enable'),
       passContext.driver.sendCommand('DOM.enable'),
       passContext.driver.sendCommand('CSS.enable'),
     ]);
 
+    // Get the computed font-size style of every node.
+    const snapshotPromise = passContext.driver.sendCommand('DOMSnapshot.captureSnapshot', {
+      computedStyles: ['font-size'],
+    });
+    const allNodesPromise = getAllNodesFromBody(passContext.driver);
+    const [snapshot, crdpNodes] = await Promise.all([snapshotPromise, allNodesPromise]);
+    const backendIdsToFontData = this.calculateBackendIdsToFontData(snapshot);
+    // `backendIdsToFontData` will include all non-empty TextNodes.
+    // `crdpNodes` will only contain the body node and its descendants.
+
     const {
       totalTextLength,
-      visitedTextLength,
       failingTextLength,
       failingNodes,
-    } = await FontSize.fetchNodesToAnalyze(passContext.driver);
-
+    } = this.findFailingNodes(backendIdsToFontData, crdpNodes);
     const {
       analyzedFailingNodesData,
       analyzedFailingTextLength,
     } = await FontSize.fetchFailingNodeSourceRules(passContext.driver, failingNodes);
 
-    passContext.driver.off('CSS.styleSheetAdded', onStylesheetAdd);
+    passContext.driver.off('CSS.styleSheetAdded', onStylesheetAdded);
 
     // For the nodes whose computed style we could attribute to a stylesheet, assign
     // the stylsheet to the data.
@@ -353,6 +359,7 @@ class FontSize extends Gatherer {
       .forEach(data => (data.cssRule.stylesheet = stylesheets.get(data.cssRule.styleSheetId)));
 
     await Promise.all([
+      passContext.driver.sendCommand('DOMSnapshot.disable'),
       passContext.driver.sendCommand('DOM.disable'),
       passContext.driver.sendCommand('CSS.disable'),
     ]);
@@ -361,13 +368,11 @@ class FontSize extends Gatherer {
       analyzedFailingNodesData,
       analyzedFailingTextLength,
       failingTextLength,
-      visitedTextLength,
       totalTextLength,
     };
   }
 }
 
 module.exports = FontSize;
-module.exports.TEXT_NODE_TYPE = TEXT_NODE_TYPE;
 module.exports.computeSelectorSpecificity = computeSelectorSpecificity;
 module.exports.getEffectiveFontRule = getEffectiveFontRule;
diff --git a/lighthouse-core/lib/i18n/locales/ar-XB.json b/lighthouse-core/lib/i18n/locales/ar-XB.json
index 5480a15f4..2856e533c 100644
--- a/lighthouse-core/lib/i18n/locales/ar-XB.json
+++ b/lighthouse-core/lib/i18n/locales/ar-XB.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "‏‮Text‬‏ ‏‮is‬‏ ‏‮illegible‬‏ ‏‮because‬‏ ‏‮there‬‏'‏‮s‬‏ ‏‮no‬‏ ‏‮viewport‬‏ ‏‮meta‬‏ ‏‮tag‬‏ ‏‮optimized‬‏ ‏‮for‬‏ ‏‮mobile‬‏ ‏‮screens‬‏."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} ‏‮of‬‏ ‏‮text‬‏ ‏‮is‬‏ ‏‮too‬‏ ‏‮small‬‏ (‏‮based‬‏ ‏‮on‬‏ {decimalProportionVisited, number, extendedPercent} ‏‮sample‬‏)."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "‏‮Document‬‏ ‏‮doesn‬‏'‏‮t‬‏ ‏‮use‬‏ ‏‮legible‬‏ ‏‮font‬‏ ‏‮sizes‬‏"
   },
diff --git a/lighthouse-core/lib/i18n/locales/ar.json b/lighthouse-core/lib/i18n/locales/ar.json
index fc479ec61..349cb1c8b 100644
--- a/lighthouse-core/lib/i18n/locales/ar.json
+++ b/lighthouse-core/lib/i18n/locales/ar.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "النص غير مقروء لأنه لا تتوفّر علامة وصفية لإطار العرض محسنة لشاشات الجوّال."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "نسبة {decimalProportion, number, extendedPercent} من النص هي صغيرة جدًا (استنادًا إلى نموذج {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "لا يستخدم المستند أحجام الخطوط القابلة للقراءة"
   },
diff --git a/lighthouse-core/lib/i18n/locales/bg.json b/lighthouse-core/lib/i18n/locales/bg.json
index a1410aa29..cc5d4d548 100644
--- a/lighthouse-core/lib/i18n/locales/bg.json
+++ b/lighthouse-core/lib/i18n/locales/bg.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Текстът не е четлив, тъй като няма мета маркер viewport, оптимизиран за мобилни екрани."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} от текста е твърде малък (въз основа на извадка от {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "В документа не се използва шрифт с четлив размер"
   },
diff --git a/lighthouse-core/lib/i18n/locales/ca.json b/lighthouse-core/lib/i18n/locales/ca.json
index 5d1d49581..92f19e6fc 100644
--- a/lighthouse-core/lib/i18n/locales/ca.json
+++ b/lighthouse-core/lib/i18n/locales/ca.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "El text és il·legible perquè no hi ha cap metaetiqueta de finestra gràfica optimitzada per a pantalles de dispositius mòbils."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "El {decimalProportion, number, extendedPercent} del text és massa petit (d'acord amb una mostra del {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "El document no utilitza lletres amb mides llegibles"
   },
diff --git a/lighthouse-core/lib/i18n/locales/cs.json b/lighthouse-core/lib/i18n/locales/cs.json
index 7e00fb5a3..b233a3d9f 100644
--- a/lighthouse-core/lib/i18n/locales/cs.json
+++ b/lighthouse-core/lib/i18n/locales/cs.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Text není čitelný, protože není k dispozici metaznačka viewport optimalizovaná pro obrazovky mobilních zařízení."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "Podíl příliš malého textu: {decimalProportion, number, extendedPercent} (posuzovaný vzorek: {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "V dokumentu nejsou použity čitelné velikosti písma"
   },
diff --git a/lighthouse-core/lib/i18n/locales/da.json b/lighthouse-core/lib/i18n/locales/da.json
index c019ba142..ca543bd71 100644
--- a/lighthouse-core/lib/i18n/locales/da.json
+++ b/lighthouse-core/lib/i18n/locales/da.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Teksten er ulæselig, fordi der ikke er et viewport-metatag, som er optimeret til mobilskærme."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} af teksten er for lille (baseret på et eksempel på {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Dokumentet bruger ikke læselige skriftstørrelser"
   },
diff --git a/lighthouse-core/lib/i18n/locales/de.json b/lighthouse-core/lib/i18n/locales/de.json
index 1aff70466..02d051715 100644
--- a/lighthouse-core/lib/i18n/locales/de.json
+++ b/lighthouse-core/lib/i18n/locales/de.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Text ist nicht lesbar, weil kein Meta-Tag für den Darstellungsbereich vorhanden ist, das für Bildschirme von Mobilgeräten optimiert ist."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} des Texts ist zu klein (auf Grundlage einer Stichprobengröße von {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Dokument enthält keine gut lesbaren Schriftgrößen"
   },
diff --git a/lighthouse-core/lib/i18n/locales/el.json b/lighthouse-core/lib/i18n/locales/el.json
index a7cdf7112..1ec556557 100644
--- a/lighthouse-core/lib/i18n/locales/el.json
+++ b/lighthouse-core/lib/i18n/locales/el.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Το κείμενο είναι δυσανάγνωστο επειδή δεν υπάρχει βελτιστοποιημένη μεταετικέτα θύρα προβολής για οθόνες κινητών."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} του κειμένου είναι πολύ μικρό (βάσει ενός δείγματος {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Το έγγραφο δεν χρησιμοποιεί ευανάγνωστα μεγέθη γραμματοσειράς"
   },
diff --git a/lighthouse-core/lib/i18n/locales/en-GB.json b/lighthouse-core/lib/i18n/locales/en-GB.json
index ebb77a5da..d8bf8366c 100644
--- a/lighthouse-core/lib/i18n/locales/en-GB.json
+++ b/lighthouse-core/lib/i18n/locales/en-GB.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Text is illegible because there's no viewport meta tag optimised for mobile screens."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} of text is too small (based on {decimalProportionVisited, number, extendedPercent} sample)."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Document doesn't use legible font sizes"
   },
diff --git a/lighthouse-core/lib/i18n/locales/en-US.json b/lighthouse-core/lib/i18n/locales/en-US.json
index 8aca1302f..bbf3bd85e 100644
--- a/lighthouse-core/lib/i18n/locales/en-US.json
+++ b/lighthouse-core/lib/i18n/locales/en-US.json
@@ -855,7 +855,7 @@
     "message": "Time to interactive is the amount of time it takes for the page to become fully interactive. [Learn more](https://web.dev/interactive)."
   },
   "lighthouse-core/audits/metrics/largest-contentful-paint.js | description": {
-    "message": "Largest Contentful Paint marks the time at which the largest text or image is painted. [Learn More](https://web.dev/largest-contentful-paint)"
+    "message": "Largest Contentful Paint marks the time at which the largest text or image is painted. [Learn More](https://web.dev/lighthouse-largest-contentful-paint)"
   },
   "lighthouse-core/audits/metrics/largest-contentful-paint.js | title": {
     "message": "Largest Contentful Paint"
@@ -965,9 +965,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Text is illegible because there's no viewport meta tag optimized for mobile screens."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} of text is too small (based on {decimalProportionVisited, number, extendedPercent} sample)."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Document doesn't use legible font sizes"
   },
@@ -1373,6 +1370,9 @@
   "lighthouse-core/config/default-config.js | seoMobileGroupTitle": {
     "message": "Mobile Friendly"
   },
+  "lighthouse-core/gather/gather-runner.js | warningRedirected": {
+    "message": "The page may not be loading as expected because your test URL ({requested}) was redirected to {final}. Try testing the second URL directly."
+  },
   "lighthouse-core/lib/i18n/i18n.js | columnCacheTTL": {
     "message": "Cache TTL"
   },
@@ -1532,12 +1532,39 @@
   "lighthouse-core/report/html/renderer/util.js | crcLongestDurationLabel": {
     "message": "Maximum critical path latency:"
   },
+  "lighthouse-core/report/html/renderer/util.js | dropdownCopyJSON": {
+    "message": "Copy JSON"
+  },
+  "lighthouse-core/report/html/renderer/util.js | dropdownDarkTheme": {
+    "message": "Toggle Dark Theme"
+  },
+  "lighthouse-core/report/html/renderer/util.js | dropdownPrintExpanded": {
+    "message": "Print Expanded"
+  },
+  "lighthouse-core/report/html/renderer/util.js | dropdownPrintSummary": {
+    "message": "Print Summary"
+  },
+  "lighthouse-core/report/html/renderer/util.js | dropdownSaveGist": {
+    "message": "Save as Gist"
+  },
+  "lighthouse-core/report/html/renderer/util.js | dropdownSaveHTML": {
+    "message": "Save as HTML"
+  },
+  "lighthouse-core/report/html/renderer/util.js | dropdownSaveJSON": {
+    "message": "Save as JSON"
+  },
+  "lighthouse-core/report/html/renderer/util.js | dropdownViewer": {
+    "message": "Open in Viewer"
+  },
   "lighthouse-core/report/html/renderer/util.js | errorLabel": {
     "message": "Error!"
   },
   "lighthouse-core/report/html/renderer/util.js | errorMissingAuditInfo": {
     "message": "Report error: no audit information"
   },
+  "lighthouse-core/report/html/renderer/util.js | footerIssue": {
+    "message": "File an issue"
+  },
   "lighthouse-core/report/html/renderer/util.js | labDataTitle": {
     "message": "Lab Data"
   },
@@ -1559,6 +1586,45 @@
   "lighthouse-core/report/html/renderer/util.js | passedAuditsGroupTitle": {
     "message": "Passed audits"
   },
+  "lighthouse-core/report/html/renderer/util.js | runtimeDesktopEmulation": {
+    "message": "Emulated Desktop"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeMobileEmulation": {
+    "message": "Emulated Nexus 5X"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeNoEmulation": {
+    "message": "No emulation"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeSettingsBenchmark": {
+    "message": "CPU/Memory Power"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeSettingsCPUThrottling": {
+    "message": "CPU throttling"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeSettingsDevice": {
+    "message": "Device"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeSettingsFetchTime": {
+    "message": "Fetch Time"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeSettingsNetworkThrottling": {
+    "message": "Network throttling"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeSettingsTitle": {
+    "message": "Runtime Settings"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeSettingsUA": {
+    "message": "User agent (host)"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeSettingsUANetwork": {
+    "message": "User agent (network)"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeSettingsUrl": {
+    "message": "URL"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeUnknown": {
+    "message": "Unknown"
+  },
   "lighthouse-core/report/html/renderer/util.js | snippetCollapseButtonLabel": {
     "message": "Collapse snippet"
   },
@@ -1568,6 +1634,9 @@
   "lighthouse-core/report/html/renderer/util.js | thirdPartyResourcesLabel": {
     "message": "Show 3rd-party resources"
   },
+  "lighthouse-core/report/html/renderer/util.js | throttlingProvided": {
+    "message": "Provided by environment"
+  },
   "lighthouse-core/report/html/renderer/util.js | toplevelWarningsMessage": {
     "message": "There were issues affecting this run of Lighthouse:"
   },
diff --git a/lighthouse-core/lib/i18n/locales/en-XA.json b/lighthouse-core/lib/i18n/locales/en-XA.json
index 11ea739dc..a9d753a66 100644
--- a/lighthouse-core/lib/i18n/locales/en-XA.json
+++ b/lighthouse-core/lib/i18n/locales/en-XA.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "[Ţéxţ îš îļļéĝîбļé бéçåûšé ţĥéŕé'š ñö vîéŵþöŕţ méţå ţåĝ öþţîmîžéð ƒöŕ möбîļé šçŕééñš. one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen]"
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "[ᐅ{decimalProportion, number, extendedPercent}ᐊ öƒ ţéxţ îš ţöö šmåļļ (бåšéð öñ ᐅ{decimalProportionVisited, number, extendedPercent}ᐊ šåmþļé). one two three four five six seven eight nine ten]"
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "[Ðöçûméñţ ðöéšñ'ţ ûšé ļéĝîбļé ƒöñţ šîžéš one two three four five six seven eight]"
   },
diff --git a/lighthouse-core/lib/i18n/locales/en-XL.json b/lighthouse-core/lib/i18n/locales/en-XL.json
index 3daf14887..a9f2ec023 100644
--- a/lighthouse-core/lib/i18n/locales/en-XL.json
+++ b/lighthouse-core/lib/i18n/locales/en-XL.json
@@ -855,7 +855,7 @@
     "message": "T̂ím̂é t̂ó îńt̂ér̂áĉt́îv́ê íŝ t́ĥé âḿôún̂t́ ôf́ t̂ím̂é ît́ t̂ák̂éŝ f́ôŕ t̂h́ê ṕâǵê t́ô b́êćôḿê f́ûĺl̂ý îńt̂ér̂áĉt́îv́ê. [Ĺêár̂ń m̂ór̂é](https://web.dev/interactive)."
   },
   "lighthouse-core/audits/metrics/largest-contentful-paint.js | description": {
-    "message": "L̂ár̂ǵêśt̂ Ćôńt̂én̂t́f̂úl̂ Ṕâín̂t́ m̂ár̂ḱŝ t́ĥé t̂ím̂é ât́ ŵh́îćĥ t́ĥé l̂ár̂ǵêśt̂ t́êx́t̂ ór̂ ím̂áĝé îś p̂áîńt̂éd̂. [Ĺêár̂ń M̂ór̂é](https://web.dev/largest-contentful-paint)"
+    "message": "L̂ár̂ǵêśt̂ Ćôńt̂én̂t́f̂úl̂ Ṕâín̂t́ m̂ár̂ḱŝ t́ĥé t̂ím̂é ât́ ŵh́îćĥ t́ĥé l̂ár̂ǵêśt̂ t́êx́t̂ ór̂ ím̂áĝé îś p̂áîńt̂éd̂. [Ĺêár̂ń M̂ór̂é](https://web.dev/lighthouse-largest-contentful-paint)"
   },
   "lighthouse-core/audits/metrics/largest-contentful-paint.js | title": {
     "message": "L̂ár̂ǵêśt̂ Ćôńt̂én̂t́f̂úl̂ Ṕâín̂t́"
@@ -965,9 +965,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "T̂éx̂t́ îś îĺl̂éĝíb̂ĺê b́êćâúŝé t̂h́êŕê'ś n̂ó v̂íêẃp̂ór̂t́ m̂ét̂á t̂áĝ óp̂t́îḿîźêd́ f̂ór̂ ḿôb́îĺê śĉŕêén̂ś."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} ôf́ t̂éx̂t́ îś t̂óô śm̂ál̂ĺ (b̂áŝéd̂ ón̂ {decimalProportionVisited, number, extendedPercent} śâḿp̂ĺê)."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "D̂óĉúm̂én̂t́ d̂óêśn̂'t́ ûśê ĺêǵîb́l̂é f̂ón̂t́ ŝíẑéŝ"
   },
@@ -1373,6 +1370,9 @@
   "lighthouse-core/config/default-config.js | seoMobileGroupTitle": {
     "message": "M̂ób̂íl̂é F̂ŕîén̂d́l̂ý"
   },
+  "lighthouse-core/gather/gather-runner.js | warningRedirected": {
+    "message": "T̂h́ê ṕâǵê ḿâý n̂ót̂ b́ê ĺôád̂ín̂ǵ âś êx́p̂éĉt́êd́ b̂éĉáûśê ýôúr̂ t́êśt̂ ÚR̂Ĺ ({requested}) ŵáŝ ŕêd́îŕêćt̂éd̂ t́ô {final}. T́r̂ý t̂éŝt́îńĝ t́ĥé ŝéĉón̂d́ ÛŔL̂ d́îŕêćt̂ĺŷ."
+  },
   "lighthouse-core/lib/i18n/i18n.js | columnCacheTTL": {
     "message": "Ĉáĉh́ê T́T̂Ĺ"
   },
@@ -1532,12 +1532,39 @@
   "lighthouse-core/report/html/renderer/util.js | crcLongestDurationLabel": {
     "message": "M̂áx̂ím̂úm̂ ćr̂ít̂íĉál̂ ṕât́ĥ ĺât́êńĉý:"
   },
+  "lighthouse-core/report/html/renderer/util.js | dropdownCopyJSON": {
+    "message": "Ĉóp̂ý ĴŚÔŃ"
+  },
+  "lighthouse-core/report/html/renderer/util.js | dropdownDarkTheme": {
+    "message": "T̂óĝǵl̂é D̂ár̂ḱ T̂h́êḿê"
+  },
+  "lighthouse-core/report/html/renderer/util.js | dropdownPrintExpanded": {
+    "message": "P̂ŕîńt̂ Éx̂ṕâńd̂éd̂"
+  },
+  "lighthouse-core/report/html/renderer/util.js | dropdownPrintSummary": {
+    "message": "P̂ŕîńt̂ Śûḿm̂ár̂ý"
+  },
+  "lighthouse-core/report/html/renderer/util.js | dropdownSaveGist": {
+    "message": "Ŝáv̂é âś Ĝíŝt́"
+  },
+  "lighthouse-core/report/html/renderer/util.js | dropdownSaveHTML": {
+    "message": "Ŝáv̂é âś ĤT́M̂Ĺ"
+  },
+  "lighthouse-core/report/html/renderer/util.js | dropdownSaveJSON": {
+    "message": "Ŝáv̂é âś ĴŚÔŃ"
+  },
+  "lighthouse-core/report/html/renderer/util.js | dropdownViewer": {
+    "message": "Ôṕêń îń V̂íêẃêŕ"
+  },
   "lighthouse-core/report/html/renderer/util.js | errorLabel": {
     "message": "Êŕr̂ór̂!"
   },
   "lighthouse-core/report/html/renderer/util.js | errorMissingAuditInfo": {
     "message": "R̂ép̂ór̂t́ êŕr̂ór̂: ńô áûd́ît́ îńf̂ór̂ḿât́îón̂"
   },
+  "lighthouse-core/report/html/renderer/util.js | footerIssue": {
+    "message": "F̂íl̂é âń îśŝúê"
+  },
   "lighthouse-core/report/html/renderer/util.js | labDataTitle": {
     "message": "L̂áb̂ D́ât́â"
   },
@@ -1559,6 +1586,45 @@
   "lighthouse-core/report/html/renderer/util.js | passedAuditsGroupTitle": {
     "message": "P̂áŝśêd́ âúd̂ít̂ś"
   },
+  "lighthouse-core/report/html/renderer/util.js | runtimeDesktopEmulation": {
+    "message": "Êḿûĺât́êd́ D̂éŝḱt̂óp̂"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeMobileEmulation": {
+    "message": "Êḿûĺât́êd́ N̂éx̂úŝ 5X́"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeNoEmulation": {
+    "message": "N̂ó êḿûĺât́îón̂"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeSettingsBenchmark": {
+    "message": "ĈṔÛ/Ḿêḿôŕŷ Ṕôẃêŕ"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeSettingsCPUThrottling": {
+    "message": "ĈṔÛ t́ĥŕôt́t̂ĺîńĝ"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeSettingsDevice": {
+    "message": "D̂év̂íĉé"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeSettingsFetchTime": {
+    "message": "F̂ét̂ćĥ T́îḿê"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeSettingsNetworkThrottling": {
+    "message": "N̂ét̂ẃôŕk̂ t́ĥŕôt́t̂ĺîńĝ"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeSettingsTitle": {
+    "message": "R̂ún̂t́îḿê Śêt́t̂ín̂ǵŝ"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeSettingsUA": {
+    "message": "Ûśêŕ âǵêńt̂ (h́ôśt̂)"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeSettingsUANetwork": {
+    "message": "Ûśêŕ âǵêńt̂ (ńêt́ŵór̂ḱ)"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeSettingsUrl": {
+    "message": "ÛŔL̂"
+  },
+  "lighthouse-core/report/html/renderer/util.js | runtimeUnknown": {
+    "message": "Ûńk̂ńôẃn̂"
+  },
   "lighthouse-core/report/html/renderer/util.js | snippetCollapseButtonLabel": {
     "message": "Ĉól̂ĺâṕŝé ŝńîṕp̂ét̂"
   },
@@ -1568,6 +1634,9 @@
   "lighthouse-core/report/html/renderer/util.js | thirdPartyResourcesLabel": {
     "message": "Ŝh́ôẃ 3r̂d́-p̂ár̂t́ŷ ŕêśôúr̂ćêś"
   },
+  "lighthouse-core/report/html/renderer/util.js | throttlingProvided": {
+    "message": "P̂ŕôv́îd́êd́ b̂ý êńv̂ír̂ón̂ḿêńt̂"
+  },
   "lighthouse-core/report/html/renderer/util.js | toplevelWarningsMessage": {
     "message": "T̂h́êŕê ẃêŕê íŝśûéŝ áf̂f́êćt̂ín̂ǵ t̂h́îś r̂ún̂ óf̂ Ĺîǵĥt́ĥóûśê:"
   },
diff --git a/lighthouse-core/lib/i18n/locales/es-419.json b/lighthouse-core/lib/i18n/locales/es-419.json
index 13f365372..39fc8e7bf 100644
--- a/lighthouse-core/lib/i18n/locales/es-419.json
+++ b/lighthouse-core/lib/i18n/locales/es-419.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "El texto es ilegible porque no hay metaetiquetas de la vista del puerto optimizadas para pantallas de dispositivos móviles."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "El porcentaje {decimalProportion, number, extendedPercent} del texto es demasiado bajo (según la muestra {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "El documento no usa tamaños de fuente legibles"
   },
diff --git a/lighthouse-core/lib/i18n/locales/es.json b/lighthouse-core/lib/i18n/locales/es.json
index 62307ac33..8a6ea26be 100644
--- a/lighthouse-core/lib/i18n/locales/es.json
+++ b/lighthouse-core/lib/i18n/locales/es.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "El texto es ilegible porque no hay metaetiquetas de viewport optimizadas para pantallas de dispositivos móviles."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "El {decimalProportion, number, extendedPercent} del texto es demasiado pequeño (según una muestra del {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "El documento no usa tamaños de fuente legibles"
   },
diff --git a/lighthouse-core/lib/i18n/locales/fi.json b/lighthouse-core/lib/i18n/locales/fi.json
index 8b6c7950d..a7f5b1e28 100644
--- a/lighthouse-core/lib/i18n/locales/fi.json
+++ b/lighthouse-core/lib/i18n/locales/fi.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Teksti on lukukelvotonta, koska näkymän sisällönkuvauskenttää ei ole optimoitu mobiilinäytöille."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} tekstistä on liian pientä (perustuen {decimalProportionVisited, number, extendedPercent}:n näytteeseen)."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Dokumentissa ei käytetä lukukelpoisia kirjasinkokoja"
   },
diff --git a/lighthouse-core/lib/i18n/locales/fil.json b/lighthouse-core/lib/i18n/locales/fil.json
index 424ef02f5..b29f4ba11 100644
--- a/lighthouse-core/lib/i18n/locales/fil.json
+++ b/lighthouse-core/lib/i18n/locales/fil.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Hindi nababasa ang text dahil walang viewport meta tag na naka-optimize para sa mga screen ng mobile."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "Masyadong maliit ang {decimalProportion, number, extendedPercent} ng text (batay sa sample na {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Hindi gumagamit ng mga nababasang laki ng font ang dokumento"
   },
diff --git a/lighthouse-core/lib/i18n/locales/fr.json b/lighthouse-core/lib/i18n/locales/fr.json
index edc4251c6..ecc13670f 100644
--- a/lighthouse-core/lib/i18n/locales/fr.json
+++ b/lighthouse-core/lib/i18n/locales/fr.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Le texte est illisible, car aucune balise Meta de fenêtre d'affichage n'est optimisée pour les écrans mobiles."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} du texte sont trop petits (d'après un échantillon de {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Les tailles de police utilisées dans le document ne sont pas lisibles"
   },
diff --git a/lighthouse-core/lib/i18n/locales/he.json b/lighthouse-core/lib/i18n/locales/he.json
index 9cc9334b9..582a0e2e6 100644
--- a/lighthouse-core/lib/i18n/locales/he.json
+++ b/lighthouse-core/lib/i18n/locales/he.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "הטקסט לא קריא כי לא בוצעה אופטימיזציית מטא תג של אזור התצוגה בשביל מסכים של ניידים."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "הגודל של {decimalProportion, number, extendedPercent} מהטקסט קטן מדי (מבוסס על דגימה של {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "מידות הגופן במסך מקשות על הקריאה"
   },
diff --git a/lighthouse-core/lib/i18n/locales/hi.json b/lighthouse-core/lib/i18n/locales/hi.json
index c8fcc4733..cd84a842a 100644
--- a/lighthouse-core/lib/i18n/locales/hi.json
+++ b/lighthouse-core/lib/i18n/locales/hi.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "मोबाइल स्क्रीन के लिए कोई व्यूपोर्ट मेटा टैग ऑप्टिमाइज़ नहीं किए जाने की वजह से टेक्स्ट पढ़ने लायक नहीं है."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "टेक्स्ट का {decimalProportion, number, extendedPercent} बहुत छोटा है ({decimalProportionVisited, number, extendedPercent} नमूने पर आधारित)."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "दस्तावेज़ पढ़ने लायक फ़ॉन्ट आकारों का इस्तेमाल नहीं करता है"
   },
diff --git a/lighthouse-core/lib/i18n/locales/hr.json b/lighthouse-core/lib/i18n/locales/hr.json
index a0ca3ce9c..818459efa 100644
--- a/lighthouse-core/lib/i18n/locales/hr.json
+++ b/lighthouse-core/lib/i18n/locales/hr.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Tekst nije čitljiv jer nijedna metaoznaka vidljivog dijela nije optimizirana za zaslone mobilnih uređaja."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} teksta nije dovoljno veliko (na temelju uzorka od {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Dokument ne sadrži čitljive veličine fontova"
   },
diff --git a/lighthouse-core/lib/i18n/locales/hu.json b/lighthouse-core/lib/i18n/locales/hu.json
index 196f54dc9..20865fec1 100644
--- a/lighthouse-core/lib/i18n/locales/hu.json
+++ b/lighthouse-core/lib/i18n/locales/hu.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "A szöveg olvashatatlan, ugyanis a látható terület metacímkéje nincs optimalizálva a mobilképernyőkre"
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "A szöveg {decimalProportion, number, extendedPercent}-a túl kicsi (az összes szöveg {decimalProportionVisited, number, extendedPercent}-a alapján)."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "A dokumentum olvashatatlan betűméreteket használ"
   },
diff --git a/lighthouse-core/lib/i18n/locales/id.json b/lighthouse-core/lib/i18n/locales/id.json
index 732ea17aa..226bcd461 100644
--- a/lighthouse-core/lib/i18n/locales/id.json
+++ b/lighthouse-core/lib/i18n/locales/id.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Teks tidak terbaca karena tidak ada tag meta viewport yang dioptimalkan untuk layar seluler."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} teks terlalu kecil (berdasarkan sampel {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Dokumen tidak menggunakan ukuran font yang terbaca"
   },
diff --git a/lighthouse-core/lib/i18n/locales/it.json b/lighthouse-core/lib/i18n/locales/it.json
index 23bb6cfd9..af4488708 100644
--- a/lighthouse-core/lib/i18n/locales/it.json
+++ b/lighthouse-core/lib/i18n/locales/it.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Il testo è illeggibile perché non esiste un meta tag viewport ottimizzato per gli schermi dei dispositivi mobili."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "Il {decimalProportion, number, extendedPercent} del testo è troppo piccolo (in base a un campione del {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Il documento non usa dimensioni dei caratteri leggibili"
   },
diff --git a/lighthouse-core/lib/i18n/locales/ja.json b/lighthouse-core/lib/i18n/locales/ja.json
index 9bc5aaae6..7acae89ed 100644
--- a/lighthouse-core/lib/i18n/locales/ja.json
+++ b/lighthouse-core/lib/i18n/locales/ja.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "テキストが判読できません。モバイル スクリーン向けに最適化されたビューポート メタタグがありません。"
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} のテキストが小さすぎます（{decimalProportionVisited, number, extendedPercent} のサンプルを基準とします）。"
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "ドキュメントで判読可能なフォントサイズが使用されていません"
   },
diff --git a/lighthouse-core/lib/i18n/locales/ko.json b/lighthouse-core/lib/i18n/locales/ko.json
index f526dbbb2..a64c82737 100644
--- a/lighthouse-core/lib/i18n/locales/ko.json
+++ b/lighthouse-core/lib/i18n/locales/ko.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "모바일 화면에 최적화된 표시 영역 메타 태그가 없어서 텍스트를 알아볼 수 없음"
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "텍스트의 {decimalProportion, number, extendedPercent} 값이 너무 작습니다({decimalProportionVisited, number, extendedPercent} 샘플 기준)."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "문서에서 읽기 쉬운 글꼴 크기를 사용하지 않음"
   },
diff --git a/lighthouse-core/lib/i18n/locales/lt.json b/lighthouse-core/lib/i18n/locales/lt.json
index b5daaa99c..ef3936221 100644
--- a/lighthouse-core/lib/i18n/locales/lt.json
+++ b/lighthouse-core/lib/i18n/locales/lt.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Tekstas neįskaitomas, nes nėra mobiliųjų įrenginių ekranams optimizuotos peržiūros srities metažymos"
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} teksto yra per mažo dydžio (atsižvelgiant į pavyzdį – {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Dokumente naudojami neįskaitomo dydžio šriftai"
   },
diff --git a/lighthouse-core/lib/i18n/locales/lv.json b/lighthouse-core/lib/i18n/locales/lv.json
index bd690ec53..eb26396f6 100644
--- a/lighthouse-core/lib/i18n/locales/lv.json
+++ b/lighthouse-core/lib/i18n/locales/lv.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Teksts nav salasāms, jo mobilo ierīču ekrāniem nav optimizēts skatvietas metatags."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} ir pārāk mazs teksta izmērs (pamatojoties uz {decimalProportionVisited, number, extendedPercent} paraugu)."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Dokumentā netiek izmantoti salasāmi fonta izmēri"
   },
diff --git a/lighthouse-core/lib/i18n/locales/nl.json b/lighthouse-core/lib/i18n/locales/nl.json
index 13fb212a2..f6a050a10 100644
--- a/lighthouse-core/lib/i18n/locales/nl.json
+++ b/lighthouse-core/lib/i18n/locales/nl.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Tekst is onleesbaar omdat er geen kijkvenstermetatag is geoptimaliseerd voor mobiele schermen."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} van de tekst is te klein (op basis van een voorbeeld van {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Document gebruikt geen leesbare lettergrootten"
   },
diff --git a/lighthouse-core/lib/i18n/locales/no.json b/lighthouse-core/lib/i18n/locales/no.json
index afcc4f81b..c9f429f2e 100644
--- a/lighthouse-core/lib/i18n/locales/no.json
+++ b/lighthouse-core/lib/i18n/locales/no.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Teksten er uleselig fordi det ikke er noen viewport-metatag som er optimalisert for mobilskjermer."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} av teksten er for liten (basert på et utvalg på {decimalProportionVisited, number, extendedPercent} av siden)."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Dokumentet bruker ikke leselige skriftstørrelser"
   },
diff --git a/lighthouse-core/lib/i18n/locales/pl.json b/lighthouse-core/lib/i18n/locales/pl.json
index cc3a9606d..0f670f5a2 100644
--- a/lighthouse-core/lib/i18n/locales/pl.json
+++ b/lighthouse-core/lib/i18n/locales/pl.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Tekst jest nieczytelny z powodu braku metatagu viewport zoptymalizowanego na potrzeby ekranów telefonów."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} tekstu jest zbyt małe (na podstawie próbki {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "W dokumencie nie są używane czytelne rozmiary czcionek"
   },
diff --git a/lighthouse-core/lib/i18n/locales/pt-PT.json b/lighthouse-core/lib/i18n/locales/pt-PT.json
index 6900e35df..f2505907f 100644
--- a/lighthouse-core/lib/i18n/locales/pt-PT.json
+++ b/lighthouse-core/lib/i18n/locales/pt-PT.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "O texto é ilegível porque não existe nenhuma metatag de área visível otimizada para ecrãs de dispositivos móveis."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} do texto é demasiado pequeno (com base na amostra de {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "O documento não utiliza tamanhos de tipo de letra legíveis"
   },
diff --git a/lighthouse-core/lib/i18n/locales/pt.json b/lighthouse-core/lib/i18n/locales/pt.json
index bfa449a8d..af18d3f85 100644
--- a/lighthouse-core/lib/i18n/locales/pt.json
+++ b/lighthouse-core/lib/i18n/locales/pt.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "O texto é ilegível porque não há nenhuma metatag de janela de visualização otimizada para telas de dispositivos móveis."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} do texto é muito pequeno (com base na amostra de {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "O documento não usa tamanhos de fonte legíveis"
   },
diff --git a/lighthouse-core/lib/i18n/locales/ro.json b/lighthouse-core/lib/i18n/locales/ro.json
index 825700711..43d837374 100644
--- a/lighthouse-core/lib/i18n/locales/ro.json
+++ b/lighthouse-core/lib/i18n/locales/ro.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Textul nu este lizibil deoarece nu există nicio metaetichetă viewport optimizată pentru ecranele dispozitivelor mobile."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} din text este prea mic (pornind de la un eșantion de {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Documentul nu folosește dimensiuni de fonturi lizibile"
   },
diff --git a/lighthouse-core/lib/i18n/locales/ru.json b/lighthouse-core/lib/i18n/locales/ru.json
index 118308d71..c1f4a7454 100644
--- a/lighthouse-core/lib/i18n/locales/ru.json
+++ b/lighthouse-core/lib/i18n/locales/ru.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Слишком мелкий текст. Настройте область просмотра для экранов мобильных устройств с помощью метатега viewport."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} текста набрано слишком мелким шрифтом (на основе образца размером {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "В документе используются шрифты слишком маленького размера"
   },
diff --git a/lighthouse-core/lib/i18n/locales/sk.json b/lighthouse-core/lib/i18n/locales/sk.json
index da241d3d1..29aeaf719 100644
--- a/lighthouse-core/lib/i18n/locales/sk.json
+++ b/lighthouse-core/lib/i18n/locales/sk.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Text nie je čitateľný, pretože neexistuje žiadna metaznačka oblasti zobrazenia optimalizovaná pre mobilné obrazovky."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "Určitá časť textu ({decimalProportion, number, extendedPercent}) je príliš malá (na základe vzorky {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Dokument nepoužíva čitateľné veľkosti písma"
   },
diff --git a/lighthouse-core/lib/i18n/locales/sl.json b/lighthouse-core/lib/i18n/locales/sl.json
index 85eec18fc..725f778b5 100644
--- a/lighthouse-core/lib/i18n/locales/sl.json
+++ b/lighthouse-core/lib/i18n/locales/sl.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Besedilo je neberljivo, ker ni metaoznake »viewport«, optimizirane za zaslone mobilnih naprav."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} besedila je premajhnega (na podlagi {decimalProportionVisited, number, extendedPercent} vzorca)."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Dokument ne uporablja berljivih velikosti pisav"
   },
diff --git a/lighthouse-core/lib/i18n/locales/sr-Latn.json b/lighthouse-core/lib/i18n/locales/sr-Latn.json
index daa9b6aba..534dd26b5 100644
--- a/lighthouse-core/lib/i18n/locales/sr-Latn.json
+++ b/lighthouse-core/lib/i18n/locales/sr-Latn.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Tekst nije čitljiv jer ne postoji metaoznaka oblasti prikaza koja je optimizovana za ekrane na mobilnim uređajima."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} teksta je premalo (na osnovu uzorka od {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Dokument ne koristi čitljive veličine fontova"
   },
diff --git a/lighthouse-core/lib/i18n/locales/sr.json b/lighthouse-core/lib/i18n/locales/sr.json
index fa072c27a..57253ac4d 100644
--- a/lighthouse-core/lib/i18n/locales/sr.json
+++ b/lighthouse-core/lib/i18n/locales/sr.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Текст није читљив јер не постоји метаознака области приказа која је оптимизована за екране на мобилним уређајима."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} текста је премало (на основу узорка од {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Документ не користи читљиве величине фонтова"
   },
diff --git a/lighthouse-core/lib/i18n/locales/sv.json b/lighthouse-core/lib/i18n/locales/sv.json
index cef1d257d..a4cb7782e 100644
--- a/lighthouse-core/lib/i18n/locales/sv.json
+++ b/lighthouse-core/lib/i18n/locales/sv.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Text är oläslig eftersom det inte finns någon metatagg för visningsområde som är optimerad för mobila skärmar."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} av texten är för liten (baserat på ett urval av {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Dokumentet har inga läsliga teckenstorlekar"
   },
diff --git a/lighthouse-core/lib/i18n/locales/ta.json b/lighthouse-core/lib/i18n/locales/ta.json
index 1fd30854f..84c8404f4 100644
--- a/lighthouse-core/lib/i18n/locales/ta.json
+++ b/lighthouse-core/lib/i18n/locales/ta.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "மொபைல் திரைகளுக்கேற்ப காட்சிப் பகுதி மேலதிகத் தகவல் மேம்படுத்தப்படாததால் உரையைச் சரியாகப் படிக்க முடியவில்லை."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} உரை மிகவும் சிறியது ({decimalProportionVisited, number, extendedPercent} மாதிரியின் அடிப்படையில்)."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "ஆவணத்தில் தெளிவான எழுத்துரு அளவுகள் பயன்படுத்தப்படவில்லை"
   },
diff --git a/lighthouse-core/lib/i18n/locales/te.json b/lighthouse-core/lib/i18n/locales/te.json
index a36773ef1..0e87df913 100644
--- a/lighthouse-core/lib/i18n/locales/te.json
+++ b/lighthouse-core/lib/i18n/locales/te.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "మొబైల్ స్క్రీన్‌ల కోసం ఆప్టిమైజ్ చేసిన వీక్షణ పోర్ట్ మెటా ట్యాగ్ అందుబాటులో లేదు కనుక వచనం సముచితంగా లేదు."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "'{decimalProportion, number, extendedPercent}' వచనం చాలా చిన్నదిగా ఉంది ('{decimalProportionVisited, number, extendedPercent}' నమూనా ఆధారంగా)."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "పత్రంలో సముచితమైన ఫాంట్ పరిమాణాలను ఉపయోగించలేదు"
   },
diff --git a/lighthouse-core/lib/i18n/locales/th.json b/lighthouse-core/lib/i18n/locales/th.json
index 4482f5028..5e64c1cbb 100644
--- a/lighthouse-core/lib/i18n/locales/th.json
+++ b/lighthouse-core/lib/i18n/locales/th.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "ข้อความอ่านได้ไม่ชัดเจนเพราะไม่มีเมตาแท็กวิวพอร์ตที่เพิ่มประสิทธิภาพให้เหมาะกับหน้าจออุปกรณ์เคลื่อนที่"
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} ของข้อความมีขนาดเล็กเกินไป (อิงจากตัวอย่าง {decimalProportionVisited, number, extendedPercent})"
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "เอกสารไม่ได้ใช้ขนาดตัวอักษรที่อ่านได้ชัดเจน"
   },
diff --git a/lighthouse-core/lib/i18n/locales/tr.json b/lighthouse-core/lib/i18n/locales/tr.json
index 2574b8f0a..b77e15152 100644
--- a/lighthouse-core/lib/i18n/locales/tr.json
+++ b/lighthouse-core/lib/i18n/locales/tr.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Mobil ekranlar için optimize edilmiş görüntü alanı meta etiketi olmadığından metin okunaklı değil."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "Metnin {decimalProportion, number, extendedPercent} kadarı çok küçük ({decimalProportionVisited, number, extendedPercent} örneği baz alınarak)."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Dokümanda okunabilir yazı tipi boyutları kullanılmıyor"
   },
diff --git a/lighthouse-core/lib/i18n/locales/uk.json b/lighthouse-core/lib/i18n/locales/uk.json
index 269e8b2be..a5e4d121e 100644
--- a/lighthouse-core/lib/i18n/locales/uk.json
+++ b/lighthouse-core/lib/i18n/locales/uk.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Текст нечитабельний, оскільки метатег області перегляду не оптимізовано для мобільних пристроїв."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} тексту має замалий шрифт (на основі {decimalProportionVisited, number, extendedPercent} вибірки)."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "У документі використано нечитабельні розміри шрифтів"
   },
diff --git a/lighthouse-core/lib/i18n/locales/vi.json b/lighthouse-core/lib/i18n/locales/vi.json
index 44c1780fc..cf09bf60f 100644
--- a/lighthouse-core/lib/i18n/locales/vi.json
+++ b/lighthouse-core/lib/i18n/locales/vi.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "Văn bản không đọc được vì không có thẻ meta viewport nào được tối ưu hóa cho màn hình thiết bị di động."
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "Văn bản có tỷ lệ {decimalProportion, number, extendedPercent} là quá nhỏ (căn cứ vào mẫu {decimalProportionVisited, number, extendedPercent})."
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "Tài liệu không sử dụng cỡ chữ dễ đọc"
   },
diff --git a/lighthouse-core/lib/i18n/locales/zh-HK.json b/lighthouse-core/lib/i18n/locales/zh-HK.json
index 5d8ab89b6..13d6bbaf5 100644
--- a/lighthouse-core/lib/i18n/locales/zh-HK.json
+++ b/lighthouse-core/lib/i18n/locales/zh-HK.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "由於網頁沒有為流動裝置螢幕設定合適的檢視區中繼標記，因文字難以辨識。"
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} 的文字過小 (以 {decimalProportionVisited, number, extendedPercent} 範例為準)。"
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "文件使用的字型大小難以辨識"
   },
diff --git a/lighthouse-core/lib/i18n/locales/zh-TW.json b/lighthouse-core/lib/i18n/locales/zh-TW.json
index e158b525e..af73ce122 100644
--- a/lighthouse-core/lib/i18n/locales/zh-TW.json
+++ b/lighthouse-core/lib/i18n/locales/zh-TW.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "文字難以辨識，這是因為網頁沒有針對行動裝置螢幕設定合適的可視區域中繼標記。"
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} 的文字過小 (根據 {decimalProportionVisited, number, extendedPercent} 的取樣結果)。"
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "文件使用的字型大小難以辨識"
   },
diff --git a/lighthouse-core/lib/i18n/locales/zh.json b/lighthouse-core/lib/i18n/locales/zh.json
index a28bb40c3..048d94e2d 100644
--- a/lighthouse-core/lib/i18n/locales/zh.json
+++ b/lighthouse-core/lib/i18n/locales/zh.json
@@ -884,9 +884,6 @@
   "lighthouse-core/audits/seo/font-size.js | explanationViewport": {
     "message": "无法辨认文字，因为缺少已针对移动设备屏幕进行优化的 viewport meta 标记。"
   },
-  "lighthouse-core/audits/seo/font-size.js | explanationWithDisclaimer": {
-    "message": "{decimalProportion, number, extendedPercent} 的文字过小（基于规模为 {decimalProportionVisited, number, extendedPercent} 的样本）。"
-  },
   "lighthouse-core/audits/seo/font-size.js | failureTitle": {
     "message": "文档未使用清晰可辨的字体大小"
   },
diff --git a/lighthouse-core/report/html/renderer/report-renderer.js b/lighthouse-core/report/html/renderer/report-renderer.js
index adcc320c4..80d6ec813 100644
--- a/lighthouse-core/report/html/renderer/report-renderer.js
+++ b/lighthouse-core/report/html/renderer/report-renderer.js
@@ -95,15 +95,18 @@ class ReportRenderer {
 
     const env = this._dom.find('.lh-env__items', footer);
     env.id = 'runtime-settings';
+    this._dom.find('.lh-env__title', footer).textContent = Util.i18n.strings.runtimeSettingsTitle;
+
     const envValues = Util.getEnvironmentDisplayValues(report.configSettings || {});
     [
-      {name: 'URL', description: report.finalUrl},
-      {name: 'Fetch time', description: Util.i18n.formatDateTime(report.fetchTime)},
+      {name: Util.i18n.strings.runtimeSettingsUrl, description: report.finalUrl},
+      {name: Util.i18n.strings.runtimeSettingsFetchTime,
+        description: Util.i18n.formatDateTime(report.fetchTime)},
       ...envValues,
-      {name: 'User agent (host)', description: report.userAgent},
-      {name: 'User agent (network)', description: report.environment &&
+      {name: Util.i18n.strings.runtimeSettingsUA, description: report.userAgent},
+      {name: Util.i18n.strings.runtimeSettingsUANetwork, description: report.environment &&
         report.environment.networkUserAgent},
-      {name: 'CPU/Memory Power', description: report.environment &&
+      {name: Util.i18n.strings.runtimeSettingsBenchmark, description: report.environment &&
         report.environment.benchmarkIndex.toFixed(0)},
     ].forEach(runtime => {
       if (!runtime.description) return;
@@ -114,6 +117,7 @@ class ReportRenderer {
       env.appendChild(item);
     });
 
+    this._dom.find('.lh-footer__version_issue', footer).textContent = Util.i18n.strings.footerIssue;
     this._dom.find('.lh-footer__version', footer).textContent = report.lighthouseVersion;
     return footer;
   }
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index 0aa51f19a..50e543f0c 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -136,6 +136,14 @@ class ReportUIFeatures {
         this._dom.find('.lh-metrics-toggle__input', this._document));
       toggleInputEl.checked = true;
     }
+
+    // Fill in all i18n data.
+    for (const node of this._dom.findAll('[data-i18n]', this._dom.document())) {
+      // These strings are guaranteed to (at least) have a default English string in Util.UIStrings,
+      // so this cannot be undefined as long as `report-ui-features.data-i18n` test passes.
+      const i18nAttr = /** @type {keyof LH.I18NRendererStrings} */ (node.getAttribute('data-i18n'));
+      node.textContent = Util.i18n.strings[i18nAttr];
+    }
   }
 
   /**
@@ -620,6 +628,7 @@ class DropDown {
     this.onDocumentKeyDown = this.onDocumentKeyDown.bind(this);
     this.onToggleClick = this.onToggleClick.bind(this);
     this.onToggleKeydown = this.onToggleKeydown.bind(this);
+    this.onMenuFocusOut = this.onMenuFocusOut.bind(this);
     this.onMenuKeydown = this.onMenuKeydown.bind(this);
 
     this._getNextMenuItem = this._getNextMenuItem.bind(this);
@@ -647,6 +656,7 @@ class DropDown {
       // Refocus on the tools button if the drop down last had focus
       this._toggleEl.focus();
     }
+    this._menuEl.removeEventListener('focusout', this.onMenuFocusOut);
     this._dom.document().removeEventListener('keydown', this.onDocumentKeyDown);
   }
 
@@ -666,6 +676,7 @@ class DropDown {
 
     this._toggleEl.classList.add('active');
     this._toggleEl.setAttribute('aria-expanded', 'true');
+    this._menuEl.addEventListener('focusout', this.onMenuFocusOut);
     this._dom.document().addEventListener('keydown', this.onDocumentKeyDown);
   }
 
@@ -744,6 +755,21 @@ class DropDown {
     }
   }
 
+  /**
+   * Focus out handler for the drop down menu.
+   * @param {Event} e
+   */
+  onMenuFocusOut(e) {
+    // TODO: The focusout event is not supported in our current version of typescript (3.5.3)
+    // https://github.com/microsoft/TypeScript/issues/30716
+    const focusEvent = /** @type {FocusEvent} */ (e);
+    const focusedEl = /** @type {?HTMLElement} */ (focusEvent.relatedTarget);
+
+    if (!this._menuEl.contains(focusedEl)) {
+      this.close();
+    }
+  }
+
   /**
    * @param {Array<Node>} allNodes
    * @param {?Node=} startNode
diff --git a/lighthouse-core/report/html/renderer/util.js b/lighthouse-core/report/html/renderer/util.js
index 0cfa21ce7..fc4fb3804 100644
--- a/lighthouse-core/report/html/renderer/util.js
+++ b/lighthouse-core/report/html/renderer/util.js
@@ -372,15 +372,15 @@ class Util {
 
     return [
       {
-        name: 'Device',
+        name: Util.i18n.strings.runtimeSettingsDevice,
         description: emulationDesc.deviceEmulation,
       },
       {
-        name: 'Network throttling',
+        name: Util.i18n.strings.runtimeSettingsNetworkThrottling,
         description: emulationDesc.networkThrottling,
       },
       {
-        name: 'CPU throttling',
+        name: Util.i18n.strings.runtimeSettingsCPUThrottling,
         description: emulationDesc.cpuThrottling,
       },
     ];
@@ -388,20 +388,18 @@ class Util {
 
   /**
    * @param {LH.Config.Settings} settings
-   * @return {{deviceEmulation: string, networkThrottling: string, cpuThrottling: string, summary: string}}
+   * @return {{deviceEmulation: string, networkThrottling: string, cpuThrottling: string}}
    */
   static getEmulationDescriptions(settings) {
     let cpuThrottling;
     let networkThrottling;
-    let summary;
 
     const throttling = settings.throttling;
 
     switch (settings.throttlingMethod) {
       case 'provided':
-        cpuThrottling = 'Provided by environment';
-        networkThrottling = 'Provided by environment';
-        summary = 'No throttling applied';
+        cpuThrottling = Util.i18n.strings.throttlingProvided;
+        networkThrottling = Util.i18n.strings.throttlingProvided;
         break;
       case 'devtools': {
         const {cpuSlowdownMultiplier, requestLatencyMs} = throttling;
@@ -409,7 +407,6 @@ class Util {
         networkThrottling = `${Util.i18n.formatNumber(requestLatencyMs)}${NBSP}ms HTTP RTT, ` +
           `${Util.i18n.formatNumber(throttling.downloadThroughputKbps)}${NBSP}Kbps down, ` +
           `${Util.i18n.formatNumber(throttling.uploadThroughputKbps)}${NBSP}Kbps up (DevTools)`;
-        summary = 'Throttled Slow 4G network';
         break;
       }
       case 'simulate': {
@@ -417,24 +414,24 @@ class Util {
         cpuThrottling = `${Util.i18n.formatNumber(cpuSlowdownMultiplier)}x slowdown (Simulated)`;
         networkThrottling = `${Util.i18n.formatNumber(rttMs)}${NBSP}ms TCP RTT, ` +
           `${Util.i18n.formatNumber(throughputKbps)}${NBSP}Kbps throughput (Simulated)`;
-        summary = 'Simulated Slow 4G network';
         break;
       }
       default:
-        cpuThrottling = 'Unknown';
-        networkThrottling = 'Unknown';
-        summary = 'Unknown';
+        cpuThrottling = Util.i18n.strings.runtimeUnknown;
+        networkThrottling = Util.i18n.strings.runtimeUnknown;
     }
 
-    let deviceEmulation = 'No emulation';
-    if (settings.emulatedFormFactor === 'mobile') deviceEmulation = 'Emulated Nexus 5X';
-    if (settings.emulatedFormFactor === 'desktop') deviceEmulation = 'Emulated Desktop';
+    let deviceEmulation = Util.i18n.strings.runtimeNoEmulation;
+    if (settings.emulatedFormFactor === 'mobile') {
+      deviceEmulation = Util.i18n.strings.runtimeMobileEmulation;
+    } else if (settings.emulatedFormFactor === 'desktop') {
+      deviceEmulation = Util.i18n.strings.runtimeDesktopEmulation;
+    }
 
     return {
       deviceEmulation,
       cpuThrottling,
       networkThrottling,
-      summary: `${deviceEmulation}, ${summary}`,
     };
   }
 
@@ -540,6 +537,57 @@ Util.UIStrings = {
 
   /** This label is for a checkbox above a table of items loaded by a web page. The checkbox is used to show or hide third-party (or "3rd-party") resources in the table, where "third-party resources" refers to items loaded by a web page from URLs that aren't controlled by the owner of the web page. */
   thirdPartyResourcesLabel: 'Show 3rd-party resources',
+
+  /** Option in a dropdown menu that opens a small, summary report in a print dialog.  */
+  dropdownPrintSummary: 'Print Summary',
+  /** Option in a dropdown menu that opens a full Lighthouse report in a print dialog.  */
+  dropdownPrintExpanded: 'Print Expanded',
+  /** Option in a dropdown menu that copies the Lighthouse JSON object to the system clipboard. */
+  dropdownCopyJSON: 'Copy JSON',
+  /** Option in a dropdown menu that saves the Lighthouse report HTML locally to the system as a '.html' file. */
+  dropdownSaveHTML: 'Save as HTML',
+  /** Option in a dropdown menu that saves the Lighthouse JSON object to the local system as a '.json' file. */
+  dropdownSaveJSON: 'Save as JSON',
+  /** Option in a dropdown menu that opens the current report in the Lighthouse Viewer Application. */
+  dropdownViewer: 'Open in Viewer',
+  /** Option in a dropdown menu that saves the current report as a new Github Gist. */
+  dropdownSaveGist: 'Save as Gist',
+  /** Option in a dropdown menu that toggles the themeing of the report between Light(default) and Dark themes. */
+  dropdownDarkTheme: 'Toggle Dark Theme',
+
+  /** Title of the Runtime settings table in a Lighthouse report.  Runtime settings are the environment configurations that a specific report used at auditing time. */
+  runtimeSettingsTitle: 'Runtime Settings',
+  /** Label for a row in a table that shows the URL that was audited during a Lighthouse run. */
+  runtimeSettingsUrl: 'URL',
+  /** Label for a row in a table that shows the time at which a Lighthouse run was conducted; formatted as a timestamp, e.g. Jan 1, 1970 12:00 AM UTC. */
+  runtimeSettingsFetchTime: 'Fetch Time',
+  /** Label for a row in a table that describes the kind of device that was emulated for the Lighthouse run.  Example values for row elements: 'No Emulation', 'Emulated Desktop', etc. */
+  runtimeSettingsDevice: 'Device',
+  /** Label for a row in a table that describes the network throttling conditions that were used during a Lighthouse run, if any. */
+  runtimeSettingsNetworkThrottling: 'Network throttling',
+  /** Label for a row in a table that describes the CPU throttling conditions that were used during a Lighthouse run, if any.*/
+  runtimeSettingsCPUThrottling: 'CPU throttling',
+  /** Label for a row in a table that shows the User Agent that was detected on the Host machine that ran Lighthouse. */
+  runtimeSettingsUA: 'User agent (host)',
+  /** Label for a row in a table that shows the User Agent that was used to send out all network requests during the Lighthouse run. */
+  runtimeSettingsUANetwork: 'User agent (network)',
+  /** Label for a row in a table that shows the estimated CPU power of the machine running Lighthouse. Example row values: 532, 1492, 783. */
+  runtimeSettingsBenchmark: 'CPU/Memory Power',
+
+  /** Label for button to create an issue against the Lighthouse Github project. */
+  footerIssue: 'File an issue',
+
+  /** Descriptive explanation for emulation setting when no device emulation is set. */
+  runtimeNoEmulation: 'No emulation',
+  /** Descriptive explanation for emulation setting when emulating a Nexus 5X mobile device. */
+  runtimeMobileEmulation: 'Emulated Nexus 5X',
+  /** Descriptive explanation for emulation setting when emulating a generic desktop form factor, as opposed to a mobile-device like form factor. */
+  runtimeDesktopEmulation: 'Emulated Desktop',
+  /** Descriptive explanation for a runtime setting that is set to an unknown value. */
+  runtimeUnknown: 'Unknown',
+
+  /** Descriptive explanation for environment throttling that was provided by the runtime environment instead of provided by Lighthouse throttling. */
+  throttlingProvided: 'Provided by environment',
 };
 
 if (typeof module !== 'undefined' && module.exports) {
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index 85e686eae..e0f3752fe 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -360,6 +360,10 @@ limitations under the License.
         position: static;
         margin-left: 0;
       }
+
+      .lh-tools__dropdown {
+        display: none;
+      }
     }
   </style>
 
@@ -406,14 +410,14 @@ limitations under the License.
       </button>
       <div id="lh-tools-dropdown" role="menu" class="lh-tools__dropdown" aria-labelledby="lh-tools-button">
          <!-- TODO(i18n): localize tools dropdown -->
-        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--print" data-action="print-summary">Print Summary</a>
-        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--print" data-action="print-expanded">Print Expanded</a>
-        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--copy" data-action="copy">Copy JSON</a>
-        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--download" data-action="save-html">Save as HTML</a>
-        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--download" data-action="save-json">Save as JSON</a>
-        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--open lh-tools--viewer" data-action="open-viewer">Open in Viewer</a>
-        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--open lh-tools--gist" data-action="save-gist">Save as Gist</a>
-        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--dark" data-action="toggle-dark">Toggle Dark Theme</a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--print" data-i18n="dropdownPrintSummary" data-action="print-summary"></a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--print" data-i18n="dropdownPrintExpanded" data-action="print-expanded"></a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--copy" data-i18n="dropdownCopyJSON" data-action="copy"></a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--download" data-i18n="dropdownSaveHTML" data-action="save-html"></a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--download" data-i18n="dropdownSaveJSON" data-action="save-json"></a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--open lh-tools--viewer" data-i18n="dropdownViewer" data-action="open-viewer"></a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--open lh-tools--gist" data-i18n="dropdownSaveGist" data-action="save-gist"></a>
+        <a role="menuitem" tabindex="-1" href="#" class="report-icon report-icon--dark" data-i18n="dropdownDarkTheme" data-action="toggle-dark"></a>
       </div>
     </div>
   </div>
@@ -558,8 +562,9 @@ limitations under the License.
     </div>
 
     <div class="lh-generated">
+      <!-- TODO(i18n): use ICU replacement to replace version w/o concatenation. -->
       Generated by <b>Lighthouse</b> <span class="lh-footer__version"></span> |
-      <a href="https://github.com/GoogleChrome/Lighthouse/issues" target="_blank" rel="noopener">File an issue</a>
+      <a href="https://github.com/GoogleChrome/Lighthouse/issues" target="_blank" rel="noopener" class="lh-footer__version_issue">File an issue</a>
     </div>
   </footer>
 </template>
diff --git a/lighthouse-core/scripts/i18n/collect-strings.js b/lighthouse-core/scripts/i18n/collect-strings.js
index efa89284e..7f783078c 100644
--- a/lighthouse-core/scripts/i18n/collect-strings.js
+++ b/lighthouse-core/scripts/i18n/collect-strings.js
@@ -463,6 +463,9 @@ const seenStrings = new Map();
 /** @type {number} */
 let collisions = 0;
 
+/** @type {Array<string>} */
+const collisionStrings = [];
+
 /**
  * Collects all LHL messsages defined in UIString from Javascript files in dir,
  * and converts them into CTC.
@@ -530,6 +533,7 @@ function collectAllStringsInDir(dir) {
             strings[seenId].meaning = strings[seenId].description;
             collisions++;
           }
+          collisionStrings.push(ctc.message);
           collisions++;
         }
       }
@@ -565,8 +569,8 @@ if (require.main === module) {
   console.log('Collected from Stack Packs!');
 
   if ((collisions) > 0) {
-    assert.equal(collisions, 15, 'The number of duplicate strings have changed, update this assertion if that is expected, or reword strings.');
     console.log(`MEANING COLLISION: ${collisions} string(s) have the same content.`);
+    assert.equal(collisions, 17, `The number of duplicate strings have changed, update this assertion if that is expected, or reword strings. Collisions: ${collisionStrings}`);
   }
 
   const strings = {...coreStrings, ...stackPackStrings};
diff --git a/proto/lighthouse-result.proto b/proto/lighthouse-result.proto
index 636bcd256..52ff4f369 100644
--- a/proto/lighthouse-result.proto
+++ b/proto/lighthouse-result.proto
@@ -363,6 +363,75 @@ message I18n {
 
     // This label is for a filter checkbox above a table of items
     string third_party_resources_label = 20;
+
+    // Descriptive explanation for emulation setting when emulating a generic desktop form factor, as opposed to a mobile-device like form factor.
+    string runtime_desktop_emulation = 21;
+    
+    // Descriptive explanation for emulation setting when emulating a Nexus 5X mobile device.
+    string runtime_mobile_emulation = 22;
+    
+    // Descriptive explanation for emulation setting when no device emulation is set.
+    string runtime_no_emulation = 23;
+    
+    // Label for a row in a table that shows the estimated CPU power of the machine running Lighthouse. Example row values: 532, 1492, 783.
+    string runtime_settings_benchmark = 24;
+    
+    // Label for a row in a table that describes the CPU throttling conditions that were used during a Lighthouse run, if any.
+    string runtime_settings_CPU_throttling = 25;
+    
+    // Label for a row in a table that describes the kind of device that was emulated for the Lighthouse run.  Example values for row elements: 'No Emulation', 'Emulated Desktop', etc.
+    string runtime_settings_device = 26;
+    
+    // Label for a row in a table that shows the time at which a Lighthouse run was conducted; formatted as a timestamp, e.g. Jan 1, 1970 12:00 AM UTC.
+    string runtime_settings_fetch_time = 27;
+    
+    // Label for a row in a table that describes the network throttling conditions that were used during a Lighthouse run, if any.
+    string runtime_settings_network_throttling = 28;
+    
+    // Title of the Runtime settings table in a Lighthouse report.  Runtime settings are the environment configurations that a specific report used at auditing time.
+    string runtime_settings_title = 29;
+    
+    // Label for a row in a table that shows the User Agent that was detected on the Host machine that ran Lighthouse.
+    string runtime_settings_UA = 30;
+    
+    // Label for a row in a table that shows the User Agent that was used to send out all network requests during the Lighthouse run.
+    string runtime_settings_UA_network = 31;
+    
+    // Label for a row in a table that shows the URL that was audited during a Lighthouse run.
+    string runtime_settings_Url = 32;
+    
+    // Descriptive explanation for a runtime setting that is set to an unknown value.
+    string runtime_unknown = 33;
+    
+    // Option in a dropdown menu that copies the Lighthouse JSON object to the system clipboard.
+    string dropdown_copy_JSON = 34;
+    
+    // Option in a dropdown menu that toggles the themeing of the report between Light(default) and Dark themes.
+    string dropdown_dark_theme = 35;
+    
+    // Option in a dropdown menu that opens a full Lighthouse report in a print dialog. 
+    string dropdown_print_expanded = 36;
+    
+    // Option in a dropdown menu that opens a small, summary report in a print dialog. 
+    string dropdown_print_summary = 37;
+    
+    // Option in a dropdown menu that saves the current report as a new Github Gist.
+    string dropdown_save_gist = 38;
+    
+    // Option in a dropdown menu that saves the Lighthouse report HTML locally to the system as a '.html' file.
+    string dropdown_save_HTML = 39;
+    
+    // Option in a dropdown menu that saves the Lighthouse JSON object to the local system as a '.json' file.
+    string dropdown_save_JSON = 40;
+    
+    // Option in a dropdown menu that opens the current report in the Lighthouse Viewer Application.
+    string dropdown_viewer = 41;
+    
+    // Label for button to create an issue against the Lighthouse Github project.
+    string footer_issue = 42;
+    
+    // Descriptive explanation for environment throttling that was provided by the runtime environment instead of provided by Lighthouse throttling.
+    string throttling_provided = 43;
   }
 
   // The message holding all formatted strings
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 356185c9c..70155ce52 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -1283,7 +1283,7 @@
             "title": "Form elements do not have associated labels"
         },
         "largest-contentful-paint": {
-            "description": "Largest Contentful Paint marks the time at which the largest text or image is painted. [Learn More](https://web.dev/largest-contentful-paint)",
+            "description": "Largest Contentful Paint marks the time at which the largest text or image is painted. [Learn More](https://web.dev/lighthouse-largest-contentful-paint)",
             "displayValue": "4.9\u00a0s",
             "id": "largest-contentful-paint",
             "numericValue": 4927.278,
@@ -2208,19 +2208,19 @@
                         "sizeOverBudget": 72955.0
                     },
                     {
-                        "countOverBudget": "8 requests",
+                        "countOverBudget": "7 requests",
                         "label": "Total",
-                        "requestCount": 18.0,
+                        "requestCount": 17.0,
                         "resourceType": "total",
-                        "size": 160738.0,
-                        "sizeOverBudget": 58338.0
+                        "size": 160517.0,
+                        "sizeOverBudget": 58117.0
                     },
                     {
                         "label": "Other",
-                        "requestCount": 2.0,
+                        "requestCount": 1.0,
                         "resourceType": "other",
-                        "size": 12861.0,
-                        "sizeOverBudget": 7741.0
+                        "size": 12640.0,
+                        "sizeOverBudget": 7520.0
                     },
                     {
                         "countOverBudget": "1 request",
@@ -2405,9 +2405,9 @@
                 "items": [
                     {
                         "label": "Total",
-                        "requestCount": 18.0,
+                        "requestCount": 17.0,
                         "resourceType": "total",
-                        "size": 160738.0
+                        "size": 160517.0
                     },
                     {
                         "label": "Script",
@@ -2429,9 +2429,9 @@
                     },
                     {
                         "label": "Other",
-                        "requestCount": 2.0,
+                        "requestCount": 1.0,
                         "resourceType": "other",
-                        "size": 12861.0
+                        "size": 12640.0
                     },
                     {
                         "label": "Stylesheet",
@@ -2460,7 +2460,7 @@
                 ],
                 "type": "table"
             },
-            "displayValue": "18 requests \u2022 157 KB",
+            "displayValue": "17 requests \u2022 157 KB",
             "id": "resource-summary",
             "score": null,
             "scoreDisplayMode": "informative",
@@ -4135,8 +4135,17 @@
             "auditGroupExpandTooltip": "Show audits",
             "crcInitialNavigation": "Initial Navigation",
             "crcLongestDurationLabel": "Maximum critical path latency:",
+            "dropdownCopyJSON": "Copy JSON",
+            "dropdownDarkTheme": "Toggle Dark Theme",
+            "dropdownPrintExpanded": "Print Expanded",
+            "dropdownPrintSummary": "Print Summary",
+            "dropdownSaveGist": "Save as Gist",
+            "dropdownSaveHTML": "Save as HTML",
+            "dropdownSaveJSON": "Save as JSON",
+            "dropdownViewer": "Open in Viewer",
             "errorLabel": "Error!",
             "errorMissingAuditInfo": "Report error: no audit information",
+            "footerIssue": "File an issue",
             "labDataTitle": "Lab Data",
             "lsPerformanceCategoryDescription": "[Lighthouse](https://developers.google.com/web/tools/lighthouse/) analysis of the current page on an emulated mobile network. Values are estimated and may vary.",
             "manualAuditsGroupTitle": "Additional items to manually check",
@@ -4144,9 +4153,23 @@
             "opportunityResourceColumnLabel": "Opportunity",
             "opportunitySavingsColumnLabel": "Estimated Savings",
             "passedAuditsGroupTitle": "Passed audits",
+            "runtimeDesktopEmulation": "Emulated Desktop",
+            "runtimeMobileEmulation": "Emulated Nexus 5X",
+            "runtimeNoEmulation": "No emulation",
+            "runtimeSettingsBenchmark": "CPU/Memory Power",
+            "runtimeSettingsCPUThrottling": "CPU throttling",
+            "runtimeSettingsDevice": "Device",
+            "runtimeSettingsFetchTime": "Fetch Time",
+            "runtimeSettingsNetworkThrottling": "Network throttling",
+            "runtimeSettingsTitle": "Runtime Settings",
+            "runtimeSettingsUA": "User agent (host)",
+            "runtimeSettingsUANetwork": "User agent (network)",
+            "runtimeSettingsUrl": "URL",
+            "runtimeUnknown": "Unknown",
             "snippetCollapseButtonLabel": "Collapse snippet",
             "snippetExpandButtonLabel": "Expand snippet",
             "thirdPartyResourcesLabel": "Show 3rd-party resources",
+            "throttlingProvided": "Provided by environment",
             "toplevelWarningsMessage": "There were issues affecting this run of Lighthouse:",
             "varianceDisclaimer": "Values are estimated and may vary. The performance score is [based only on these metrics](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted).",
             "warningAuditsGroupTitle": "Passed audits but with warnings",
@@ -5009,6 +5032,12 @@
                 "name": "lh:audit:render-blocking-resources",
                 "startTime": 0.0
             },
+            {
+                "duration": 100.0,
+                "entryType": "measure",
+                "name": "lh:computed:UnusedCSS",
+                "startTime": 0.0
+            },
             {
                 "duration": 100.0,
                 "entryType": "measure",
diff --git a/tsconfig.json b/tsconfig.json
index fe0eecb3d..dfdb8067a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -28,5 +28,6 @@
   "files": [
     // Opt-in to typechecking for some core tests.
     "lighthouse-core/test/gather/driver-test.js",
+    "lighthouse-core/test/gather/gather-runner-test.js",
   ],
 }
diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts
index 5dc356fd3..e63f5b13b 100644
--- a/types/artifacts.d.ts
+++ b/types/artifacts.d.ts
@@ -307,7 +307,6 @@ declare global {
       export interface FontSize {
         totalTextLength: number;
         failingTextLength: number;
-        visitedTextLength: number;
         analyzedFailingTextLength: number;
         /** Elements that contain a text node that failed size criteria. */
         analyzedFailingNodesData: Array<{
diff --git a/types/externs.d.ts b/types/externs.d.ts
index 2c210b209..6c878c7b5 100644
--- a/types/externs.d.ts
+++ b/types/externs.d.ts
@@ -7,6 +7,30 @@
 import _Crdp from 'devtools-protocol/types/protocol';
 import _CrdpMappings from 'devtools-protocol/types/protocol-mapping'
 
+// Convert unions (T1 | T2 | T3) into tuples ([T1, T2, T3]).
+// https://stackoverflow.com/a/52933137/2788187 https://stackoverflow.com/a/50375286
+type UnionToIntersection<U> =
+(U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never
+
+type UnionToFunctions<U> =
+  U extends unknown ? (k: U) => void : never;
+
+type IntersectionOfFunctionsToType<F> =
+  F extends { (a: infer A): void; (b: infer B): void; (c: infer C): void; (d: infer D): void; } ? [A, B, C, D] :
+  F extends { (a: infer A): void; (b: infer B): void; (c: infer C): void; } ? [A, B, C] :
+  F extends { (a: infer A): void; (b: infer B): void; } ? [A, B] :
+  F extends { (a: infer A): void } ? [A] :
+  never;
+
+type SplitType<T> =
+  IntersectionOfFunctionsToType<UnionToIntersection<UnionToFunctions<T>>>;
+
+// (T1 | T2 | T3) -> [RecursivePartial(T1), RecursivePartial(T2), RecursivePartial(T3)]
+type RecursivePartialUnion<T, S=SplitType<T>> = {[P in keyof S]: RecursivePartial<S[P]>};
+
+// Return length of a tuple.
+type GetLength<T extends any[]> = T extends { length: infer L } ? L : never;
+
 declare global {
   // Augment Intl to include
   // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/getCanonicalLocales
@@ -29,8 +53,26 @@ declare global {
   /** Make optional all properties on T and any properties on object properties of T. */
   type RecursivePartial<T> = {
     [P in keyof T]+?:
+      // RE: First two conditions.
+      // If type is a union, map each individual component and transform the resultant tuple back into a union.
+      // Only up to 4 components of a union is supported (all but the last few are dropped). For more, modify the second condition
+      // and `IntersectionOfFunctionsToType`.
+      // Ex: `{passes: PassJson[] | null}` - T[P] doesn't exactly match the array-recursing condition, so without these first couple
+      // conditions, it would fall through to the last condition (would just return T[P]).
+
+      // RE: First condition.
+      // Guard against large string unions, which would be unreasonable to support (much more than 4 components is common).
+
+      SplitType<T[P]> extends string[] ? T[P] :
+      GetLength<SplitType<T[P]>> extends 2|3|4 ? RecursivePartialUnion<T[P]>[number] :
+
+      // Recurse into arrays.
       T[P] extends (infer U)[] ? RecursivePartial<U>[] :
+
+      // Recurse into objects.
       T[P] extends (object|undefined) ? RecursivePartial<T[P]> :
+
+      // Strings, numbers, etc. (terminal types) end here.
       T[P];
   };
 
diff --git a/types/smokehouse.d.ts b/types/smokehouse.d.ts
index 070015b1f..caa4e7746 100644
--- a/types/smokehouse.d.ts
+++ b/types/smokehouse.d.ts
@@ -9,7 +9,7 @@
     audits: Record<string, any>;
     requestedUrl: string;
     finalUrl: string;
-    runWarnings?: Array<string>;
+    runWarnings?: Array<string|RegExp>;
     runtimeError?: {
       code?: any;
       message?: any;


Test patch:
diff --git a/lighthouse-cli/test/fixtures/byte-efficiency/tester.html b/lighthouse-cli/test/fixtures/byte-efficiency/tester.html
index 03982adc5..e1368d5a6 100644
--- a/lighthouse-cli/test/fixtures/byte-efficiency/tester.html
+++ b/lighthouse-cli/test/fixtures/byte-efficiency/tester.html
@@ -53,7 +53,7 @@ setTimeout(() => {
     left: 0;
   }
 </style>
-<script src="script.js"></script>
+<script src="script.js" type="application/javascript"></script>
 </head>
 
 <body>
@@ -171,8 +171,8 @@ generateInlineStyleWithSize(12000, '.kinda-unused { background: none; }\n');
 generateInlineStyleWithSize(30000, '.definitely-unused { background: none; }\n');
 </script>
 
-<!-- Ensure the page takes at least 7 seconds and we don't exit before the lazily loaded image -->
-<script src="delay-complete.js?delay=7000" async></script>
+<!-- Ensure the page takes at least 8 seconds and we don't exit before the lazily loaded image -->
+<script src="delay-complete.js?delay=8000" async></script>
 <script>
   // Used block #1
   // FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES -- FILLER DATA JUST TO OCCUPY BYTES
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/byte-efficiency/expectations.js b/lighthouse-cli/test/smokehouse/test-definitions/byte-efficiency/expectations.js
index 990fd774f..d9bd8b3c9 100644
--- a/lighthouse-cli/test/smokehouse/test-definitions/byte-efficiency/expectations.js
+++ b/lighthouse-cli/test/smokehouse/test-definitions/byte-efficiency/expectations.js
@@ -11,6 +11,68 @@
  */
 const expectations = [
   {
+    artifacts: {
+      ScriptElements: [
+        {
+          type: null,
+          src: null,
+          async: false,
+          defer: false,
+          source: 'head',
+          devtoolsNodePath: '2,HTML,0,HEAD,3,SCRIPT',
+        },
+        {
+          type: 'application/javascript',
+          src: 'http://localhost:10200/byte-efficiency/script.js',
+          async: false,
+          defer: false,
+          source: 'head',
+          devtoolsNodePath: '2,HTML,0,HEAD,5,SCRIPT',
+        },
+        {
+          type: null,
+          src: null,
+          async: false,
+          defer: false,
+          source: 'body',
+          devtoolsNodePath: '2,HTML,1,BODY,0,DIV,3,SCRIPT',
+        },
+        {
+          type: null,
+          src: null,
+          async: false,
+          defer: false,
+          source: 'body',
+          devtoolsNodePath: '2,HTML,1,BODY,3,SCRIPT',
+        },
+        {
+          type: null,
+          src: 'http://localhost:10200/byte-efficiency/delay-complete.js?delay=8000',
+          async: true,
+          defer: false,
+          source: 'body',
+          devtoolsNodePath: '2,HTML,1,BODY,1438,SCRIPT',
+        },
+        {
+          type: null,
+          src: null,
+          async: false,
+          defer: false,
+          source: 'body',
+          devtoolsNodePath: '2,HTML,1,BODY,1439,SCRIPT',
+          content: /Used block #1/,
+        },
+        {
+          type: null,
+          src: null,
+          async: false,
+          defer: false,
+          source: 'body',
+          devtoolsNodePath: '2,HTML,1,BODY,1440,SCRIPT',
+          content: /Unused block #1/,
+        },
+      ],
+    },
     lhr: {
       requestedUrl: 'http://localhost:10200/byte-efficiency/tester.html',
       finalUrl: 'http://localhost:10200/byte-efficiency/tester.html',
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/perf/expectations.js b/lighthouse-cli/test/smokehouse/test-definitions/perf/expectations.js
index 0ca81582e..ffdd83e95 100644
--- a/lighthouse-cli/test/smokehouse/test-definitions/perf/expectations.js
+++ b/lighthouse-cli/test/smokehouse/test-definitions/perf/expectations.js
@@ -73,15 +73,15 @@ module.exports = [
       audits: {
         'resource-summary': {
           score: null,
-          displayValue: '11 requests • 164 KB',
+          displayValue: '10 requests • 164 KB',
           details: {
             items: [
-              {resourceType: 'total', requestCount: 11, size: '168000±1000'},
+              {resourceType: 'total', requestCount: 10, size: '168000±1000'},
               {resourceType: 'font', requestCount: 2, size: '80000±1000'},
               {resourceType: 'script', requestCount: 3, size: '55000±1000'},
               {resourceType: 'image', requestCount: 2, size: '28000±1000'},
               {resourceType: 'document', requestCount: 1, size: '2200±100'},
-              {resourceType: 'other', requestCount: 2, size: '1300±50'},
+              {resourceType: 'other', requestCount: 1, size: '1000±50'},
               {resourceType: 'stylesheet', requestCount: 1, size: '450±100'},
               {resourceType: 'media', requestCount: 0, size: 0},
               {resourceType: 'third-party', requestCount: 0, size: 0},
@@ -95,7 +95,7 @@ module.exports = [
             items: [
               {
                 resourceType: 'total',
-                countOverBudget: '3 requests',
+                countOverBudget: '2 requests',
                 sizeOverBudget: '65000±1000',
               },
               {
@@ -130,7 +130,7 @@ module.exports = [
               },
               {
                 resourceType: 'other',
-                countOverBudget: '1 request',
+                countOverBudget: undefined,
                 sizeOverBudget: undefined,
               },
               {
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/pwa/pwa-expectations.js b/lighthouse-cli/test/smokehouse/test-definitions/pwa/pwa-expectations.js
index c97c2897e..a3437b8da 100644
--- a/lighthouse-cli/test/smokehouse/test-definitions/pwa/pwa-expectations.js
+++ b/lighthouse-cli/test/smokehouse/test-definitions/pwa/pwa-expectations.js
@@ -80,7 +80,7 @@ const expectations = [
 
   {
     lhr: {
-      requestedUrl: 'https://www.chromestatus.com/',
+      requestedUrl: 'https://www.chromestatus.com/features',
       finalUrl: 'https://www.chromestatus.com/features',
       audits: {
         'is-on-https': {
@@ -93,7 +93,7 @@ const expectations = [
           score: 1,
         },
         'works-offline': {
-          score: 0,
+          score: 1,
         },
         'offline-start-url': {
           score: 1,
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/redirects/expectations.js b/lighthouse-cli/test/smokehouse/test-definitions/redirects/expectations.js
index 06b66ac63..68a37e3b3 100644
--- a/lighthouse-cli/test/smokehouse/test-definitions/redirects/expectations.js
+++ b/lighthouse-cli/test/smokehouse/test-definitions/redirects/expectations.js
@@ -27,6 +27,9 @@ const expectations = [
           },
         },
       },
+      runWarnings: [
+        /The page may not be loading as expected because your test URL \(.*online-only.html.*\) was redirected to .*redirects-final.html. Try testing the second URL directly./,
+      ],
     },
   },
   {
@@ -44,6 +47,9 @@ const expectations = [
           },
         },
       },
+      runWarnings: [
+        /The page may not be loading as expected because your test URL \(.*online-only.html.*\) was redirected to .*redirects-final.html. Try testing the second URL directly./,
+      ],
     },
   },
 ];
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/seo/expectations.js b/lighthouse-cli/test/smokehouse/test-definitions/seo/expectations.js
index 1fc36155d..b3a4b7710 100644
--- a/lighthouse-cli/test/smokehouse/test-definitions/seo/expectations.js
+++ b/lighthouse-cli/test/smokehouse/test-definitions/seo/expectations.js
@@ -162,7 +162,7 @@ const expectations = [
               {
                 source: {type: 'code', value: 'User Agent Stylesheet'},
                 selector: 'h6',
-                fontSize: '10px',
+                fontSize: '10.72px',
               },
               {
                 source: {type: 'url', value: /seo-tester\.html.+$/},
diff --git a/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js b/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js
index be8b64b28..1fd3946bb 100644
--- a/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/unused-css-rules-test.js
@@ -20,105 +20,8 @@ describe('Best Practices: unused css rules audit', () => {
     return arr.join('');
   }
 
-  describe('#determineContentPreview', () => {
-    function assertLinesContained(actual, expected) {
-      expected.split('\n').forEach(line => {
-        assert.ok(actual.includes(line.trim()), `${line} is found in preview`);
-      });
-    }
-
-    const preview = UnusedCSSAudit.determineContentPreview;
-
-    it('correctly computes short content preview', () => {
-      const shortContent = `
-        html, body {
-          background: green;
-        }
-      `.trim();
-
-      assertLinesContained(preview(shortContent), shortContent);
-    });
-
-    it('correctly computes long content preview', () => {
-      const longContent = `
-        body {
-          color: white;
-        }
-
-        html {
-          content: '${generate('random', 50)}';
-        }
-      `.trim();
-
-      assertLinesContained(preview(longContent), `
-        body {
-          color: white;
-        } ...
-      `.trim());
-    });
-
-    it('correctly computes long rule content preview', () => {
-      const longContent = `
-        body {
-          color: white;
-          font-size: 20px;
-          content: '${generate('random', 50)}';
-        }
-      `.trim();
-
-      assertLinesContained(preview(longContent), `
-        body {
-          color: white;
-          font-size: 20px; ... } ...
-      `.trim());
-    });
-
-    it('correctly computes long comment content preview', () => {
-      const longContent = `
-      /**
-       * @license ${generate('a', 100)}
-       */
-      `.trim();
-
-      assert.ok(/aaa\.\.\./.test(preview(longContent)));
-    });
-  });
-
-  describe('#mapSheetToResult', () => {
-    let baseSheet;
-    const baseUrl = 'http://g.co/';
-
-    function map(overrides, url = baseUrl) {
-      if (overrides.header && overrides.header.sourceURL) {
-        overrides.header.sourceURL = baseUrl + overrides.header.sourceURL;
-      }
-      return UnusedCSSAudit.mapSheetToResult(Object.assign(baseSheet, overrides), url);
-    }
-
-    beforeEach(() => {
-      baseSheet = {
-        header: {sourceURL: baseUrl},
-        content: 'dummy',
-        usedRules: [],
-      };
-    });
-
-    it('correctly computes wastedBytes', () => {
-      assert.equal(map({usedRules: []}).wastedPercent, 100);
-      assert.equal(map({usedRules: [{startOffset: 0, endOffset: 3}]}).wastedPercent, 40);
-      assert.equal(map({usedRules: [{startOffset: 0, endOffset: 5}]}).wastedPercent, 0);
-    });
-
-    it('correctly computes url', () => {
-      const expectedPreview = 'dummy';
-      assert.strictEqual(map({header: {sourceURL: ''}}).url, expectedPreview);
-      assert.strictEqual(map({header: {sourceURL: 'a'}}, 'http://g.co/a').url, expectedPreview);
-      assert.equal(map({header: {sourceURL: 'foobar'}}).url, 'http://g.co/foobar');
-    });
-  });
-
   describe('#audit', () => {
-    const networkRecords = [
+    const defaultNetworkRecords = [
       {
         url: 'file://a.css',
         transferSize: 100 * 1024,
@@ -127,7 +30,9 @@ describe('Best Practices: unused css rules audit', () => {
       },
     ];
 
-    function getArtifacts({CSSUsage}) {
+    const context = {computedCache: new Map()};
+
+    function getArtifacts({CSSUsage, networkRecords = defaultNetworkRecords}) {
       return {
         devtoolsLogs: {defaultPass: networkRecordsToDevtoolsLog(networkRecords)},
         URL: {finalUrl: ''},
@@ -135,10 +40,14 @@ describe('Best Practices: unused css rules audit', () => {
       };
     }
 
+    beforeEach(() => {
+      context.computedCache.clear();
+    });
+
     it('ignores missing stylesheets', () => {
       return UnusedCSSAudit.audit_(getArtifacts({
         CSSUsage: {rules: [{styleSheetId: 'a', used: false}], stylesheets: []},
-      }), networkRecords).then(result => {
+      }), defaultNetworkRecords, context).then(result => {
         assert.equal(result.items.length, 0);
       });
     });
@@ -159,7 +68,7 @@ describe('Best Practices: unused css rules audit', () => {
             content: '.my.favorite.selector { rule: content; }',
           },
         ]},
-      }), networkRecords).then(result => {
+      }), defaultNetworkRecords, context).then(result => {
         assert.equal(result.items.length, 0);
       });
     });
@@ -183,7 +92,7 @@ describe('Best Practices: unused css rules audit', () => {
             content: `${generate('123', 450)}`, // will be filtered out
           },
         ]},
-      }), networkRecords).then(result => {
+      }), defaultNetworkRecords, context).then(result => {
         assert.equal(result.items.length, 2);
         assert.equal(result.items[0].totalBytes, 100 * 1024);
         assert.equal(result.items[1].totalBytes, 40000 * 3 * 0.2);
@@ -193,16 +102,7 @@ describe('Best Practices: unused css rules audit', () => {
     });
 
     it('handles phantom network records without size data', async () => {
-      const result = await UnusedCSSAudit.audit_(getArtifacts({
-        CSSUsage: {rules: [
-          {styleSheetId: 'a', used: true, startOffset: 0, endOffset: 60000}, // 40000 * 3 * 50% = 60000
-        ], stylesheets: [
-          {
-            header: {styleSheetId: 'a', sourceURL: 'file://a.html'},
-            content: `${generate('123', 40000)}`, // stylesheet size of 40000 * 3 uncompressed bytes
-          },
-        ]},
-      }), [
+      const networkRecords = [
         {
           url: 'file://a.html',
           transferSize: 100 * 1024 * 0.5, // compression ratio of 0.5
@@ -215,8 +115,22 @@ describe('Best Practices: unused css rules audit', () => {
           resourceSize: 0,
           resourceType: 'Document',
         },
-      ]);
+      ];
+      const artifacts = getArtifacts({
+        CSSUsage: {
+          rules: [
+            {styleSheetId: 'a', used: true, startOffset: 0, endOffset: 60000}, // 40000 * 3 * 50% = 60000
+          ], stylesheets: [
+            {
+              header: {styleSheetId: 'a', sourceURL: 'file://a.html'},
+              content: `${generate('123', 40000)}`, // stylesheet size of 40000 * 3 uncompressed bytes
+            },
+          ],
+        },
+        networkRecords,
+      });
 
+      const result = await UnusedCSSAudit.audit_(artifacts, networkRecords, context);
       expect(result.items).toMatchObject([
         {totalBytes: 40000 * 3 * 0.5, wastedPercent: 50},
       ]);
@@ -249,7 +163,7 @@ describe('Best Practices: unused css rules audit', () => {
             content: '       ',
           },
         ]},
-      }), networkRecords).then(result => {
+      }), defaultNetworkRecords, context).then(result => {
         assert.equal(result.items.length, 1);
         assert.equal(Math.floor(result.items[0].wastedPercent), 33);
       });
diff --git a/lighthouse-core/test/audits/seo/font-size-test.js b/lighthouse-core/test/audits/seo/font-size-test.js
index f0e77bbcc..81a62b09e 100644
--- a/lighthouse-core/test/audits/seo/font-size-test.js
+++ b/lighthouse-core/test/audits/seo/font-size-test.js
@@ -41,7 +41,6 @@ describe('SEO: Font size audit', () => {
       MetaElements: makeMetaElements(validViewport),
       FontSize: {
         totalTextLength: 100,
-        visitedTextLength: 100,
         failingTextLength: 41,
         analyzedFailingTextLength: 41,
         analyzedFailingNodesData: [
@@ -63,7 +62,6 @@ describe('SEO: Font size audit', () => {
       MetaElements: makeMetaElements(validViewport),
       FontSize: {
         totalTextLength: 0,
-        visitedTextLength: 0,
         failingTextLength: 0,
         analyzedFailingTextLength: 0,
         analyzedFailingNodesData: [
@@ -83,7 +81,6 @@ describe('SEO: Font size audit', () => {
       MetaElements: makeMetaElements(validViewport),
       FontSize: {
         totalTextLength: 330,
-        visitedTextLength: 330,
         failingTextLength: 33,
         analyzedFailingTextLength: 33,
         analyzedFailingNodesData: [
@@ -120,7 +117,6 @@ describe('SEO: Font size audit', () => {
       MetaElements: makeMetaElements(validViewport),
       FontSize: {
         totalTextLength: 7,
-        visitedTextLength: 7,
         failingTextLength: 7,
         analyzedFailingTextLength: 7,
         analyzedFailingNodesData: [
@@ -145,7 +141,6 @@ describe('SEO: Font size audit', () => {
       MetaElements: makeMetaElements(validViewport),
       FontSize: {
         totalTextLength: 100,
-        visitedTextLength: 100,
         failingTextLength: 50,
         analyzedFailingTextLength: 10,
         analyzedFailingNodesData: [
@@ -171,7 +166,6 @@ describe('SEO: Font size audit', () => {
       MetaElements: makeMetaElements(validViewport),
       FontSize: {
         totalTextLength: 100,
-        visitedTextLength: 50,
         failingTextLength: 50,
         analyzedFailingTextLength: 50,
         analyzedFailingNodesData: [
@@ -182,10 +176,7 @@ describe('SEO: Font size audit', () => {
     };
     const auditResult = await FontSizeAudit.audit(artifacts, getFakeContext());
     assert.equal(auditResult.score, 0);
-    expect(auditResult.explanation).toBeDisplayString(
-      '100% of text is too small (based on 50% sample).'
-    );
-    expect(auditResult.displayValue).toBeDisplayString('0% legible text');
+    expect(auditResult.displayValue).toBeDisplayString('50% legible text');
   });
 
   it('maintains 2 trailing decimal places', async () => {
@@ -194,7 +185,6 @@ describe('SEO: Font size audit', () => {
       MetaElements: makeMetaElements(validViewport),
       FontSize: {
         totalTextLength: 323,
-        visitedTextLength: 323,
         failingTextLength: 33,
         analyzedFailingTextLength: 33,
         analyzedFailingNodesData: [
@@ -214,7 +204,6 @@ describe('SEO: Font size audit', () => {
       MetaElements: makeMetaElements(validViewport),
       FontSize: {
         totalTextLength: 323,
-        visitedTextLength: 323,
         failingTextLength: 315,
         analyzedFailingTextLength: 315,
         analyzedFailingNodesData: [
diff --git a/lighthouse-core/test/computed/resource-summary-test.js b/lighthouse-core/test/computed/resource-summary-test.js
index 87e2b98c7..d425f3fc9 100644
--- a/lighthouse-core/test/computed/resource-summary-test.js
+++ b/lighthouse-core/test/computed/resource-summary-test.js
@@ -60,6 +60,17 @@ describe('Resource summary computed', () => {
     assert.equal(result.other.size, 50);
   });
 
+  it('ignores /favicon.ico', async () => {
+    artifacts = mockArtifacts([
+      {url: 'http://example.com/file.html', resourceType: 'Document', transferSize: 30},
+      {url: 'http://example.com/favicon.ico', resourceType: 'Other', transferSize: 10},
+    ]);
+    const result = await ComputedResourceSummary.request(artifacts, context);
+
+    assert.equal(result.total.count, 1);
+    assert.equal(result.total.size, 30);
+  });
+
   describe('determining third-party resources', () => {
     it('with a third-party resource', async () => {
       artifacts = mockArtifacts([
diff --git a/lighthouse-core/test/computed/unused-css-test.js b/lighthouse-core/test/computed/unused-css-test.js
new file mode 100644
index 000000000..c0b69377d
--- /dev/null
+++ b/lighthouse-core/test/computed/unused-css-test.js
@@ -0,0 +1,118 @@
+/**
+ * @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';
+
+/* eslint-env jest */
+
+const assert = require('assert');
+const UnusedCSS = require('../../computed/unused-css.js');
+
+describe('UnusedCSS computed artifact', () => {
+  function generate(content, length) {
+    const arr = [];
+    for (let i = 0; i < length; i++) {
+      arr.push(content);
+    }
+    return arr.join('');
+  }
+
+  const preview = UnusedCSS.determineContentPreview;
+
+  describe('#determineContentPreview', () => {
+    function assertLinesContained(actual, expected) {
+      expected.split('\n').forEach(line => {
+        assert.ok(actual.includes(line.trim()), `${line} is found in preview`);
+      });
+    }
+
+    it('correctly computes short content preview', () => {
+      const shortContent = `
+            html, body {
+              background: green;
+            }
+          `.trim();
+
+      assertLinesContained(preview(shortContent), shortContent);
+    });
+
+    it('correctly computes long content preview', () => {
+      const longContent = `
+            body {
+              color: white;
+            }
+    
+            html {
+              content: '${generate('random', 50)}';
+            }
+          `.trim();
+
+      assertLinesContained(preview(longContent), `
+            body {
+              color: white;
+            } ...
+          `.trim());
+    });
+
+    it('correctly computes long rule content preview', () => {
+      const longContent = `
+            body {
+              color: white;
+              font-size: 20px;
+              content: '${generate('random', 50)}';
+            }
+          `.trim();
+
+      assertLinesContained(preview(longContent), `
+            body {
+              color: white;
+              font-size: 20px; ... } ...
+          `.trim());
+    });
+
+    it('correctly computes long comment content preview', () => {
+      const longContent = `
+          /**
+           * @license ${generate('a', 100)}
+           */
+          `.trim();
+
+      assert.ok(/aaa\.\.\./.test(preview(longContent)));
+    });
+  });
+
+  describe('#mapSheetToResult', () => {
+    let baseSheet;
+    const baseUrl = 'http://g.co/';
+
+    function map(overrides, url = baseUrl) {
+      if (overrides.header && overrides.header.sourceURL) {
+        overrides.header.sourceURL = baseUrl + overrides.header.sourceURL;
+      }
+      return UnusedCSS.mapSheetToResult(Object.assign(baseSheet, overrides), url);
+    }
+
+    beforeEach(() => {
+      baseSheet = {
+        header: {sourceURL: baseUrl},
+        content: 'dummy',
+        usedRules: [],
+      };
+    });
+
+    it('correctly computes wastedBytes', () => {
+      assert.equal(map({usedRules: []}).wastedPercent, 100);
+      assert.equal(map({usedRules: [{startOffset: 0, endOffset: 3}]}).wastedPercent, 40);
+      assert.equal(map({usedRules: [{startOffset: 0, endOffset: 5}]}).wastedPercent, 0);
+    });
+
+    it('correctly computes url', () => {
+      const expectedPreview = 'dummy';
+      assert.strictEqual(map({header: {sourceURL: ''}}).url, expectedPreview);
+      assert.strictEqual(map({header: {sourceURL: 'a'}}, 'http://g.co/a').url, expectedPreview);
+      assert.equal(map({header: {sourceURL: 'foobar'}}).url, 'http://g.co/foobar');
+    });
+  });
+});
diff --git a/lighthouse-core/test/gather/fake-driver.js b/lighthouse-core/test/gather/fake-driver.js
index f41b055ca..43fb64de6 100644
--- a/lighthouse-core/test/gather/fake-driver.js
+++ b/lighthouse-core/test/gather/fake-driver.js
@@ -27,8 +27,9 @@ function makeFakeDriver({protocolGetVersionResponse}) {
     disconnect() {
       return Promise.resolve();
     },
-    gotoURL() {
-      return Promise.resolve('https://www.reddit.com/r/nba');
+    /** @param {string} url */
+    gotoURL(url) {
+      return Promise.resolve(url);
     },
     beginEmulation() {
       return Promise.resolve();
diff --git a/lighthouse-core/test/gather/gather-runner-test.js b/lighthouse-core/test/gather/gather-runner-test.js
index 74e0ea855..a6b4b87a5 100644
--- a/lighthouse-core/test/gather/gather-runner-test.js
+++ b/lighthouse-core/test/gather/gather-runner-test.js
@@ -8,16 +8,59 @@
 /* eslint-env jest */
 
 const Gatherer = require('../../gather/gatherers/gatherer.js');
-const GatherRunner = require('../../gather/gather-runner.js');
+const GatherRunner_ = require('../../gather/gather-runner.js');
 const assert = require('assert');
 const Config = require('../../config/config.js');
 const unresolvedPerfLog = require('./../fixtures/unresolved-perflog.json');
 const NetworkRequest = require('../../lib/network-request.js');
 const LHError = require('../../lib/lh-error.js');
 const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js');
+const Driver = require('../../gather/driver.js');
+const Connection = require('../../gather/connections/connection.js');
+const {createMockSendCommandFn} = require('./mock-commands.js');
 
 jest.mock('../../lib/stack-collector.js', () => () => Promise.resolve([]));
 
+/**
+ * @template {Array} TParams
+ * @template TReturn
+ * @param {(...args: TParams) => TReturn} fn
+ */
+function makeParamsOptional(fn) {
+  return /** @type {(...args: RecursivePartial<TParams>) => TReturn} */ (fn);
+}
+
+const GatherRunner = {
+  afterPass: makeParamsOptional(GatherRunner_.afterPass),
+  beginRecording: makeParamsOptional(GatherRunner_.beginRecording),
+  collectArtifacts: makeParamsOptional(GatherRunner_.collectArtifacts),
+  endRecording: makeParamsOptional(GatherRunner_.endRecording),
+  getInterstitialError: makeParamsOptional(GatherRunner_.getInterstitialError),
+  getNetworkError: makeParamsOptional(GatherRunner_.getNetworkError),
+  getPageLoadError: makeParamsOptional(GatherRunner_.getPageLoadError),
+  getWebAppManifest: makeParamsOptional(GatherRunner_.getWebAppManifest),
+  initializeBaseArtifacts: makeParamsOptional(GatherRunner_.initializeBaseArtifacts),
+  loadPage: makeParamsOptional(GatherRunner_.loadPage),
+  run: makeParamsOptional(GatherRunner_.run),
+  runPass: makeParamsOptional(GatherRunner_.runPass),
+  setupDriver: makeParamsOptional(GatherRunner_.setupDriver),
+  setupPassNetwork: makeParamsOptional(GatherRunner_.setupPassNetwork),
+};
+
+/**
+ * @param {RecursivePartial<LH.Config.Json>} json
+ */
+function makeConfig(json) {
+  // @ts-ignore: allow recursive partial.
+  return new Config(json);
+}
+
+const LoadFailureMode = {
+  fatal: /** @type {'fatal'} */ ('fatal'),
+  ignore: /** @type {'ignore'} */ ('ignore'),
+  warn: /** @type {'warn'} */ ('warn'),
+};
+
 class TestGatherer extends Gatherer {
   constructor() {
     super();
@@ -36,62 +79,63 @@ class TestGathererNoArtifact extends Gatherer {
   afterPass() {}
 }
 
+class EmulationDriver extends Driver {
+  enableRuntimeEvents() {
+    return Promise.resolve();
+  }
+  enableAsyncStacks() {
+    return Promise.resolve();
+  }
+  assertNoSameOriginServiceWorkerClients() {
+    return Promise.resolve();
+  }
+  cacheNatives() {
+    return Promise.resolve();
+  }
+  registerPerformanceObserver() {
+    return Promise.resolve();
+  }
+  cleanBrowserCaches() {
+    return Promise.resolve();
+  }
+  clearDataForOrigin() {
+    return Promise.resolve();
+  }
+}
+
 const fakeDriver = require('./fake-driver.js');
 const fakeDriverUsingRealMobileDevice = fakeDriver.fakeDriverUsingRealMobileDevice;
 
-// TODO: Refactor this to use gather/mock-commands.js
-function getMockedEmulationDriver(emulationFn, netThrottleFn, cpuThrottleFn,
-  blockUrlFn, extraHeadersFn) {
-  const Driver = require('../../gather/driver.js');
-  const Connection = require('../../gather/connections/connection.js');
-  const EmulationDriver = class extends Driver {
-    enableRuntimeEvents() {
-      return Promise.resolve();
-    }
-    enableAsyncStacks() {
-      return Promise.resolve();
-    }
-    assertNoSameOriginServiceWorkerClients() {
-      return Promise.resolve();
-    }
-    cacheNatives() {
-      return Promise.resolve();
-    }
-    registerPerformanceObserver() {
-      return Promise.resolve();
-    }
-    cleanBrowserCaches() {}
-    clearDataForOrigin() {}
-  };
-  const EmulationMock = class extends Connection {
-    sendCommand(command, sessionId, params) {
-      let fn = null;
-      switch (command) {
-        case 'Network.emulateNetworkConditions':
-          fn = netThrottleFn;
-          break;
-        case 'Emulation.setCPUThrottlingRate':
-          fn = cpuThrottleFn;
-          break;
-        case 'Emulation.setDeviceMetricsOverride':
-          fn = emulationFn;
-          break;
-        case 'Network.setBlockedURLs':
-          fn = blockUrlFn;
-          break;
-        case 'Network.setExtraHTTPHeaders':
-          fn = extraHeadersFn;
-          break;
-        default:
-          fn = null;
-          break;
-      }
-      return Promise.resolve(fn && fn(params));
-    }
-  };
-  return new EmulationDriver(new EmulationMock());
+/** @type {EmulationDriver} */
+let driver;
+/** @type {Connection & {sendCommand: ReturnType<typeof createMockSendCommandFn>}} */
+let connectionStub;
+
+function resetDefaultMockResponses() {
+  connectionStub.sendCommand = createMockSendCommandFn()
+    .mockResponse('Emulation.setCPUThrottlingRate')
+    .mockResponse('Emulation.setDeviceMetricsOverride')
+    .mockResponse('Emulation.setTouchEmulationEnabled')
+    .mockResponse('Network.emulateNetworkConditions')
+    .mockResponse('Network.enable')
+    .mockResponse('Network.setBlockedURLs')
+    .mockResponse('Network.setExtraHTTPHeaders')
+    .mockResponse('Network.setUserAgentOverride')
+    .mockResponse('Page.enable')
+    .mockResponse('ServiceWorker.enable');
 }
 
+beforeEach(() => {
+  // @ts-ignore - connectionStub has a mocked version of sendCommand implemented in each test
+  connectionStub = new Connection();
+  // @ts-ignore
+  connectionStub.sendCommand = cmd => {
+    throw new Error(`${cmd} not implemented`);
+  };
+  driver = new EmulationDriver(connectionStub);
+  resetDefaultMockResponses();
+});
+
 describe('GatherRunner', function() {
   it('loads a page and updates passContext.URL on redirect', () => {
     const url1 = 'https://example.com';
@@ -138,7 +182,7 @@ describe('GatherRunner', function() {
   it('collects benchmark as an artifact', async () => {
     const requestedUrl = 'https://example.com';
     const driver = fakeDriver;
-    const config = new Config({passes: []});
+    const config = makeConfig({passes: []});
     const options = {requestedUrl, driver, settings: config.settings};
 
     const results = await GatherRunner.run(config.passes, options);
@@ -148,7 +192,7 @@ describe('GatherRunner', function() {
   it('collects host user agent as an artifact', async () => {
     const requestedUrl = 'https://example.com';
     const driver = fakeDriver;
-    const config = new Config({passes: []});
+    const config = makeConfig({passes: []});
     const options = {requestedUrl, driver, settings: config.settings};
 
     const results = await GatherRunner.run(config.passes, options);
@@ -159,7 +203,7 @@ describe('GatherRunner', function() {
   it('collects network user agent as an artifact', async () => {
     const requestedUrl = 'https://example.com';
     const driver = fakeDriver;
-    const config = new Config({passes: [{}]});
+    const config = makeConfig({passes: [{}]});
     const options = {requestedUrl, driver, settings: config.settings};
 
     const results = await GatherRunner.run(config.passes, options);
@@ -174,7 +218,7 @@ describe('GatherRunner', function() {
         return Promise.resolve(finalUrl);
       },
     });
-    const config = new Config({passes: [{}]});
+    const config = makeConfig({passes: [{}]});
     const options = {requestedUrl, driver, settings: config.settings};
 
     return GatherRunner.run(config.passes, options).then(artifacts => {
@@ -188,7 +232,7 @@ describe('GatherRunner', function() {
 
     it('works when running on desktop device without emulation', async () => {
       const driver = fakeDriver;
-      const config = new Config({
+      const config = makeConfig({
         passes: [],
         settings: {emulatedFormFactor: 'none', internalDisableDeviceScreenEmulation: false},
       });
@@ -200,7 +244,7 @@ describe('GatherRunner', function() {
 
     it('works when running on desktop device with mobile emulation', async () => {
       const driver = fakeDriver;
-      const config = new Config({
+      const config = makeConfig({
         passes: [],
         settings: {emulatedFormFactor: 'mobile', internalDisableDeviceScreenEmulation: false},
       });
@@ -212,7 +256,7 @@ describe('GatherRunner', function() {
 
     it('works when running on mobile device without emulation', async () => {
       const driver = fakeDriverUsingRealMobileDevice;
-      const config = new Config({
+      const config = makeConfig({
         passes: [],
         settings: {emulatedFormFactor: 'none', internalDisableDeviceScreenEmulation: false},
       });
@@ -224,7 +268,7 @@ describe('GatherRunner', function() {
 
     it('works when running on mobile device with desktop emulation', async () => {
       const driver = fakeDriverUsingRealMobileDevice;
-      const config = new Config({
+      const config = makeConfig({
         passes: [],
         settings: {emulatedFormFactor: 'desktop', internalDisableDeviceScreenEmulation: false},
       });
@@ -238,6 +282,11 @@ describe('GatherRunner', function() {
   describe('collects HostFormFactor as an artifact', () => {
     const requestedUrl = 'https://example.com';
 
+    /**
+     * @param {string} name
+     * @param {string} userAgent
+     * @param {string} expectedValue
+     */
     function test(name, userAgent, expectedValue) {
       it(name, async () => {
         const driver = Object.assign({}, fakeDriver, {
@@ -245,7 +294,7 @@ describe('GatherRunner', function() {
             return Promise.resolve({userAgent: userAgent});
           },
         });
-        const config = new Config({
+        const config = makeConfig({
           passes: [],
           settings: {},
         });
@@ -267,110 +316,47 @@ describe('GatherRunner', function() {
     test('works when running on desktop device', DESKTOP_UA, 'desktop');
   });
 
-  it('sets up the driver to begin emulation when all emulation flags are undefined', () => {
-    const tests = {
-      calledDeviceEmulation: false,
-      calledNetworkEmulation: false,
-      calledCpuEmulation: false,
-    };
-    const createEmulationCheck = variable => (arg) => {
-      tests[variable] = arg;
-
-      return true;
-    };
-    const driver = getMockedEmulationDriver(
-      createEmulationCheck('calledDeviceEmulation'),
-      createEmulationCheck('calledNetworkEmulation'),
-      createEmulationCheck('calledCpuEmulation')
-    );
-
-    return GatherRunner.setupDriver(driver, {
+  it('sets up the driver to begin emulation when all flags are undefined', async () => {
+    await GatherRunner.setupDriver(driver, {
       settings: {
         emulatedFormFactor: 'mobile',
+        throttlingMethod: 'provided',
         internalDisableDeviceScreenEmulation: false,
       },
-    }).then(_ => {
-      assert.ok(tests.calledDeviceEmulation, 'did not call device emulation');
-      assert.deepEqual(tests.calledNetworkEmulation, {
-        latency: 0, downloadThroughput: 0, uploadThroughput: 0, offline: false,
-      });
-      assert.ok(!tests.calledCpuEmulation, 'called cpu emulation');
     });
+
+    connectionStub.sendCommand.findInvocation('Emulation.setDeviceMetricsOverride');
+    expect(connectionStub.sendCommand.findInvocation('Network.emulateNetworkConditions')).toEqual({
+      latency: 0, downloadThroughput: 0, uploadThroughput: 0, offline: false,
+    });
+    expect(() =>
+      connectionStub.sendCommand.findInvocation('Emulation.setCPUThrottlingRate')).toThrow();
   });
 
   it('applies the correct emulation given a particular emulationFormFactor', async () => {
-    const emulationFn = jest.fn();
-    const driver = getMockedEmulationDriver(
-      emulationFn,
-      () => true,
-      () => true
-    );
-
+    /** @param {'mobile'|'desktop'|'none'} formFactor */
     const getSettings = formFactor => ({
       emulatedFormFactor: formFactor,
       internalDisableDeviceScreenEmulation: false,
     });
 
     await GatherRunner.setupDriver(driver, {settings: getSettings('mobile')});
-    expect(emulationFn.mock.calls.slice(-1)[0][0]).toMatchObject({mobile: true});
+    expect(connectionStub.sendCommand.findInvocation('Emulation.setDeviceMetricsOverride'))
+      .toMatchObject({mobile: true});
 
+    resetDefaultMockResponses();
     await GatherRunner.setupDriver(driver, {settings: getSettings('desktop')});
-    expect(emulationFn.mock.calls.slice(-1)[0][0]).toMatchObject({mobile: false});
+    expect(connectionStub.sendCommand.findInvocation('Emulation.setDeviceMetricsOverride'))
+      .toMatchObject({mobile: false});
 
-    emulationFn.mockClear();
+    resetDefaultMockResponses();
     await GatherRunner.setupDriver(driver, {settings: getSettings('none')});
-    expect(emulationFn).not.toHaveBeenCalled();
+    expect(() =>
+      connectionStub.sendCommand.findInvocation('Emulation.setDeviceMetricsOverride')).toThrow();
   });
 
-  it('stops throttling when not devtools', () => {
-    const tests = {
-      calledDeviceEmulation: false,
-      calledNetworkEmulation: false,
-      calledCpuEmulation: false,
-    };
-    const createEmulationCheck = variable => (...args) => {
-      tests[variable] = args;
-      return true;
-    };
-    const driver = getMockedEmulationDriver(
-      createEmulationCheck('calledDeviceEmulation'),
-      createEmulationCheck('calledNetworkEmulation'),
-      createEmulationCheck('calledCpuEmulation')
-    );
-
-    return GatherRunner.setupDriver(driver, {
-      settings: {
-        emulatedFormFactor: 'mobile',
-        throttlingMethod: 'provided',
-        internalDisableDeviceScreenEmulation: false,
-      },
-    }).then(_ => {
-      assert.ok(tests.calledDeviceEmulation, 'did not call device emulation');
-      assert.deepEqual(tests.calledNetworkEmulation, [{
-        latency: 0, downloadThroughput: 0, uploadThroughput: 0, offline: false,
-      }]);
-      assert.ok(!tests.calledCpuEmulation, 'called CPU emulation');
-    });
-  });
-
-  it('sets throttling according to settings', () => {
-    const tests = {
-      calledDeviceEmulation: false,
-      calledNetworkEmulation: false,
-      calledCpuEmulation: false,
-    };
-    const createEmulationCheck = variable => (...args) => {
-      tests[variable] = args;
-
-      return true;
-    };
-    const driver = getMockedEmulationDriver(
-      createEmulationCheck('calledDeviceEmulation'),
-      createEmulationCheck('calledNetworkEmulation'),
-      createEmulationCheck('calledCpuEmulation')
-    );
-
-    return GatherRunner.setupDriver(driver, {
+  it('sets throttling according to settings', async () => {
+    await GatherRunner.setupDriver(driver, {
       settings: {
         emulatedFormFactor: 'mobile', internalDisableDeviceScreenEmulation: false,
         throttlingMethod: 'devtools',
@@ -381,21 +367,25 @@ describe('GatherRunner', function() {
           cpuSlowdownMultiplier: 1,
         },
       },
-    }).then(_ => {
-      assert.ok(tests.calledDeviceEmulation, 'did not call device emulation');
-      assert.deepEqual(tests.calledNetworkEmulation, [{
-        latency: 100, downloadThroughput: 1024, uploadThroughput: 1024, offline: false,
-      }]);
-      assert.deepEqual(tests.calledCpuEmulation, [{rate: 1}]);
+    });
+
+    connectionStub.sendCommand.findInvocation('Emulation.setDeviceMetricsOverride');
+    expect(connectionStub.sendCommand.findInvocation('Network.emulateNetworkConditions')).toEqual({
+      latency: 100, downloadThroughput: 1024, uploadThroughput: 1024, offline: false,
+    });
+    expect(connectionStub.sendCommand.findInvocation('Emulation.setCPUThrottlingRate')).toEqual({
+      rate: 1,
     });
   });
 
   it('clears origin storage', () => {
     const asyncFunc = () => Promise.resolve();
+    /** @type {Record<string, boolean>} */
     const tests = {
       calledCleanBrowserCaches: false,
       calledClearStorage: false,
     };
+    /** @param {string} variable */
     const createCheck = variable => () => {
       tests[variable] = true;
       return Promise.resolve();
@@ -424,9 +414,11 @@ describe('GatherRunner', function() {
 
   it('clears the disk & memory cache on a perf run', async () => {
     const asyncFunc = () => Promise.resolve();
+    /** @type {Record<string, boolean>} */
     const tests = {
       calledCleanBrowserCaches: false,
     };
+    /** @param {string} variable */
     const createCheck = variable => () => {
       tests[variable] = true;
       return Promise.resolve();
@@ -447,7 +439,7 @@ describe('GatherRunner', function() {
     };
     const passConfig = {
       passName: 'default',
-      loadFailureMode: 'ignore',
+      loadFailureMode: LoadFailureMode.ignore,
       recordTrace: true,
       useThrottling: true,
       gatherers: [],
@@ -474,13 +466,14 @@ describe('GatherRunner', function() {
     const navigationError = new LHError(LHError.errors.NO_FCP);
     const driver = Object.assign({}, fakeDriver, {
       online: true,
+      /** @param {string} url */
       gotoURL: url => url.includes('blank') ? null : Promise.reject(navigationError),
       endDevtoolsLog() {
         return networkRecordsToDevtoolsLog([{url: requestedUrl, statusCode: 500}]);
       },
     });
 
-    const config = new Config({
+    const config = makeConfig({
       passes: [{
         recordTrace: true,
         passName: 'firstPass',
@@ -496,7 +489,8 @@ describe('GatherRunner', function() {
     const artifacts = await GatherRunner.run(config.passes, options);
     expect(artifacts.LighthouseRunWarnings).toHaveLength(1);
     expect(artifacts.PageLoadError).toBeInstanceOf(Error);
-    expect(artifacts.PageLoadError.code).toEqual('ERRORED_DOCUMENT_REQUEST');
+    expect(artifacts.PageLoadError).toMatchObject({code: 'ERRORED_DOCUMENT_REQUEST'});
+    // @ts-ignore: Test-only gatherer.
     expect(artifacts.TestGatherer).toBeUndefined();
   });
 
@@ -506,13 +500,14 @@ describe('GatherRunner', function() {
     const navigationError = new LHError(LHError.errors.NO_FCP);
     const driver = Object.assign({}, fakeDriver, {
       online: true,
+      /** @param {string} url */
       gotoURL: url => url.includes('blank') ? null : Promise.reject(navigationError),
       endDevtoolsLog() {
         return networkRecordsToDevtoolsLog([{url: requestedUrl}]);
       },
     });
 
-    const config = new Config({
+    const config = makeConfig({
       passes: [{
         recordTrace: true,
         passName: 'firstPass',
@@ -528,7 +523,8 @@ describe('GatherRunner', function() {
     const artifacts = await GatherRunner.run(config.passes, options);
     expect(artifacts.LighthouseRunWarnings).toHaveLength(1);
     expect(artifacts.PageLoadError).toBeInstanceOf(Error);
-    expect(artifacts.PageLoadError.code).toEqual('NO_FCP');
+    expect(artifacts.PageLoadError).toMatchObject({code: 'NO_FCP'});
+    // @ts-ignore: Test-only gatherer.
     expect(artifacts.TestGatherer).toBeUndefined();
   });
 
@@ -539,17 +535,18 @@ describe('GatherRunner', function() {
 
     const gotoUrlForAboutBlank = jest.fn().mockResolvedValue(null);
     const gotoUrlForRealUrl = jest.fn()
-      .mockResolvedValueOnce(null)
+      .mockResolvedValueOnce(requestedUrl)
       .mockRejectedValueOnce(navigationError);
     const driver = Object.assign({}, fakeDriver, {
       online: true,
+      /** @param {string} url */
       gotoURL: url => url.includes('blank') ? gotoUrlForAboutBlank() : gotoUrlForRealUrl(),
       endDevtoolsLog() {
         return networkRecordsToDevtoolsLog([{url: requestedUrl}]);
       },
     });
 
-    const config = new Config({
+    const config = makeConfig({
       passes: [{passName: 'defaultPass', recordTrace: true}, {
         loadFailureMode: 'warn',
         recordTrace: true,
@@ -566,16 +563,19 @@ describe('GatherRunner', function() {
     const artifacts = await GatherRunner.run(config.passes, options);
     expect(artifacts.LighthouseRunWarnings).toHaveLength(1);
     expect(artifacts.PageLoadError).toEqual(null);
+    // @ts-ignore: Test-only gatherer.
     expect(artifacts.TestGatherer).toBeUndefined();
     expect(artifacts.devtoolsLogs).toHaveProperty('pageLoadError-nextPass');
   });
 
   it('does not clear origin storage with flag --disable-storage-reset', () => {
     const asyncFunc = () => Promise.resolve();
+    /** @type {Record<string, boolean>} */
     const tests = {
       calledCleanBrowserCaches: false,
       calledClearStorage: false,
     };
+    /** @param {string} variable */
     const createCheck = variable => () => {
       tests[variable] = true;
       return Promise.resolve();
@@ -604,13 +604,8 @@ describe('GatherRunner', function() {
     });
   });
 
-  it('tells the driver to block given URL patterns when blockedUrlPatterns is given', () => {
-    let receivedUrlPatterns = null;
-    const driver = getMockedEmulationDriver(null, null, null, params => {
-      receivedUrlPatterns = params.urls;
-    });
-
-    return GatherRunner.setupPassNetwork({
+  it('tells the driver to block given URL patterns when blockedUrlPatterns is given', async () => {
+    await GatherRunner.setupPassNetwork({
       driver,
       settings: {
         blockedUrlPatterns: ['http://*.evil.com', '.jpg', '.woff2'],
@@ -619,46 +614,41 @@ describe('GatherRunner', function() {
         blockedUrlPatterns: ['*.jpeg'],
         gatherers: [],
       },
-    }).then(() => assert.deepStrictEqual(
-      receivedUrlPatterns.sort(),
-      ['*.jpeg', '.jpg', '.woff2', 'http://*.evil.com']
-    ));
-  });
-
-  it('does not throw when blockedUrlPatterns is not given', () => {
-    let receivedUrlPatterns = null;
-    const driver = getMockedEmulationDriver(null, null, null, params => {
-      receivedUrlPatterns = params.urls;
     });
 
-    return GatherRunner.setupPassNetwork({
+    const blockedUrlsResult = connectionStub.sendCommand.findInvocation('Network.setBlockedURLs');
+    blockedUrlsResult.urls.sort();
+    expect(blockedUrlsResult)
+      .toEqual({urls: ['*.jpeg', '.jpg', '.woff2', 'http://*.evil.com']});
+  });
+
+  it('does not throw when blockedUrlPatterns is not given', async () => {
+    await GatherRunner.setupPassNetwork({
       driver,
       settings: {},
       passConfig: {gatherers: []},
-    }).then(() => assert.deepStrictEqual(receivedUrlPatterns, []));
-  });
+    });
 
+    expect(connectionStub.sendCommand.findInvocation('Network.setBlockedURLs'))
+      .toEqual({urls: []});
+  });
 
-  it('tells the driver to set additional http headers when extraHeaders flag is given', () => {
-    let receivedHeaders = null;
-    const driver = getMockedEmulationDriver(null, null, null, null, params => {
-      receivedHeaders = params.headers;
-    });
-    const headers = {
+  it('tells the driver to set additional http when extraHeaders flag is given', async () => {
+    const extraHeaders = {
       'Cookie': 'monster',
       'x-men': 'wolverine',
     };
 
-    return GatherRunner.setupPassNetwork({
+    await GatherRunner.setupPassNetwork({
       driver,
       settings: {
-        extraHeaders: headers,
+        extraHeaders,
       },
       passConfig: {gatherers: []},
-    }).then(() => assert.deepStrictEqual(
-        receivedHeaders,
-        headers
-      ));
+    });
+
+    expect(connectionStub.sendCommand.findInvocation('Network.setExtraHTTPHeaders'))
+      .toEqual({headers: extraHeaders});
   });
 
   it('tells the driver to begin tracing', async () => {
@@ -761,6 +751,7 @@ describe('GatherRunner', function() {
 
   it('resets scroll position between every gatherer', async () => {
     class ScrollMcScrollyGatherer extends TestGatherer {
+      /** @param {{driver: Driver}} context */
       afterPass(context) {
         context.driver.scrollTo({x: 1000, y: 1000});
       }
@@ -778,7 +769,11 @@ describe('GatherRunner', function() {
       ],
     };
 
-    await GatherRunner.afterPass({url, driver, passConfig}, {}, {TestGatherer: []});
+    /** @type {any} Using Test-only gatherer. */
+    const gathererResults = {
+      TestGatherer: [],
+    };
+    await GatherRunner.afterPass({url, driver, passConfig}, {}, gathererResults);
     // One time for the afterPass of ScrollMcScrolly, two times for the resets of the two gatherers.
     expect(scrollToSpy.mock.calls).toEqual([
       [{x: 1000, y: 1000}],
@@ -791,7 +786,7 @@ describe('GatherRunner', function() {
     const t1 = new TestGatherer();
     const t2 = new TestGatherer();
 
-    const config = new Config({
+    const config = makeConfig({
       passes: [{
         recordTrace: true,
         passName: 'firstPass',
@@ -817,7 +812,7 @@ describe('GatherRunner', function() {
   });
 
   it('respects trace names', () => {
-    const config = new Config({
+    const config = makeConfig({
       passes: [{
         recordTrace: true,
         passName: 'firstPass',
@@ -843,40 +838,16 @@ describe('GatherRunner', function() {
       });
   });
 
-  it('doesn\'t leave networkRecords as an artifact', () => {
-    const config = new Config({
-      passes: [{
-        recordTrace: true,
-        passName: 'firstPass',
-        gatherers: [{instance: new TestGatherer()}],
-      }, {
-        recordTrace: true,
-        passName: 'secondPass',
-        gatherers: [{instance: new TestGatherer()}],
-      }],
-    });
-    const options = {
-      driver: fakeDriver,
-      requestedUrl: 'https://example.com',
-      settings: config.settings,
-    };
-
-    return GatherRunner.run(config.passes, options)
-      .then(artifacts => {
-        assert.equal(artifacts.networkRecords, undefined);
-      });
-  });
-
   it('saves trace and devtoolsLog with error prefix when there was a runtime error', async () => {
     const requestedUrl = 'https://example.com';
     const driver = Object.assign({}, fakeDriver, {
-      // resolved URL here does not match any request in the network records, causing a runtime error.
+      /** @param {string} _ Resolved URL here does not match any request in the network records, causing a runtime error. */
       gotoURL: async _ => requestedUrl,
       online: true,
       endDevtoolsLog: () => [],
     });
 
-    const config = new Config({
+    const config = makeConfig({
       passes: [{
         passName: 'firstPass',
         recordTrace: true,
@@ -886,7 +857,8 @@ describe('GatherRunner', function() {
     const options = {driver, requestedUrl, settings: config.settings};
     const artifacts = await GatherRunner.run(config.passes, options);
 
-    expect(artifacts.PageLoadError.code).toEqual('NO_DOCUMENT_REQUEST');
+    expect(artifacts.PageLoadError).toMatchObject({code: 'NO_DOCUMENT_REQUEST'});
+    // @ts-ignore: Test-only gatherer.
     expect(artifacts.TestGatherer).toBeUndefined();
 
     // The only loadData available should be prefixed with `pageLoadError-`.
@@ -898,7 +870,7 @@ describe('GatherRunner', function() {
     const t1 = new (class Test1 extends TestGatherer {})();
     const t2 = new (class Test2 extends TestGatherer {})();
     const t3 = new (class Test3 extends TestGatherer {})();
-    const config = new Config({
+    const config = makeConfig({
       passes: [{
         passName: 'firstPass',
         recordTrace: true,
@@ -917,7 +889,7 @@ describe('GatherRunner', function() {
     const requestedUrl = 'https://www.reddit.com/r/nba';
     let firstLoad = true;
     const driver = Object.assign({}, fakeDriver, {
-      // Loads the page successfully in the first pass, fails with NO_FCP in the second.
+      /** @param {string} url Loads the page successfully in the first pass, fails with NO_FCP in the second. */
       async gotoURL(url) {
         if (url.includes('blank')) return null;
         if (firstLoad) {
@@ -938,13 +910,16 @@ describe('GatherRunner', function() {
     expect(t3.called).toBe(false);
 
     // But only t1 has a valid artifact; t2 and t3 aren't defined.
+    // @ts-ignore: Test-only gatherer.
     expect(artifacts.Test1).toBe('MyArtifact');
+    // @ts-ignore: Test-only gatherer.
     expect(artifacts.Test2).toBeUndefined();
+    // @ts-ignore: Test-only gatherer.
     expect(artifacts.Test3).toBeUndefined();
 
     // PageLoadError artifact has the error.
     expect(artifacts.PageLoadError).toBeInstanceOf(LHError);
-    expect(artifacts.PageLoadError.code).toEqual('NO_FCP');
+    expect(artifacts.PageLoadError).toMatchObject({code: 'NO_FCP'});
 
     // firstPass has a saved trace and devtoolsLog, secondPass has an error trace and log.
     expect(Object.keys(artifacts.traces)).toEqual(['firstPass', 'pageLoadError-secondPass']);
@@ -952,6 +927,15 @@ describe('GatherRunner', function() {
   });
 
   describe('#getNetworkError', () => {
+    /**
+     * @param {NetworkRequest=} mainRecord
+     */
+    function getAndExpectError(mainRecord) {
+      const error = GatherRunner.getNetworkError(mainRecord);
+      if (!error) throw new Error('expected a network error');
+      return error;
+    }
+
     it('passes when the page is loaded', () => {
       const url = 'http://the-page.com';
       const mainRecord = new NetworkRequest();
@@ -965,7 +949,7 @@ describe('GatherRunner', function() {
       mainRecord.url = url;
       mainRecord.failed = true;
       mainRecord.localizedFailDescription = 'foobar';
-      const error = GatherRunner.getNetworkError(mainRecord);
+      const error = getAndExpectError(mainRecord);
       assert.equal(error.message, 'FAILED_DOCUMENT_REQUEST');
       assert.equal(error.code, 'FAILED_DOCUMENT_REQUEST');
       expect(error.friendlyMessage)
@@ -973,7 +957,7 @@ describe('GatherRunner', function() {
     });
 
     it('fails when page times out', () => {
-      const error = GatherRunner.getNetworkError(undefined);
+      const error = getAndExpectError(undefined);
       assert.equal(error.message, 'NO_DOCUMENT_REQUEST');
       assert.equal(error.code, 'NO_DOCUMENT_REQUEST');
       expect(error.friendlyMessage).toBeDisplayString(/^Lighthouse was unable to reliably load/);
@@ -984,7 +968,7 @@ describe('GatherRunner', function() {
       const mainRecord = new NetworkRequest();
       mainRecord.url = url;
       mainRecord.statusCode = 404;
-      const error = GatherRunner.getNetworkError(mainRecord);
+      const error = getAndExpectError(mainRecord);
       assert.equal(error.message, 'ERRORED_DOCUMENT_REQUEST');
       assert.equal(error.code, 'ERRORED_DOCUMENT_REQUEST');
       expect(error.friendlyMessage)
@@ -996,7 +980,7 @@ describe('GatherRunner', function() {
       const mainRecord = new NetworkRequest();
       mainRecord.url = url;
       mainRecord.statusCode = 500;
-      const error = GatherRunner.getNetworkError(mainRecord);
+      const error = getAndExpectError(mainRecord);
       assert.equal(error.message, 'ERRORED_DOCUMENT_REQUEST');
       assert.equal(error.code, 'ERRORED_DOCUMENT_REQUEST');
       expect(error.friendlyMessage)
@@ -1009,7 +993,7 @@ describe('GatherRunner', function() {
       mainRecord.url = url;
       mainRecord.failed = true;
       mainRecord.localizedFailDescription = 'net::ERR_NAME_NOT_RESOLVED';
-      const error = GatherRunner.getNetworkError(mainRecord);
+      const error = getAndExpectError(mainRecord);
       assert.equal(error.message, 'DNS_FAILURE');
       assert.equal(error.code, 'DNS_FAILURE');
       expect(error.friendlyMessage).toBeDisplayString(/^DNS servers could not resolve/);
@@ -1017,6 +1001,16 @@ describe('GatherRunner', function() {
   });
 
   describe('#getInterstitialError', () => {
+    /**
+     * @param {NetworkRequest} mainRecord
+     * @param {NetworkRequest[]} networkRecords
+     */
+    function getAndExpectError(mainRecord, networkRecords) {
+      const error = GatherRunner.getInterstitialError(mainRecord, networkRecords);
+      if (!error) throw new Error('expected an interstitial error');
+      return error;
+    }
+
     it('passes when the page was not requested', () => {
       expect(GatherRunner.getInterstitialError(undefined, [])).toBeUndefined();
     });
@@ -1059,7 +1053,7 @@ describe('GatherRunner', function() {
       interstitialRecord.url = 'data:text/html;base64,abcdef';
       interstitialRecord.documentURL = 'chrome-error://chromewebdata/';
       const records = [mainRecord, interstitialRecord];
-      const error = GatherRunner.getInterstitialError(mainRecord, records);
+      const error = getAndExpectError(mainRecord, records);
       expect(error.message).toEqual('CHROME_INTERSTITIAL_ERROR');
       expect(error.code).toEqual('CHROME_INTERSTITIAL_ERROR');
       expect(error.friendlyMessage).toBeDisplayString(/^Chrome prevented/);
@@ -1075,7 +1069,7 @@ describe('GatherRunner', function() {
       interstitialRecord.url = 'data:text/html;base64,abcdef';
       interstitialRecord.documentURL = 'chrome-error://chromewebdata/';
       const records = [mainRecord, interstitialRecord];
-      const error = GatherRunner.getInterstitialError(mainRecord, records);
+      const error = getAndExpectError(mainRecord, records);
       expect(error.message).toEqual('INSECURE_DOCUMENT_REQUEST');
       expect(error.code).toEqual('INSECURE_DOCUMENT_REQUEST');
       expect(error.friendlyMessage).toBeDisplayString(/valid security certificate/);
@@ -1101,14 +1095,29 @@ describe('GatherRunner', function() {
   });
 
   describe('#getPageLoadError', () => {
+    /**
+     * @param {RecursivePartial<LH.Gatherer.PassContext>} passContext
+     * @param {RecursivePartial<LH.Gatherer.LoadData>} loadData
+     * @param {LH.LighthouseError|undefined} navigationError
+     */
+    function getAndExpectError(passContext, loadData, navigationError) {
+      const error = GatherRunner.getPageLoadError(passContext, loadData, navigationError);
+      if (!error) throw new Error('expected a page load error');
+      return error;
+    }
+
+    /** @type {LH.LighthouseError} */
     let navigationError;
 
     beforeEach(() => {
-      navigationError = new Error('NAVIGATION_ERROR');
+      navigationError = /** @type {LH.LighthouseError} */ (new Error('NAVIGATION_ERROR'));
     });
 
     it('passes when the page is loaded', () => {
-      const passContext = {url: 'http://the-page.com', passConfig: {loadFailureMode: 'fatal'}};
+      const passContext = {
+        url: 'http://the-page.com',
+        passConfig: {loadFailureMode: LoadFailureMode.fatal},
+      };
       const mainRecord = new NetworkRequest();
       const loadData = {networkRecords: [mainRecord]};
       mainRecord.url = passContext.url;
@@ -1117,9 +1126,11 @@ describe('GatherRunner', function() {
     });
 
     it('passes when the page is loaded, ignoring any fragment', () => {
-      const url = 'http://example.com/#/page/list';
+      const passContext = {
+        url: 'http://example.com/#/page/list',
+        passConfig: {loadFailureMode: LoadFailureMode.fatal},
+      };
       const mainRecord = new NetworkRequest();
-      const passContext = {url, passConfig: {loadFailureMode: 'fatal'}};
       const loadData = {networkRecords: [mainRecord]};
       mainRecord.url = 'http://example.com';
       const error = GatherRunner.getPageLoadError(passContext, loadData, undefined);
@@ -1127,7 +1138,10 @@ describe('GatherRunner', function() {
     });
 
     it('passes when the page is expected to fail', () => {
-      const passContext = {url: 'http://the-page.com', passConfig: {loadFailureMode: 'ignore'}};
+      const passContext = {
+        url: 'http://the-page.com',
+        passConfig: {loadFailureMode: LoadFailureMode.ignore},
+      };
       const mainRecord = new NetworkRequest();
       const loadData = {networkRecords: [mainRecord]};
       mainRecord.url = passContext.url;
@@ -1138,7 +1152,10 @@ describe('GatherRunner', function() {
     });
 
     it('fails with interstitial error first', () => {
-      const passContext = {url: 'http://the-page.com', passConfig: {loadFailureMode: 'fatal'}};
+      const passContext = {
+        url: 'http://the-page.com',
+        passConfig: {loadFailureMode: LoadFailureMode.fatal},
+      };
       const mainRecord = new NetworkRequest();
       const interstitialRecord = new NetworkRequest();
       const loadData = {networkRecords: [mainRecord, interstitialRecord]};
@@ -1148,41 +1165,50 @@ describe('GatherRunner', function() {
       interstitialRecord.url = 'data:text/html;base64,abcdef';
       interstitialRecord.documentURL = 'chrome-error://chromewebdata/';
 
-      const error = GatherRunner.getPageLoadError(passContext, loadData, navigationError);
+      const error = getAndExpectError(passContext, loadData, navigationError);
       expect(error.message).toEqual('CHROME_INTERSTITIAL_ERROR');
     });
 
     it('fails with network error next', () => {
-      const passContext = {url: 'http://the-page.com', passConfig: {loadFailureMode: 'fatal'}};
+      const passContext = {
+        url: 'http://the-page.com',
+        passConfig: {loadFailureMode: LoadFailureMode.fatal},
+      };
       const mainRecord = new NetworkRequest();
       const loadData = {networkRecords: [mainRecord]};
 
       mainRecord.url = passContext.url;
       mainRecord.failed = true;
 
-      const error = GatherRunner.getPageLoadError(passContext, loadData, navigationError);
+      const error = getAndExpectError(passContext, loadData, navigationError);
       expect(error.message).toEqual('FAILED_DOCUMENT_REQUEST');
     });
 
     it('fails with nav error last', () => {
-      const passContext = {url: 'http://the-page.com', passConfig: {loadFailureMode: 'fatal'}};
+      const passContext = {
+        url: 'http://the-page.com',
+        passConfig: {loadFailureMode: LoadFailureMode.fatal},
+      };
       const mainRecord = new NetworkRequest();
       const loadData = {networkRecords: [mainRecord]};
 
       mainRecord.url = passContext.url;
 
-      const error = GatherRunner.getPageLoadError(passContext, loadData, navigationError);
+      const error = getAndExpectError(passContext, loadData, navigationError);
       expect(error.message).toEqual('NAVIGATION_ERROR');
     });
 
     it('fails when loadFailureMode is warn', () => {
-      const passContext = {url: 'http://the-page.com', passConfig: {loadFailureMode: 'warn'}};
+      const passContext = {
+        url: 'http://the-page.com',
+        passConfig: {loadFailureMode: LoadFailureMode.warn},
+      };
       const mainRecord = new NetworkRequest();
       const loadData = {networkRecords: [mainRecord]};
 
       mainRecord.url = passContext.url;
 
-      const error = GatherRunner.getPageLoadError(passContext, loadData, navigationError);
+      const error = getAndExpectError(passContext, loadData, navigationError);
       expect(error.message).toEqual('NAVIGATION_ERROR');
     });
   });
@@ -1190,23 +1216,36 @@ describe('GatherRunner', function() {
   describe('artifact collection', () => {
     // Make sure our gatherers never execute in parallel
     it('runs gatherer lifecycle methods strictly in sequence', async () => {
+      /** @type {Record<string, number>} */
       const counter = {
         beforePass: 0,
         pass: 0,
         afterPass: 0,
       };
       const shortPause = () => new Promise(resolve => setTimeout(resolve, 50));
+      /**
+       * @param {string} counterName
+       * @param {number} value
+       */
       async function fastish(counterName, value) {
         assert.strictEqual(counter[counterName], value - 1);
         counter[counterName] = value;
         await shortPause();
         assert.strictEqual(counter[counterName], value);
       }
+      /**
+       * @param {string} counterName
+       * @param {number} value
+       */
       async function medium(counterName, value) {
         await Promise.resolve();
         await Promise.resolve();
         await fastish(counterName, value);
       }
+      /**
+       * @param {string} counterName
+       * @param {number} value
+       */
       async function slowwwww(counterName, value) {
         await shortPause();
         await shortPause();
@@ -1251,12 +1290,13 @@ describe('GatherRunner', function() {
           }
         },
       ];
-      const config = new Config({
+      const config = makeConfig({
         passes: [{
           gatherers: gatherers.map(G => ({instance: new G()})),
         }],
       });
 
+      /** @type {any} Using Test-only gatherers. */
       const artifacts = await GatherRunner.run(config.passes, {
         driver: fakeDriver,
         requestedUrl: 'https://example.com',
@@ -1306,7 +1346,7 @@ describe('GatherRunner', function() {
         }(),
       ].map(instance => ({instance}));
       const gathererNames = gatherers.map(gatherer => gatherer.instance.name);
-      const config = new Config({
+      const config = makeConfig({
         passes: [{
           gatherers,
         }],
@@ -1323,55 +1363,67 @@ describe('GatherRunner', function() {
       });
     });
 
-    it('passes gatherer options', () => {
+    it('passes gatherer options', async () => {
+      /** @type {Record<string, any[]>} */
       const calls = {beforePass: [], pass: [], afterPass: []};
-      class EavesdropGatherer extends Gatherer {
-        beforePass(context) {
-          calls.beforePass.push(context.options);
-        }
-        pass(context) {
-          calls.pass.push(context.options);
-        }
-        afterPass(context) {
-          calls.afterPass.push(context.options);
-          return context.options.x || 'none';
-        }
-      }
+      /** @param {string} name */
+      const makeEavesdropGatherer = name => {
+        const C = class extends Gatherer {};
+        Object.defineProperty(C, 'name', {value: name});
+        return Object.assign(new C, {
+          /** @param {LH.Gatherer.PassContext} context */
+          beforePass(context) {
+            calls.beforePass.push(context.options);
+          },
+          /** @param {LH.Gatherer.PassContext} context */
+          pass(context) {
+            calls.pass.push(context.options);
+          },
+          /** @param {LH.Gatherer.PassContext} context */
+          afterPass(context) {
+            calls.afterPass.push(context.options);
+            // @ts-ignore
+            return context.options.x || 'none';
+          },
+        });
+      };
 
       const gatherers = [
-        {instance: new class EavesdropGatherer1 extends EavesdropGatherer {}(), options: {x: 1}},
-        {instance: new class EavesdropGatherer2 extends EavesdropGatherer {}(), options: {x: 2}},
-        {instance: new class EavesdropGatherer3 extends EavesdropGatherer {}()},
+        {instance: makeEavesdropGatherer('EavesdropGatherer1'), options: {x: 1}},
+        {instance: makeEavesdropGatherer('EavesdropGatherer2'), options: {x: 2}},
+        {instance: makeEavesdropGatherer('EavesdropGatherer3')},
       ];
 
-      const config = new Config({
+      const config = makeConfig({
         passes: [{gatherers}],
       });
 
-      return GatherRunner.run(config.passes, {
+      /** @type {any} Using Test-only gatherers. */
+      const artifacts = await GatherRunner.run(config.passes, {
         driver: fakeDriver,
         requestedUrl: 'https://example.com',
         settings: config.settings,
-      }).then(artifacts => {
-        assert.equal(artifacts.EavesdropGatherer1, 1);
-        assert.equal(artifacts.EavesdropGatherer2, 2);
-        assert.equal(artifacts.EavesdropGatherer3, 'none');
-
-        // assert that all three phases received the gatherer options expected
-        const expectedOptions = [{x: 1}, {x: 2}, {}];
-        for (let i = 0; i < 3; i++) {
-          assert.deepEqual(calls.beforePass[i], expectedOptions[i]);
-          assert.deepEqual(calls.pass[i], expectedOptions[i]);
-          assert.deepEqual(calls.afterPass[i], expectedOptions[i]);
-        }
       });
+
+      assert.equal(artifacts.EavesdropGatherer1, 1);
+      assert.equal(artifacts.EavesdropGatherer2, 2);
+      assert.equal(artifacts.EavesdropGatherer3, 'none');
+
+      // assert that all three phases received the gatherer options expected
+      const expectedOptions = [{x: 1}, {x: 2}, {}];
+      for (let i = 0; i < 3; i++) {
+        assert.deepEqual(calls.beforePass[i], expectedOptions[i]);
+        assert.deepEqual(calls.pass[i], expectedOptions[i]);
+        assert.deepEqual(calls.afterPass[i], expectedOptions[i]);
+      }
     });
 
-    it('uses the last not-undefined phase result as artifact', () => {
+    it('uses the last not-undefined phase result as artifact', async () => {
       const recoverableError = new Error('My recoverable error');
       const someOtherError = new Error('Bad, bad error.');
 
       // Gatherer results are all expected to be arrays of promises
+      /** @type {any} Using Test-only gatherers. */
       const gathererResults = {
         // 97 wins.
         AfterGatherer: [
@@ -1402,12 +1454,12 @@ describe('GatherRunner', function() {
         ],
       };
 
-      return GatherRunner.collectArtifacts(gathererResults).then(({artifacts}) => {
-        assert.strictEqual(artifacts.AfterGatherer, 97);
-        assert.strictEqual(artifacts.PassGatherer, 284);
-        assert.strictEqual(artifacts.SingleErrorGatherer, recoverableError);
-        assert.strictEqual(artifacts.TwoErrorGatherer, recoverableError);
-      });
+      /** @type {any} Using Test-only gatherers. */
+      const {artifacts} = await GatherRunner.collectArtifacts(gathererResults);
+      assert.strictEqual(artifacts.AfterGatherer, 97);
+      assert.strictEqual(artifacts.PassGatherer, 284);
+      assert.strictEqual(artifacts.SingleErrorGatherer, recoverableError);
+      assert.strictEqual(artifacts.TwoErrorGatherer, recoverableError);
     });
 
     it('produces a deduped LighthouseRunWarnings artifact from array of warnings', async () => {
@@ -1418,6 +1470,7 @@ describe('GatherRunner', function() {
       ];
 
       class WarningGatherer extends Gatherer {
+        /** @param {LH.Gatherer.PassContext} passContext */
         afterPass(passContext) {
           passContext.LighthouseRunWarnings.push(...runWarnings, ...runWarnings);
           assert.strictEqual(passContext.LighthouseRunWarnings.length, runWarnings.length * 2);
@@ -1426,7 +1479,7 @@ describe('GatherRunner', function() {
         }
       }
 
-      const config = new Config({
+      const config = makeConfig({
         passes: [{
           gatherers: [{instance: new WarningGatherer()}],
         }],
@@ -1479,7 +1532,7 @@ describe('GatherRunner', function() {
         }(),
       ].map(instance => ({instance}));
       const gathererNames = gatherers.map(gatherer => gatherer.instance.name);
-      const config = new Config({
+      const config = makeConfig({
         passes: [{
           gatherers,
         }],
@@ -1493,13 +1546,13 @@ describe('GatherRunner', function() {
         gathererNames.forEach(gathererName => {
           const errorArtifact = artifacts[gathererName];
           assert.ok(errorArtifact instanceof Error);
-          assert.strictEqual(errorArtifact.message, gathererName);
+          expect(errorArtifact).toMatchObject({message: gathererName});
         });
       });
     });
 
     it('rejects if a gatherer does not provide an artifact', () => {
-      const config = new Config({
+      const config = makeConfig({
         passes: [{
           recordTrace: true,
           passName: 'firstPass',
@@ -1517,7 +1570,7 @@ describe('GatherRunner', function() {
     });
 
     it('rejects when domain name can\'t be resolved', () => {
-      const config = new Config({
+      const config = makeConfig({
         passes: [{
           recordTrace: true,
           passName: 'firstPass',
@@ -1549,7 +1602,7 @@ describe('GatherRunner', function() {
     });
 
     it('resolves when domain name can\'t be resolved but is offline', () => {
-      const config = new Config({
+      const config = makeConfig({
         passes: [{
           recordTrace: true,
           passName: 'firstPass',
@@ -1582,6 +1635,7 @@ describe('GatherRunner', function() {
 
   describe('.getWebAppManifest', () => {
     const MANIFEST_URL = 'https://example.com/manifest.json';
+    /** @type {RecursivePartial<LH.Gatherer.PassContext>} */
     let passContext;
 
     beforeEach(() => {
@@ -1602,10 +1656,11 @@ describe('GatherRunner', function() {
     it('should parse the manifest when found', async () => {
       const manifest = {name: 'App'};
       const getAppManifest = jest.spyOn(fakeDriver, 'getAppManifest');
+      // @ts-ignore: Some terrible @types/jest bug lies here.
       getAppManifest.mockResolvedValueOnce({data: JSON.stringify(manifest), url: MANIFEST_URL});
       const result = await GatherRunner.getWebAppManifest(passContext);
       expect(result).toHaveProperty('raw', JSON.stringify(manifest));
-      expect(result.value).toMatchObject({
+      expect(result && result.value).toMatchObject({
         name: {value: 'App', raw: 'App'},
         start_url: {value: passContext.url, raw: undefined},
       });
diff --git a/lighthouse-core/test/gather/gatherers/seo/font-size-test.js b/lighthouse-core/test/gather/gatherers/seo/font-size-test.js
index 3b023ebc3..55c8ec400 100644
--- a/lighthouse-core/test/gather/gatherers/seo/font-size-test.js
+++ b/lighthouse-core/test/gather/gatherers/seo/font-size-test.js
@@ -10,50 +10,85 @@
 const FontSizeGather = require('../../../../gather/gatherers/seo/font-size.js');
 let fontSizeGather;
 
+const TEXT_NODE_TYPE = 3;
 const smallText = ' body sm𝐀ll text ';
 const bigText = 'body 𝐁ig text';
-const failingText = 'failing text 💩';
-const bodyNode = {nodeId: 3, nodeName: 'BODY', parentId: 1};
-const failingNode = {nodeId: 10, nodeName: 'P', parentId: 3};
+const bodyNode = {nodeId: 3, backendNodeId: 102, nodeName: 'BODY', parentId: 1, fontSize: '10px'};
 const nodes = [
-  {nodeId: 1, nodeName: 'HTML'},
-  {nodeId: 2, nodeName: 'HEAD', parentId: 1},
+  {nodeId: 1, backendNodeId: 100, nodeName: 'HTML'},
+  {nodeId: 2, backendNodeId: 101, nodeName: 'HEAD', parentId: 1},
   bodyNode,
   {
     nodeId: 4,
+    backendNodeId: 103,
     nodeValue: 'head text',
-    nodeType: FontSizeGather.TEXT_NODE_TYPE,
+    nodeType: TEXT_NODE_TYPE,
     parentId: 2,
   },
   {
     nodeId: 5,
+    backendNodeId: 104,
     nodeValue: smallText,
-    nodeType: FontSizeGather.TEXT_NODE_TYPE,
+    nodeType: TEXT_NODE_TYPE,
     parentId: 3,
   },
-  {nodeId: 6, nodeName: 'H1', parentId: 3},
+  {nodeId: 6, backendNodeId: 105, nodeName: 'H1', parentId: 3},
   {
     nodeId: 7,
+    backendNodeId: 106,
     nodeValue: bigText,
-    nodeType: FontSizeGather.TEXT_NODE_TYPE,
+    nodeType: TEXT_NODE_TYPE,
     parentId: 6,
   },
-  {nodeId: 8, nodeName: 'SCRIPT', parentId: 3},
+  {nodeId: 8, backendNodeId: 107, nodeName: 'SCRIPT', parentId: 3},
   {
     nodeId: 9,
+    backendNodeId: 108,
     nodeValue: 'script text',
-    nodeType: FontSizeGather.TEXT_NODE_TYPE,
+    nodeType: TEXT_NODE_TYPE,
     parentId: 8,
   },
-  failingNode,
-  {
-    nodeId: 11,
-    nodeValue: failingText,
-    nodeType: FontSizeGather.TEXT_NODE_TYPE,
-    parentId: 10,
-  },
 ];
 
+const stringsMap = {};
+const strings = [];
+const getOrCreateStringIndex = value => {
+  if (value in stringsMap) {
+    return stringsMap[value];
+  }
+
+  const index = strings.length;
+  stringsMap[value] = index;
+  strings.push(value);
+  return index;
+};
+const snapshot = {
+  documents: [
+    {
+      nodes: {
+        backendNodeId: nodes.map(node => node.backendNodeId),
+      },
+      layout: {
+        nodeIndex: nodes.map((_, i) => i),
+        styles: nodes.map(node => [
+          getOrCreateStringIndex(`${node.nodeValue === smallText ? 10 : 20}px`),
+        ]),
+        text: nodes.map(node => getOrCreateStringIndex(node.nodeValue)),
+      },
+      textBoxes: {
+        layoutIndex: nodes.map((_, i) => i).filter(i => {
+          const node = nodes[i];
+          if (node.nodeType !== TEXT_NODE_TYPE) return false;
+
+          const parentNode = nodes.find(n => n.nodeId === node.parentId);
+          return parentNode && parentNode.nodeName !== 'SCRIPT';
+        }),
+      },
+    },
+  ],
+  strings,
+};
+
 describe('Font size gatherer', () => {
   // Reset the Gatherer before each test.
   beforeEach(() => {
@@ -64,27 +99,16 @@ describe('Font size gatherer', () => {
     const driver = {
       on() {},
       off() {},
-      async sendCommand(command, params) {
-        if (command === 'CSS.getComputedStyleForNode') {
-          if (params.nodeId === failingNode.nodeId) {
-            throw new Error('This is the failing node');
-          }
-
-          return {
-            computedStyle: [
-              {
-                name: 'font-size',
-                value: params.nodeId === bodyNode.nodeId ? 10 : 20,
-              },
-            ],
-          };
-        } else if (command === 'CSS.getMatchedStylesForNode') {
+      async sendCommand(command) {
+        if (command === 'CSS.getMatchedStylesForNode') {
           return {
             inlineStyle: null,
             attributesStyle: null,
             matchedCSSRules: [],
             inherited: [],
           };
+        } else if (command === 'DOMSnapshot.captureSnapshot') {
+          return snapshot;
         }
       },
       async getNodesInDocument() {
@@ -94,15 +118,14 @@ describe('Font size gatherer', () => {
 
     const artifact = await fontSizeGather.afterPass({driver});
     const expectedFailingTextLength = Array.from(smallText.trim()).length;
-    const expectedVisitedTextLength = Array.from(bigText.trim()).length + expectedFailingTextLength;
-    const expectedTotalTextLength = Array.from(failingText.trim()).length +
-      expectedVisitedTextLength;
+    const expectedTotalTextLength =
+      Array.from(smallText.trim()).length +
+      Array.from(bigText.trim()).length;
     const expectedAnalyzedFailingTextLength = expectedFailingTextLength;
 
     expect(artifact).toEqual({
       analyzedFailingTextLength: expectedAnalyzedFailingTextLength,
       failingTextLength: expectedFailingTextLength,
-      visitedTextLength: expectedVisitedTextLength,
       totalTextLength: expectedTotalTextLength,
       analyzedFailingNodesData: [
         {
diff --git a/lighthouse-core/test/network-records-to-devtools-log.js b/lighthouse-core/test/network-records-to-devtools-log.js
index 50c380345..5e025135b 100644
--- a/lighthouse-core/test/network-records-to-devtools-log.js
+++ b/lighthouse-core/test/network-records-to-devtools-log.js
@@ -1,3 +1,4 @@
+// @ts-nocheck
 /**
  * @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
@@ -16,7 +17,7 @@ const redirectSuffix = ':redirect';
 
 /**
  * Extract requestId without any `:redirect` strings.
- * @param {Partial<NetworkRequest>} requestId
+ * @param {Partial<NetworkRequest>} record
  */
 function getBaseRequestId(record) {
   if (!record.requestId) return;
diff --git a/lighthouse-core/test/report/html/renderer/report-renderer-test.js b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
index 87db7bcab..fb0646c45 100644
--- a/lighthouse-core/test/report/html/renderer/report-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
@@ -287,6 +287,8 @@ describe('ReportRenderer', () => {
           // https://github.com/dequelabs/axe-core/tree/b573b1c1/doc/examples/jest_react#to-run-the-example
           'color-contrast': {enabled: false},
           'link-in-text-block': {enabled: false},
+          // Report has empty links prior to i18n-ing.
+          'link-name': {enabled: false},
         },
       };
 
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 a1e999f00..c29c2a6e6 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
@@ -412,5 +412,53 @@ describe('ReportUIFeatures', () => {
         assert.strictEqual(nextNode, nodes[2]);
       });
     });
+
+    describe('onMenuFocusOut', () => {
+      beforeEach(() => {
+        dropDown._toggleEl.click();
+        assert.ok(dropDown._toggleEl.classList.contains('active'));
+      });
+
+      it('should toggle active class when focus relatedTarget is null', () => {
+        const event = new window.FocusEvent('focusout', {relatedTarget: null});
+        dropDown.onMenuFocusOut(event);
+
+        assert.ok(!dropDown._toggleEl.classList.contains('active'));
+      });
+
+      it('should toggle active class when focus relatedTarget is document.body', () => {
+        const relatedTarget = dom.document().body;
+        const event = new window.FocusEvent('focusout', {relatedTarget});
+        dropDown.onMenuFocusOut(event);
+
+        assert.ok(!dropDown._toggleEl.classList.contains('active'));
+      });
+
+      it('should toggle active class when focus relatedTarget is _toggleEl', () => {
+        const relatedTarget = dropDown._toggleEl;
+        const event = new window.FocusEvent('focusout', {relatedTarget});
+        dropDown.onMenuFocusOut(event);
+
+        assert.ok(!dropDown._toggleEl.classList.contains('active'));
+      });
+
+      it('should not toggle active class when focus relatedTarget is a menu item', () => {
+        const relatedTarget = dropDown._getNextMenuItem();
+        const event = new window.FocusEvent('focusout', {relatedTarget});
+        dropDown.onMenuFocusOut(event);
+
+        assert.ok(dropDown._toggleEl.classList.contains('active'));
+      });
+    });
+  });
+
+  describe('data-i18n', () => {
+    it('should have only valid data-i18n values in template', () => {
+      const container = render(sampleResults);
+      for (const node of dom.findAll('[data-i18n]', container)) {
+        const val = node.getAttribute('data-i18n');
+        assert.ok(val in Util.UIStrings, `Invalid data-i18n value of: "${val}" found.`);
+      }
+    });
   });
 });
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index bafb171f0..1776fc5da 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -92,7 +92,7 @@
     "largest-contentful-paint": {
       "id": "largest-contentful-paint",
       "title": "Largest Contentful Paint",
-      "description": "Largest Contentful Paint marks the time at which the largest text or image is painted. [Learn More](https://web.dev/largest-contentful-paint)",
+      "description": "Largest Contentful Paint marks the time at which the largest text or image is painted. [Learn More](https://web.dev/lighthouse-largest-contentful-paint)",
       "score": 0.31,
       "scoreDisplayMode": "numeric",
       "numericValue": 4927.278,
@@ -1357,17 +1357,17 @@
           {
             "resourceType": "total",
             "label": "Total",
-            "requestCount": 18,
-            "size": 160738,
-            "countOverBudget": "8 requests",
-            "sizeOverBudget": 58338
+            "requestCount": 17,
+            "size": 160517,
+            "countOverBudget": "7 requests",
+            "sizeOverBudget": 58117
           },
           {
             "resourceType": "other",
             "label": "Other",
-            "requestCount": 2,
-            "size": 12861,
-            "sizeOverBudget": 7741
+            "requestCount": 1,
+            "size": 12640,
+            "sizeOverBudget": 7520
           },
           {
             "resourceType": "third-party",
@@ -1419,7 +1419,7 @@
       "description": "To set budgets for the quantity and size of page resources, add a budget.json file. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/budgets).",
       "score": null,
       "scoreDisplayMode": "informative",
-      "displayValue": "18 requests • 157 KB",
+      "displayValue": "17 requests • 157 KB",
       "details": {
         "type": "table",
         "headings": [
@@ -1443,8 +1443,8 @@
           {
             "resourceType": "total",
             "label": "Total",
-            "requestCount": 18,
-            "size": 160738
+            "requestCount": 17,
+            "size": 160517
           },
           {
             "resourceType": "script",
@@ -1467,8 +1467,8 @@
           {
             "resourceType": "other",
             "label": "Other",
-            "requestCount": 2,
-            "size": 12861
+            "requestCount": 1,
+            "size": 12640
           },
           {
             "resourceType": "stylesheet",
@@ -5109,6 +5109,12 @@
         "duration": 100,
         "entryType": "measure"
       },
+      {
+        "startTime": 0,
+        "name": "lh:computed:UnusedCSS",
+        "duration": 100,
+        "entryType": "measure"
+      },
       {
         "startTime": 0,
         "name": "lh:computed:FirstContentfulPaint",
@@ -5327,8 +5333,17 @@
       "auditGroupExpandTooltip": "Show audits",
       "crcInitialNavigation": "Initial Navigation",
       "crcLongestDurationLabel": "Maximum critical path latency:",
+      "dropdownCopyJSON": "Copy JSON",
+      "dropdownDarkTheme": "Toggle Dark Theme",
+      "dropdownPrintExpanded": "Print Expanded",
+      "dropdownPrintSummary": "Print Summary",
+      "dropdownSaveGist": "Save as Gist",
+      "dropdownSaveHTML": "Save as HTML",
+      "dropdownSaveJSON": "Save as JSON",
+      "dropdownViewer": "Open in Viewer",
       "errorLabel": "Error!",
       "errorMissingAuditInfo": "Report error: no audit information",
+      "footerIssue": "File an issue",
       "labDataTitle": "Lab Data",
       "lsPerformanceCategoryDescription": "[Lighthouse](https://developers.google.com/web/tools/lighthouse/) analysis of the current page on an emulated mobile network. Values are estimated and may vary.",
       "manualAuditsGroupTitle": "Additional items to manually check",
@@ -5336,9 +5351,23 @@
       "opportunityResourceColumnLabel": "Opportunity",
       "opportunitySavingsColumnLabel": "Estimated Savings",
       "passedAuditsGroupTitle": "Passed audits",
+      "runtimeDesktopEmulation": "Emulated Desktop",
+      "runtimeMobileEmulation": "Emulated Nexus 5X",
+      "runtimeNoEmulation": "No emulation",
+      "runtimeSettingsBenchmark": "CPU/Memory Power",
+      "runtimeSettingsCPUThrottling": "CPU throttling",
+      "runtimeSettingsDevice": "Device",
+      "runtimeSettingsFetchTime": "Fetch Time",
+      "runtimeSettingsNetworkThrottling": "Network throttling",
+      "runtimeSettingsTitle": "Runtime Settings",
+      "runtimeSettingsUA": "User agent (host)",
+      "runtimeSettingsUANetwork": "User agent (network)",
+      "runtimeSettingsUrl": "URL",
+      "runtimeUnknown": "Unknown",
       "snippetCollapseButtonLabel": "Collapse snippet",
       "snippetExpandButtonLabel": "Expand snippet",
       "thirdPartyResourcesLabel": "Show 3rd-party resources",
+      "throttlingProvided": "Provided by environment",
       "toplevelWarningsMessage": "There were issues affecting this run of Lighthouse:",
       "varianceDisclaimer": "Values are estimated and may vary. The performance score is [based only on these metrics](https://github.com/GoogleChrome/lighthouse/blob/d2ec9ffbb21de9ad1a0f86ed24575eda32c796f0/docs/scoring.md#how-are-the-scores-weighted).",
       "warningAuditsGroupTitle": "Passed audits but with warnings",
@@ -5761,7 +5790,7 @@
         },
         {
           "values": {
-            "count": 8
+            "count": 7
           },
           "path": "audits[performance-budget].details.items[1].countOverBudget"
         },
@@ -5825,8 +5854,8 @@
       "lighthouse-core/audits/resource-summary.js | displayValue": [
         {
           "values": {
-            "requestCount": 18,
-            "byteCount": 160738
+            "requestCount": 17,
+            "byteCount": 160517
           },
           "path": "audits[resource-summary].displayValue"
         }