Repository: GoogleChrome/lighthouse
Pull request #10215 (2020-01-11 01:29:35)

tests(gather-runner): type check

Akoneto9: https://www.btse.com/en/register?ref=MXW7pm
Add me here

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/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/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/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..43af98db2 100644
--- a/lighthouse-core/lib/i18n/locales/en-US.json
+++ b/lighthouse-core/lib/i18n/locales/en-US.json
@@ -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"
   },
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..e3e25e8d8 100644
--- a/lighthouse-core/lib/i18n/locales/en-XL.json
+++ b/lighthouse-core/lib/i18n/locales/en-XL.json
@@ -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̂"
   },
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/templates.html b/lighthouse-core/report/html/templates.html
index 85e686eae..9100776c5 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>
 
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 356185c9c..7dbdd78c1 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -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",
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];
   };
 


Test patch:
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/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/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/gather/gather-runner-test.js b/lighthouse-core/test/gather/gather-runner-test.js
index 78dd8380b..f324e9889 100644
--- a/lighthouse-core/test/gather/gather-runner-test.js
+++ b/lighthouse-core/test/gather/gather-runner-test.js
@@ -8,7 +8,7 @@
 /* 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');
@@ -21,6 +21,46 @@ 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();
@@ -55,14 +95,20 @@ class EmulationDriver extends Driver {
   registerPerformanceObserver() {
     return Promise.resolve();
   }
-  cleanBrowserCaches() {}
-  clearDataForOrigin() {}
+  cleanBrowserCaches() {
+    return Promise.resolve();
+  }
+  clearDataForOrigin() {
+    return Promise.resolve();
+  }
 }
 
 const fakeDriver = require('./fake-driver.js');
 const fakeDriverUsingRealMobileDevice = fakeDriver.fakeDriverUsingRealMobileDevice;
 
+/** @type {EmulationDriver} */
 let driver;
+/** @type {Connection & {sendCommand: ReturnType<typeof createMockSendCommandFn>}} */
 let connectionStub;
 
 function resetDefaultMockResponses() {
@@ -80,7 +126,9 @@ function resetDefaultMockResponses() {
 }
 
 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`);
   };
@@ -134,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);
@@ -144,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);
@@ -155,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);
@@ -170,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 => {
@@ -184,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},
       });
@@ -196,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},
       });
@@ -208,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},
       });
@@ -220,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},
       });
@@ -234,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, {
@@ -241,7 +294,7 @@ describe('GatherRunner', function() {
             return Promise.resolve({userAgent: userAgent});
           },
         });
-        const config = new Config({
+        const config = makeConfig({
           passes: [],
           settings: {},
         });
@@ -281,6 +334,7 @@ describe('GatherRunner', function() {
   });
 
   it('applies the correct emulation given a particular emulationFormFactor', async () => {
+    /** @param {'mobile'|'desktop'|'none'} formFactor */
     const getSettings = formFactor => ({
       emulatedFormFactor: formFactor,
       internalDisableDeviceScreenEmulation: false,
@@ -326,10 +380,12 @@ describe('GatherRunner', function() {
 
   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();
@@ -358,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();
@@ -381,7 +439,7 @@ describe('GatherRunner', function() {
     };
     const passConfig = {
       passName: 'default',
-      loadFailureMode: 'ignore',
+      loadFailureMode: LoadFailureMode.ignore,
       recordTrace: true,
       useThrottling: true,
       gatherers: [],
@@ -408,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',
@@ -430,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();
   });
 
@@ -440,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',
@@ -462,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();
   });
 
@@ -477,13 +539,14 @@ describe('GatherRunner', function() {
       .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,
@@ -500,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();
@@ -685,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});
       }
@@ -702,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}],
@@ -715,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',
@@ -741,7 +812,7 @@ describe('GatherRunner', function() {
   });
 
   it('respects trace names', () => {
-    const config = new Config({
+    const config = makeConfig({
       passes: [{
         recordTrace: true,
         passName: 'firstPass',
@@ -767,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,
@@ -810,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-`.
@@ -822,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,
@@ -841,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) {
@@ -862,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']);
@@ -876,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();
@@ -889,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)
@@ -897,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/);
@@ -908,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)
@@ -920,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)
@@ -933,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/);
@@ -941,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();
     });
@@ -983,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/);
@@ -999,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/);
@@ -1025,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;
@@ -1041,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);
@@ -1051,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;
@@ -1062,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]};
@@ -1072,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');
     });
   });
@@ -1114,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();
@@ -1175,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',
@@ -1230,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,
         }],
@@ -1247,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: [
@@ -1326,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 () => {
@@ -1342,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);
@@ -1350,7 +1479,7 @@ describe('GatherRunner', function() {
         }
       }
 
-      const config = new Config({
+      const config = makeConfig({
         passes: [{
           gatherers: [{instance: new WarningGatherer()}],
         }],
@@ -1403,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,
         }],
@@ -1417,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',
@@ -1441,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',
@@ -1473,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',
@@ -1506,6 +1635,7 @@ describe('GatherRunner', function() {
 
   describe('.getWebAppManifest', () => {
     const MANIFEST_URL = 'https://example.com/manifest.json';
+    /** @type {RecursivePartial<LH.Gatherer.PassContext>} */
     let passContext;
 
     beforeEach(() => {
@@ -1526,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/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index bafb171f0..6e051dc41 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -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",
@@ -5761,7 +5761,7 @@
         },
         {
           "values": {
-            "count": 8
+            "count": 7
           },
           "path": "audits[performance-budget].details.items[1].countOverBudget"
         },
@@ -5825,8 +5825,8 @@
       "lighthouse-core/audits/resource-summary.js | displayValue": [
         {
           "values": {
-            "requestCount": 18,
-            "byteCount": 160738
+            "requestCount": 17,
+            "byteCount": 160517
           },
           "path": "audits[resource-summary].displayValue"
         }